mirror of
https://github.com/librespot-org/librespot.git
synced 2024-12-18 17:11:53 +00:00
Spirc: Replace Mecury with Dealer (#1356)
This was a huge effort by photovoltex@gmail.com with help from the community. Over 140 commits were squashed. Below, their commit messages are kept unchanged. --- * dealer wrapper for ease of use * improve sending protobuf requests * replace connect config with connect_state config * start integrating dealer into spirc * payload handling, gzip support * put connect state consistent * formatting * request payload handling, gzip support * expose dealer::protocol, move request in own file * integrate handle of connect-state commands * spirc: remove ident field * transfer playing state better * spirc: remove remote_update stream * spirc: replace command sender with connect state update * spirc: remove device state and remaining unused methods * spirc: remove mercury sender * add repeat track state * ConnectState: add methods to replace state in spirc * spirc: move context into connect_state, update load and next * spirc: remove state, adjust remaining methods * spirc: handle more dealer request commands * revert rustfmt.toml * spirc: impl shuffle - impl shuffle again - extracted fill up of next tracks in own method - moved queue revision update into next track fill up - removed unused method `set_playing_track_index` - added option to specify index when resetting the playback context - reshuffle after repeat context * spirc: handle device became inactive * dealer: adjust payload handling * spirc: better set volume handling * dealer: box PlayCommand (clippy warning) * dealer: always respect queued tracks * spirc: update duration of track * ConnectState: update more restrictions * cleanup * spirc: handle queue requests * spirc: skip next with track * proto: exclude spirc.proto - move "deserialize_with" functions into own file - replace TrackRef with ProvidedTrack * spirc: stabilize transfer/context handling * core: cleanup some remains * connect: improvements to code structure and performance - use VecDeque for next and prev tracks * connect: delayed volume update * connect: move context resolve into own function * connect: load context asynchronous * connect: handle reconnect - might currently steal the active devices playback * connect: some fixes and adjustments - fix wrong offset when transferring playback - fix missing displayed context in web-player - remove access_token from log - send correct state reason when updating volume - queue track correctly - fix wrong assumption for skip_to * connect: replace error case with option * connect: use own context state * connect: more stabilising - handle SkipTo having no Index - handle no transferred restrictions - handle no transferred index - update state before shutdown, for smoother reacquiring * connect: working autoplay * connect: handle repeat context/track * connect: some quick fixes - found self-named uid in collection after reconnecting * connect: handle add_to_queue via set_queue * fix clippy warnings * fix check errors, fix/update example * fix 1.75 specific error * connect: position update improvements * connect: handle unavailable * connect: fix incorrect status handling for desktop and mobile * core: fix dealer reconnect - actually acquire new token - use login5 token retrieval * connect: split state into multiple files * connect: encapsulate provider logic * connect: remove public access to next and prev tracks * connect: remove public access to player * connect: move state only commands into own file * connect: improve logging * connect: handle transferred queue again * connect: fix all-features specific error * connect: extract transfer handling into own file * connect: remove old context model * connect: handle more transfer cases correctly * connect: do auth_token pre-acquiring earlier * connect: handle play with skip_to by uid * connect: simplified cluster update log * core/connect: add remaining set value commands * connect: position update workaround/fix * connect: some queue cleanups * connect: add uid to queue * connect: duration as volume delay const * connect: some adjustments and todo cleanups - send volume update before general update - simplify queue revision to use the track uri - argument why copying the prev/next tracks is fine * connect: handle shuffle from set_options * connect: handle context update * connect: move other structs into model.rs * connect: reduce SpircCommand visibility * connect: fix visibility of model * connect: fix: shuffle on startup isn't applied * connect: prevent loading a context with no tracks * connect: use the first page of a context * connect: improve context resolving - support multiple pages - support page_url of context - handle single track * connect: prevent integer underflow * connect: rename method for better clarity * connect: handle mutate and update messages * connect: fix 1.75 problems * connect: fill, instead of replace next page * connect: reduce context update to single method * connect: remove unused SpircError, handle local files * connect: reduce nesting, adjust initial transfer handling * connect: don't update volume initially * core: disable trace logging of handled mercury responses * core/connect: prevent takeover from other clients, handle session-update * connect: add queue-uid for set_queue command * connect: adjust fields for PlayCommand * connect: preserve context position after update_context * connect: unify metadata modification - only handle `is_queued` `true` items for queue * connect: polish request command handling - reply to all request endpoints - adjust some naming - add some docs * connect: add uid to tracks without * connect: simpler update of current index * core/connect: update log msg, fix wrong behavior - handle became inactive separately - remove duplicate stop - adjust docs for websocket request * core: add option to request without metrics and salt * core/context: adjust context requests and update - search should now return the expected context - removed workaround for single track playback - move local playback check into update_context - check track uri for invalid characters - early return with `?` * connect: handle possible search context uri * connect: remove logout support - handle logout command - disable support for logout - add todos for logout * connect: adjust detailed tracks/context handling - always allow next - handle no prev track available - separate active and fill up context * connect: adjust context resolve handling, again * connect: add autoplay metadata to tracks - transfer into autoplay again * core/connect: cleanup session after spirc stops * update CHANGELOG.md * playback: fix clippy warnings * connect: adjust metadata - unify naming - move more metadata infos into metadata.rs * connect: add delimiter between context and autoplay playback * connect: stop and resume correctly * connect: adjust context resolving - improved certain logging parts - preload autoplay when autoplay attribute mutates - fix transfer context uri - fix typo - handle empty strings for resolve uri - fix unexpected stop of playback * connect: ignore failure during stop * connect: revert resolve_uri changes * connect: correct context reset * connect: reduce boiler code * connect: fix some incorrect states - uid getting replaced by empty value - shuffle/repeat clearing autoplay context - fill_up updating and using incorrect index * core: adjust incorrect separator * connect: move `add_to_queue` and `mark_unavailable` into tracks.rs * connect: refactor - directly modify PutStateRequest - replace `next_tracks`, `prev_tracks`, `player` and `device` with `request` - provide helper methods for the removed fields * connect: adjust handling of context metadata/restrictions * connect: fix incorrect context states * connect: become inactive when no cluster is reported * update CHANGELOG.md * core/playback: preemptively fix clippy warnings * connect: minor adjustment to session changed * connect: change return type changing active context * connect: handle unavailable contexts * connect: fix previous restrictions blocking load with shuffle * connect: update comments and logging * core/connect: reduce some more duplicate code * more docs around the dealer
This commit is contained in:
parent
f646ef2b5a
commit
5839b36192
43 changed files with 4229 additions and 1283 deletions
|
@ -9,8 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- [connect] Replaced `ConnectConfig` with `ConnectStateConfig` (breaking)
|
||||||
|
- [connect] Replaced `playing_track_index` field of `SpircLoadCommand` with `playing_track` (breaking)
|
||||||
|
- [connect] Replaced Mercury usage in `Spirc` with Dealer
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- [connect] Add `seek_to` field to `SpircLoadCommand` (breaking)
|
||||||
|
- [connect] Add `repeat_track` field to `SpircLoadCommand` (breaking)
|
||||||
|
- [playback] Add `track` field to `PlayerEvent::RepeatChanged` (breaking)
|
||||||
|
- [core] Add `request_with_options` and `request_with_protobuf_and_options` to `SpClient`
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- [core] Fix "no native root CA certificates found" on platforms unsupported
|
- [core] Fix "no native root CA certificates found" on platforms unsupported
|
||||||
|
|
35
Cargo.lock
generated
35
Cargo.lock
generated
|
@ -630,6 +630,15 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crc32fast"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.20"
|
version = "0.8.20"
|
||||||
|
@ -894,6 +903,16 @@ version = "0.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.0.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fnv"
|
name = "fnv"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
|
@ -1941,7 +1960,6 @@ dependencies = [
|
||||||
name = "librespot-connect"
|
name = "librespot-connect"
|
||||||
version = "0.6.0-dev"
|
version = "0.6.0-dev"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"form_urlencoded",
|
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"librespot-core",
|
"librespot-core",
|
||||||
"librespot-playback",
|
"librespot-playback",
|
||||||
|
@ -1949,11 +1967,11 @@ dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"protobuf",
|
"protobuf",
|
||||||
"rand",
|
"rand",
|
||||||
"serde",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1965,6 +1983,7 @@ dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
|
"flate2",
|
||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
@ -1991,6 +2010,7 @@ dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"priority-queue",
|
"priority-queue",
|
||||||
"protobuf",
|
"protobuf",
|
||||||
|
"protobuf-json-mapping",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"rand",
|
"rand",
|
||||||
"rsa",
|
"rsa",
|
||||||
|
@ -2744,6 +2764,17 @@ dependencies = [
|
||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "protobuf-json-mapping"
|
||||||
|
version = "3.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b445cf83c9303695e6c423d269759e139b6182d2f1171e18afda7078a764336"
|
||||||
|
dependencies = [
|
||||||
|
"protobuf",
|
||||||
|
"protobuf-support",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "protobuf-parse"
|
name = "protobuf-parse"
|
||||||
version = "3.7.1"
|
version = "3.7.1"
|
||||||
|
|
|
@ -9,16 +9,15 @@ repository = "https://github.com/librespot-org/librespot"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
form_urlencoded = "1.0"
|
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
protobuf = "3.5"
|
protobuf = "3.5"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
tokio = { version = "1", features = ["macros", "parking_lot", "sync"] }
|
tokio = { version = "1", features = ["macros", "parking_lot", "sync"] }
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
|
uuid = { version = "1.11.0", features = ["v4"] }
|
||||||
|
|
||||||
[dependencies.librespot-core]
|
[dependencies.librespot-core]
|
||||||
path = "../core"
|
path = "../core"
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
use crate::core::config::DeviceType;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct ConnectConfig {
|
|
||||||
pub name: String,
|
|
||||||
pub device_type: DeviceType,
|
|
||||||
pub is_group: bool,
|
|
||||||
pub initial_volume: Option<u16>,
|
|
||||||
pub has_volume_ctrl: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ConnectConfig {
|
|
||||||
fn default() -> ConnectConfig {
|
|
||||||
ConnectConfig {
|
|
||||||
name: "Librespot".to_string(),
|
|
||||||
device_type: DeviceType::default(),
|
|
||||||
is_group: false,
|
|
||||||
initial_volume: Some(50),
|
|
||||||
has_volume_ctrl: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,121 +0,0 @@
|
||||||
// TODO : move to metadata
|
|
||||||
|
|
||||||
use crate::core::spotify_id::SpotifyId;
|
|
||||||
use crate::protocol::spirc::TrackRef;
|
|
||||||
|
|
||||||
use serde::{
|
|
||||||
de::{Error, Unexpected},
|
|
||||||
Deserialize,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Default, Clone)]
|
|
||||||
pub struct StationContext {
|
|
||||||
pub uri: String,
|
|
||||||
pub title: String,
|
|
||||||
#[serde(rename = "titleUri")]
|
|
||||||
pub title_uri: String,
|
|
||||||
pub subtitles: Vec<SubtitleContext>,
|
|
||||||
#[serde(rename = "imageUri")]
|
|
||||||
pub image_uri: String,
|
|
||||||
pub seeds: Vec<String>,
|
|
||||||
#[serde(deserialize_with = "deserialize_protobuf_TrackRef")]
|
|
||||||
pub tracks: Vec<TrackRef>,
|
|
||||||
pub next_page_url: String,
|
|
||||||
pub correlation_id: String,
|
|
||||||
pub related_artists: Vec<ArtistContext>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Default, Clone)]
|
|
||||||
pub struct PageContext {
|
|
||||||
#[serde(deserialize_with = "deserialize_protobuf_TrackRef")]
|
|
||||||
pub tracks: Vec<TrackRef>,
|
|
||||||
pub next_page_url: String,
|
|
||||||
pub correlation_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Default, Clone)]
|
|
||||||
pub struct TrackContext {
|
|
||||||
pub uri: String,
|
|
||||||
pub uid: String,
|
|
||||||
pub artist_uri: String,
|
|
||||||
pub album_uri: String,
|
|
||||||
#[serde(rename = "original_gid")]
|
|
||||||
pub gid: String,
|
|
||||||
pub metadata: MetadataContext,
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Deserialize, Debug, Default, Clone)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct ArtistContext {
|
|
||||||
#[serde(rename = "artistName")]
|
|
||||||
artist_name: String,
|
|
||||||
#[serde(rename = "imageUri")]
|
|
||||||
image_uri: String,
|
|
||||||
#[serde(rename = "artistUri")]
|
|
||||||
artist_uri: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Deserialize, Debug, Default, Clone)]
|
|
||||||
pub struct MetadataContext {
|
|
||||||
album_title: String,
|
|
||||||
artist_name: String,
|
|
||||||
artist_uri: String,
|
|
||||||
image_url: String,
|
|
||||||
title: String,
|
|
||||||
#[serde(deserialize_with = "bool_from_string")]
|
|
||||||
is_explicit: bool,
|
|
||||||
#[serde(deserialize_with = "bool_from_string")]
|
|
||||||
is_promotional: bool,
|
|
||||||
decision_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[derive(Deserialize, Debug, Default, Clone)]
|
|
||||||
pub struct SubtitleContext {
|
|
||||||
name: String,
|
|
||||||
uri: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bool_from_string<'de, D>(de: D) -> Result<bool, D::Error>
|
|
||||||
where
|
|
||||||
D: serde::Deserializer<'de>,
|
|
||||||
{
|
|
||||||
match String::deserialize(de)?.as_ref() {
|
|
||||||
"true" => Ok(true),
|
|
||||||
"false" => Ok(false),
|
|
||||||
other => Err(D::Error::invalid_value(
|
|
||||||
Unexpected::Str(other),
|
|
||||||
&"true or false",
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(non_snake_case)]
|
|
||||||
fn deserialize_protobuf_TrackRef<'d, D>(de: D) -> Result<Vec<TrackRef>, D::Error>
|
|
||||||
where
|
|
||||||
D: serde::Deserializer<'d>,
|
|
||||||
{
|
|
||||||
let v: Vec<TrackContext> = serde::Deserialize::deserialize(de)?;
|
|
||||||
v.iter()
|
|
||||||
.map(|v| {
|
|
||||||
let mut t = TrackRef::new();
|
|
||||||
// This has got to be the most round about way of doing this.
|
|
||||||
t.set_gid(
|
|
||||||
SpotifyId::from_base62(&v.gid)
|
|
||||||
.map_err(|_| {
|
|
||||||
D::Error::invalid_value(
|
|
||||||
Unexpected::Str(&v.gid),
|
|
||||||
&"a Base-62 encoded Spotify ID",
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
.to_raw()
|
|
||||||
.to_vec(),
|
|
||||||
);
|
|
||||||
t.set_uri(v.uri.to_owned());
|
|
||||||
Ok(t)
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<TrackRef>, D::Error>>()
|
|
||||||
}
|
|
|
@ -5,6 +5,6 @@ use librespot_core as core;
|
||||||
use librespot_playback as playback;
|
use librespot_playback as playback;
|
||||||
use librespot_protocol as protocol;
|
use librespot_protocol as protocol;
|
||||||
|
|
||||||
pub mod config;
|
mod model;
|
||||||
pub mod context;
|
|
||||||
pub mod spirc;
|
pub mod spirc;
|
||||||
|
pub mod state;
|
||||||
|
|
188
connect/src/model.rs
Normal file
188
connect/src/model.rs
Normal file
|
@ -0,0 +1,188 @@
|
||||||
|
use crate::state::ConnectState;
|
||||||
|
use librespot_core::dealer::protocol::SkipTo;
|
||||||
|
use librespot_protocol::player::Context;
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SpircLoadCommand {
|
||||||
|
pub context_uri: String,
|
||||||
|
/// Whether the given tracks should immediately start playing, or just be initially loaded.
|
||||||
|
pub start_playing: bool,
|
||||||
|
pub seek_to: u32,
|
||||||
|
pub shuffle: bool,
|
||||||
|
pub repeat: bool,
|
||||||
|
pub repeat_track: bool,
|
||||||
|
pub playing_track: PlayingTrack,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum PlayingTrack {
|
||||||
|
Index(u32),
|
||||||
|
Uri(String),
|
||||||
|
Uid(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SkipTo> for PlayingTrack {
|
||||||
|
fn from(value: SkipTo) -> Self {
|
||||||
|
// order of checks is important, as the index can be 0, but still has an uid or uri provided,
|
||||||
|
// so we only use the index as last resort
|
||||||
|
if let Some(uri) = value.track_uri {
|
||||||
|
PlayingTrack::Uri(uri)
|
||||||
|
} else if let Some(uid) = value.track_uid {
|
||||||
|
PlayingTrack::Uid(uid)
|
||||||
|
} else {
|
||||||
|
PlayingTrack::Index(value.track_index.unwrap_or_else(|| {
|
||||||
|
warn!("SkipTo didn't provided any point to skip to, falling back to index 0");
|
||||||
|
0
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) enum SpircPlayStatus {
|
||||||
|
Stopped,
|
||||||
|
LoadingPlay {
|
||||||
|
position_ms: u32,
|
||||||
|
},
|
||||||
|
LoadingPause {
|
||||||
|
position_ms: u32,
|
||||||
|
},
|
||||||
|
Playing {
|
||||||
|
nominal_start_time: i64,
|
||||||
|
preloading_of_next_track_triggered: bool,
|
||||||
|
},
|
||||||
|
Paused {
|
||||||
|
position_ms: u32,
|
||||||
|
preloading_of_next_track_triggered: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(super) struct ResolveContext {
|
||||||
|
context: Context,
|
||||||
|
fallback: Option<String>,
|
||||||
|
autoplay: bool,
|
||||||
|
/// if `true` updates the entire context, otherwise only fills the context from the next
|
||||||
|
/// retrieve page, it is usually used when loading the next page of an already established context
|
||||||
|
///
|
||||||
|
/// like for example:
|
||||||
|
/// - playing an artists profile
|
||||||
|
update: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolveContext {
|
||||||
|
pub fn from_uri(uri: impl Into<String>, fallback: impl Into<String>, autoplay: bool) -> Self {
|
||||||
|
let fallback_uri = fallback.into();
|
||||||
|
Self {
|
||||||
|
context: Context {
|
||||||
|
uri: uri.into(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
fallback: (!fallback_uri.is_empty()).then_some(fallback_uri),
|
||||||
|
autoplay,
|
||||||
|
update: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_context(context: Context, autoplay: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
context,
|
||||||
|
fallback: None,
|
||||||
|
autoplay,
|
||||||
|
update: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// expected page_url: hm://artistplaycontext/v1/page/spotify/album/5LFzwirfFwBKXJQGfwmiMY/km_artist
|
||||||
|
pub fn from_page_url(page_url: String) -> Self {
|
||||||
|
let split = if let Some(rest) = page_url.strip_prefix("hm://") {
|
||||||
|
rest.split('/')
|
||||||
|
} else {
|
||||||
|
warn!("page_url didn't started with hm://. got page_url: {page_url}");
|
||||||
|
page_url.split('/')
|
||||||
|
};
|
||||||
|
|
||||||
|
let uri = split
|
||||||
|
.skip_while(|s| s != &"spotify")
|
||||||
|
.take(3)
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
.join(":");
|
||||||
|
|
||||||
|
trace!("created an ResolveContext from page_url <{page_url}> as uri <{uri}>");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
context: Context {
|
||||||
|
uri,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
fallback: None,
|
||||||
|
update: false,
|
||||||
|
autoplay: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// the uri which should be used to resolve the context, might not be the context uri
|
||||||
|
pub fn resolve_uri(&self) -> Option<&String> {
|
||||||
|
// it's important to call this always, or at least for every ResolveContext
|
||||||
|
// otherwise we might not even check if we need to fallback and just use the fallback uri
|
||||||
|
ConnectState::get_context_uri_from_context(&self.context)
|
||||||
|
.and_then(|s| (!s.is_empty()).then_some(s))
|
||||||
|
.or(self.fallback.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// the actual context uri
|
||||||
|
pub fn context_uri(&self) -> &str {
|
||||||
|
&self.context.uri
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn autoplay(&self) -> bool {
|
||||||
|
self.autoplay
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&self) -> bool {
|
||||||
|
self.update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for ResolveContext {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"resolve_uri: <{:?}>, context_uri: <{}>, autoplay: <{}>, update: <{}>",
|
||||||
|
self.resolve_uri(),
|
||||||
|
self.context.uri,
|
||||||
|
self.autoplay,
|
||||||
|
self.update
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for ResolveContext {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
let eq_context = self.context_uri() == other.context_uri();
|
||||||
|
let eq_resolve = self.resolve_uri() == other.resolve_uri();
|
||||||
|
let eq_autoplay = self.autoplay == other.autoplay;
|
||||||
|
let eq_update = self.update == other.update;
|
||||||
|
|
||||||
|
eq_context && eq_resolve && eq_autoplay && eq_update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Eq for ResolveContext {}
|
||||||
|
|
||||||
|
impl Hash for ResolveContext {
|
||||||
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||||
|
self.context_uri().hash(state);
|
||||||
|
self.resolve_uri().hash(state);
|
||||||
|
self.autoplay.hash(state);
|
||||||
|
self.update.hash(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ResolveContext> for Context {
|
||||||
|
fn from(value: ResolveContext) -> Self {
|
||||||
|
value.context
|
||||||
|
}
|
||||||
|
}
|
1963
connect/src/spirc.rs
1963
connect/src/spirc.rs
File diff suppressed because it is too large
Load diff
448
connect/src/state.rs
Normal file
448
connect/src/state.rs
Normal file
|
@ -0,0 +1,448 @@
|
||||||
|
pub(super) mod context;
|
||||||
|
mod handle;
|
||||||
|
pub mod metadata;
|
||||||
|
mod options;
|
||||||
|
pub(super) mod provider;
|
||||||
|
mod restrictions;
|
||||||
|
mod tracks;
|
||||||
|
mod transfer;
|
||||||
|
|
||||||
|
use crate::model::SpircPlayStatus;
|
||||||
|
use crate::state::{
|
||||||
|
context::{ContextType, ResetContext, StateContext},
|
||||||
|
provider::{IsProvider, Provider},
|
||||||
|
};
|
||||||
|
use librespot_core::{
|
||||||
|
config::DeviceType, date::Date, dealer::protocol::Request, spclient::SpClientResult, version,
|
||||||
|
Error, Session,
|
||||||
|
};
|
||||||
|
use librespot_protocol::connect::{
|
||||||
|
Capabilities, Device, DeviceInfo, MemberType, PutStateReason, PutStateRequest,
|
||||||
|
};
|
||||||
|
use librespot_protocol::player::{
|
||||||
|
ContextIndex, ContextPage, ContextPlayerOptions, PlayOrigin, PlayerState, ProvidedTrack,
|
||||||
|
Suppressions,
|
||||||
|
};
|
||||||
|
use log::LevelFilter;
|
||||||
|
use protobuf::{EnumOrUnknown, MessageField};
|
||||||
|
use std::{
|
||||||
|
collections::hash_map::DefaultHasher,
|
||||||
|
hash::{Hash, Hasher},
|
||||||
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
// these limitations are essential, otherwise to many tracks will overload the web-player
|
||||||
|
const SPOTIFY_MAX_PREV_TRACKS_SIZE: usize = 10;
|
||||||
|
const SPOTIFY_MAX_NEXT_TRACKS_SIZE: usize = 80;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum StateError {
|
||||||
|
#[error("the current track couldn't be resolved from the transfer state")]
|
||||||
|
CouldNotResolveTrackFromTransfer,
|
||||||
|
#[error("message field {0} was not available")]
|
||||||
|
MessageFieldNone(String),
|
||||||
|
#[error("context is not available. type: {0:?}")]
|
||||||
|
NoContext(ContextType),
|
||||||
|
#[error("could not find track {0:?} in context of {1}")]
|
||||||
|
CanNotFindTrackInContext(Option<usize>, usize),
|
||||||
|
#[error("currently {action} is not allowed because {reason}")]
|
||||||
|
CurrentlyDisallowed { action: String, reason: String },
|
||||||
|
#[error("the provided context has no tracks")]
|
||||||
|
ContextHasNoTracks,
|
||||||
|
#[error("playback of local files is not supported")]
|
||||||
|
UnsupportedLocalPlayBack,
|
||||||
|
#[error("track uri <{0}> contains invalid characters")]
|
||||||
|
InvalidTrackUri(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StateError> for Error {
|
||||||
|
fn from(err: StateError) -> Self {
|
||||||
|
use StateError::*;
|
||||||
|
match err {
|
||||||
|
CouldNotResolveTrackFromTransfer
|
||||||
|
| MessageFieldNone(_)
|
||||||
|
| NoContext(_)
|
||||||
|
| CanNotFindTrackInContext(_, _)
|
||||||
|
| ContextHasNoTracks
|
||||||
|
| InvalidTrackUri(_) => Error::failed_precondition(err),
|
||||||
|
CurrentlyDisallowed { .. } | UnsupportedLocalPlayBack => Error::unavailable(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ConnectStateConfig {
|
||||||
|
pub session_id: String,
|
||||||
|
pub initial_volume: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub device_type: DeviceType,
|
||||||
|
pub volume_steps: i32,
|
||||||
|
pub is_group: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConnectStateConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
session_id: String::new(),
|
||||||
|
initial_volume: u32::from(u16::MAX) / 2,
|
||||||
|
name: "librespot".to_string(),
|
||||||
|
device_type: DeviceType::Speaker,
|
||||||
|
volume_steps: 64,
|
||||||
|
is_group: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct ConnectState {
|
||||||
|
/// the entire state that is updated to the remote server
|
||||||
|
request: PutStateRequest,
|
||||||
|
|
||||||
|
unavailable_uri: Vec<String>,
|
||||||
|
|
||||||
|
pub active_since: Option<SystemTime>,
|
||||||
|
queue_count: u64,
|
||||||
|
|
||||||
|
// separation is necessary because we could have already loaded
|
||||||
|
// the autoplay context but are still playing from the default context
|
||||||
|
/// to update the active context use [switch_active_context](ConnectState::set_active_context)
|
||||||
|
pub active_context: ContextType,
|
||||||
|
pub fill_up_context: ContextType,
|
||||||
|
|
||||||
|
/// the context from which we play, is used to top up prev and next tracks
|
||||||
|
pub context: Option<StateContext>,
|
||||||
|
/// upcoming contexts, directly provided by the context-resolver
|
||||||
|
next_contexts: Vec<ContextPage>,
|
||||||
|
|
||||||
|
/// a context to keep track of our shuffled context,
|
||||||
|
/// should be only available when `player.option.shuffling_context` is true
|
||||||
|
shuffle_context: Option<StateContext>,
|
||||||
|
/// a context to keep track of the autoplay context
|
||||||
|
autoplay_context: Option<StateContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectState {
|
||||||
|
pub fn new(cfg: ConnectStateConfig, session: &Session) -> Self {
|
||||||
|
let device_info = DeviceInfo {
|
||||||
|
can_play: true,
|
||||||
|
volume: cfg.initial_volume,
|
||||||
|
name: cfg.name,
|
||||||
|
device_id: session.device_id().to_string(),
|
||||||
|
device_type: EnumOrUnknown::new(cfg.device_type.into()),
|
||||||
|
device_software_version: version::SEMVER.to_string(),
|
||||||
|
spirc_version: version::SPOTIFY_SPIRC_VERSION.to_string(),
|
||||||
|
client_id: session.client_id(),
|
||||||
|
is_group: cfg.is_group,
|
||||||
|
capabilities: MessageField::some(Capabilities {
|
||||||
|
volume_steps: cfg.volume_steps,
|
||||||
|
hidden: false, // could be exposed later to only observe the playback
|
||||||
|
gaia_eq_connect_id: true,
|
||||||
|
can_be_player: true,
|
||||||
|
|
||||||
|
needs_full_player_state: true,
|
||||||
|
|
||||||
|
is_observable: true,
|
||||||
|
is_controllable: true,
|
||||||
|
|
||||||
|
supports_gzip_pushes: true,
|
||||||
|
// todo: enable after logout handling is implemented, see spirc logout_request
|
||||||
|
supports_logout: false,
|
||||||
|
supported_types: vec!["audio/episode".into(), "audio/track".into()],
|
||||||
|
supports_playlist_v2: true,
|
||||||
|
supports_transfer_command: true,
|
||||||
|
supports_command_request: true,
|
||||||
|
supports_set_options_command: true,
|
||||||
|
|
||||||
|
is_voice_enabled: false,
|
||||||
|
restrict_to_local: false,
|
||||||
|
disable_volume: false,
|
||||||
|
connect_disabled: false,
|
||||||
|
supports_rename: false,
|
||||||
|
supports_external_episodes: false,
|
||||||
|
supports_set_backend_metadata: false,
|
||||||
|
supports_hifi: MessageField::none(),
|
||||||
|
|
||||||
|
command_acks: true,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut state = Self {
|
||||||
|
request: PutStateRequest {
|
||||||
|
member_type: EnumOrUnknown::new(MemberType::CONNECT_STATE),
|
||||||
|
put_state_reason: EnumOrUnknown::new(PutStateReason::PLAYER_STATE_CHANGED),
|
||||||
|
device: MessageField::some(Device {
|
||||||
|
device_info: MessageField::some(device_info),
|
||||||
|
player_state: MessageField::some(PlayerState {
|
||||||
|
session_id: cfg.session_id,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.reset();
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.set_active(false);
|
||||||
|
self.queue_count = 0;
|
||||||
|
|
||||||
|
// preserve the session_id
|
||||||
|
let session_id = self.player().session_id.clone();
|
||||||
|
|
||||||
|
self.device_mut().player_state = MessageField::some(PlayerState {
|
||||||
|
session_id,
|
||||||
|
is_system_initiated: true,
|
||||||
|
playback_speed: 1.,
|
||||||
|
play_origin: MessageField::some(PlayOrigin::new()),
|
||||||
|
suppressions: MessageField::some(Suppressions::new()),
|
||||||
|
options: MessageField::some(ContextPlayerOptions::new()),
|
||||||
|
// + 1, so that we have a buffer where we can swap elements
|
||||||
|
prev_tracks: Vec::with_capacity(SPOTIFY_MAX_PREV_TRACKS_SIZE + 1),
|
||||||
|
next_tracks: Vec::with_capacity(SPOTIFY_MAX_NEXT_TRACKS_SIZE + 1),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_mut(&mut self) -> &mut Device {
|
||||||
|
self.request
|
||||||
|
.device
|
||||||
|
.as_mut()
|
||||||
|
.expect("the request is always available")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn player_mut(&mut self) -> &mut PlayerState {
|
||||||
|
self.device_mut()
|
||||||
|
.player_state
|
||||||
|
.as_mut()
|
||||||
|
.expect("the player_state has to be always given")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn device_info(&self) -> &DeviceInfo {
|
||||||
|
&self.request.device.device_info
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn player(&self) -> &PlayerState {
|
||||||
|
&self.request.device.player_state
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.request.is_active
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_volume(&mut self, volume: u32) {
|
||||||
|
self.device_mut()
|
||||||
|
.device_info
|
||||||
|
.as_mut()
|
||||||
|
.expect("the device_info has to be always given")
|
||||||
|
.volume = volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_last_command(&mut self, command: Request) {
|
||||||
|
self.request.last_command_message_id = command.message_id;
|
||||||
|
self.request.last_command_sent_by_device_id = command.sent_by_device_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_now(&mut self, now: u64) {
|
||||||
|
self.request.client_side_timestamp = now;
|
||||||
|
|
||||||
|
if let Some(active_since) = self.active_since {
|
||||||
|
if let Ok(active_since_duration) = active_since.duration_since(UNIX_EPOCH) {
|
||||||
|
match active_since_duration.as_millis().try_into() {
|
||||||
|
Ok(active_since_ms) => self.request.started_playing_at = active_since_ms,
|
||||||
|
Err(why) => warn!("couldn't update active since because {why}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_active(&mut self, value: bool) {
|
||||||
|
if value {
|
||||||
|
if self.request.is_active {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.request.is_active = true;
|
||||||
|
self.active_since = Some(SystemTime::now())
|
||||||
|
} else {
|
||||||
|
self.request.is_active = false;
|
||||||
|
self.active_since = None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_origin(&mut self, origin: PlayOrigin) {
|
||||||
|
self.player_mut().play_origin = MessageField::some(origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_session_id(&mut self, session_id: String) {
|
||||||
|
self.player_mut().session_id = session_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_status(&mut self, status: &SpircPlayStatus) {
|
||||||
|
let player = self.player_mut();
|
||||||
|
player.is_paused = matches!(
|
||||||
|
status,
|
||||||
|
SpircPlayStatus::LoadingPause { .. }
|
||||||
|
| SpircPlayStatus::Paused { .. }
|
||||||
|
| SpircPlayStatus::Stopped
|
||||||
|
);
|
||||||
|
|
||||||
|
// desktop and mobile require all 'states' set to true, when we are paused,
|
||||||
|
// otherwise the play button (desktop) is grayed out or the preview (mobile) can't be opened
|
||||||
|
player.is_buffering = player.is_paused
|
||||||
|
|| matches!(
|
||||||
|
status,
|
||||||
|
SpircPlayStatus::LoadingPause { .. } | SpircPlayStatus::LoadingPlay { .. }
|
||||||
|
);
|
||||||
|
player.is_playing = player.is_paused
|
||||||
|
|| matches!(
|
||||||
|
status,
|
||||||
|
SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::Playing { .. }
|
||||||
|
);
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"updated connect play status playing: {}, paused: {}, buffering: {}",
|
||||||
|
player.is_playing, player.is_paused, player.is_buffering
|
||||||
|
);
|
||||||
|
|
||||||
|
self.update_restrictions()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// index is 0 based, so the first track is index 0
|
||||||
|
pub fn update_current_index(&mut self, f: impl Fn(&mut ContextIndex)) {
|
||||||
|
match self.player_mut().index.as_mut() {
|
||||||
|
Some(player_index) => f(player_index),
|
||||||
|
None => {
|
||||||
|
let mut new_index = ContextIndex::new();
|
||||||
|
f(&mut new_index);
|
||||||
|
self.player_mut().index = MessageField::some(new_index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_position(&mut self, position_ms: u32, timestamp: i64) {
|
||||||
|
let player = self.player_mut();
|
||||||
|
player.position_as_of_timestamp = position_ms.into();
|
||||||
|
player.timestamp = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_duration(&mut self, duration: u32) {
|
||||||
|
self.player_mut().duration = duration.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_queue_revision(&mut self) {
|
||||||
|
let mut state = DefaultHasher::new();
|
||||||
|
self.next_tracks()
|
||||||
|
.iter()
|
||||||
|
.for_each(|t| t.uri.hash(&mut state));
|
||||||
|
self.player_mut().queue_revision = state.finish().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_playback_to_position(&mut self, new_index: Option<usize>) -> Result<(), Error> {
|
||||||
|
let new_index = new_index.unwrap_or(0);
|
||||||
|
self.update_current_index(|i| i.track = new_index as u32);
|
||||||
|
self.update_context_index(self.active_context, new_index + 1)?;
|
||||||
|
|
||||||
|
if !self.current_track(|t| t.is_queue()) {
|
||||||
|
self.set_current_track(new_index)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.clear_prev_track();
|
||||||
|
|
||||||
|
if new_index > 0 {
|
||||||
|
let context = self.get_context(&self.active_context)?;
|
||||||
|
|
||||||
|
let before_new_track = context.tracks.len() - new_index;
|
||||||
|
self.player_mut().prev_tracks = context
|
||||||
|
.tracks
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.skip(before_new_track)
|
||||||
|
.take(SPOTIFY_MAX_PREV_TRACKS_SIZE)
|
||||||
|
.rev()
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
debug!("has {} prev tracks", self.prev_tracks().len())
|
||||||
|
}
|
||||||
|
|
||||||
|
self.clear_next_tracks(true);
|
||||||
|
self.fill_up_next_tracks()?;
|
||||||
|
self.update_restrictions();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_as_unavailable_for_match(track: &mut ProvidedTrack, uri: &str) {
|
||||||
|
if track.uri == uri {
|
||||||
|
debug!("Marked <{}:{}> as unavailable", track.provider, track.uri);
|
||||||
|
track.set_provider(Provider::Unavailable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_position_in_relation(&mut self, timestamp: i64) {
|
||||||
|
let player = self.player_mut();
|
||||||
|
|
||||||
|
let diff = timestamp - player.timestamp;
|
||||||
|
player.position_as_of_timestamp += diff;
|
||||||
|
|
||||||
|
if log::max_level() >= LevelFilter::Debug {
|
||||||
|
let pos = Duration::from_millis(player.position_as_of_timestamp as u64);
|
||||||
|
let time = Date::from_timestamp_ms(timestamp)
|
||||||
|
.map(|d| d.time().to_string())
|
||||||
|
.unwrap_or_else(|_| timestamp.to_string());
|
||||||
|
|
||||||
|
let sec = pos.as_secs();
|
||||||
|
let (min, sec) = (sec / 60, sec % 60);
|
||||||
|
debug!("update position to {min}:{sec:0>2} at {time}");
|
||||||
|
}
|
||||||
|
|
||||||
|
player.timestamp = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn became_inactive(&mut self, session: &Session) -> SpClientResult {
|
||||||
|
self.reset();
|
||||||
|
self.reset_context(ResetContext::Completely);
|
||||||
|
|
||||||
|
session.spclient().put_connect_state_inactive(false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_with_reason(
|
||||||
|
&mut self,
|
||||||
|
session: &Session,
|
||||||
|
reason: PutStateReason,
|
||||||
|
) -> SpClientResult {
|
||||||
|
let prev_reason = self.request.put_state_reason;
|
||||||
|
|
||||||
|
self.request.put_state_reason = EnumOrUnknown::new(reason);
|
||||||
|
let res = self.send_state(session).await;
|
||||||
|
|
||||||
|
self.request.put_state_reason = prev_reason;
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notifies the remote server about a new device
|
||||||
|
pub async fn notify_new_device_appeared(&mut self, session: &Session) -> SpClientResult {
|
||||||
|
self.send_with_reason(session, PutStateReason::NEW_DEVICE)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notifies the remote server about a new volume
|
||||||
|
pub async fn notify_volume_changed(&mut self, session: &Session) -> SpClientResult {
|
||||||
|
self.send_with_reason(session, PutStateReason::VOLUME_CHANGED)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the connect state for the connect session to the remote server
|
||||||
|
pub async fn send_state(&self, session: &Session) -> SpClientResult {
|
||||||
|
session
|
||||||
|
.spclient()
|
||||||
|
.put_connect_state_request(&self.request)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
415
connect/src/state/context.rs
Normal file
415
connect/src/state/context.rs
Normal file
|
@ -0,0 +1,415 @@
|
||||||
|
use crate::state::{metadata::Metadata, provider::Provider, ConnectState, StateError};
|
||||||
|
use librespot_core::{Error, SpotifyId};
|
||||||
|
use librespot_protocol::player::{
|
||||||
|
Context, ContextIndex, ContextPage, ContextTrack, ProvidedTrack, Restrictions,
|
||||||
|
};
|
||||||
|
use protobuf::MessageField;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const LOCAL_FILES_IDENTIFIER: &str = "spotify:local-files";
|
||||||
|
const SEARCH_IDENTIFIER: &str = "spotify:search";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct StateContext {
|
||||||
|
pub tracks: Vec<ProvidedTrack>,
|
||||||
|
pub metadata: HashMap<String, String>,
|
||||||
|
pub restrictions: Option<Restrictions>,
|
||||||
|
/// is used to keep track which tracks are already loaded into the next_tracks
|
||||||
|
pub index: ContextIndex,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Copy, Clone)]
|
||||||
|
pub enum ContextType {
|
||||||
|
#[default]
|
||||||
|
Default,
|
||||||
|
Shuffle,
|
||||||
|
Autoplay,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum LoadNext {
|
||||||
|
Done,
|
||||||
|
PageUrl(String),
|
||||||
|
Empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum UpdateContext {
|
||||||
|
Default,
|
||||||
|
Autoplay,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum ResetContext<'s> {
|
||||||
|
Completely,
|
||||||
|
DefaultIndex,
|
||||||
|
WhenDifferent(&'s str),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConnectState {
|
||||||
|
pub fn find_index_in_context<F: Fn(&ProvidedTrack) -> bool>(
|
||||||
|
context: Option<&StateContext>,
|
||||||
|
f: F,
|
||||||
|
) -> Result<usize, StateError> {
|
||||||
|
let ctx = context
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(StateError::NoContext(ContextType::Default))?;
|
||||||
|
|
||||||
|
ctx.tracks
|
||||||
|
.iter()
|
||||||
|
.position(f)
|
||||||
|
.ok_or(StateError::CanNotFindTrackInContext(None, ctx.tracks.len()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn get_context(&self, ty: &ContextType) -> Result<&StateContext, StateError> {
|
||||||
|
match ty {
|
||||||
|
ContextType::Default => self.context.as_ref(),
|
||||||
|
ContextType::Shuffle => self.shuffle_context.as_ref(),
|
||||||
|
ContextType::Autoplay => self.autoplay_context.as_ref(),
|
||||||
|
}
|
||||||
|
.ok_or(StateError::NoContext(*ty))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn context_uri(&self) -> &String {
|
||||||
|
&self.player().context_uri
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_context(&mut self, mut reset_as: ResetContext) {
|
||||||
|
self.set_active_context(ContextType::Default);
|
||||||
|
self.fill_up_context = ContextType::Default;
|
||||||
|
|
||||||
|
if matches!(reset_as, ResetContext::WhenDifferent(ctx) if self.context_uri() != ctx) {
|
||||||
|
reset_as = ResetContext::Completely
|
||||||
|
}
|
||||||
|
self.shuffle_context = None;
|
||||||
|
|
||||||
|
match reset_as {
|
||||||
|
ResetContext::Completely => {
|
||||||
|
self.context = None;
|
||||||
|
self.autoplay_context = None;
|
||||||
|
self.next_contexts.clear();
|
||||||
|
}
|
||||||
|
ResetContext::WhenDifferent(_) => debug!("context didn't change, no reset"),
|
||||||
|
ResetContext::DefaultIndex => {
|
||||||
|
for ctx in [self.context.as_mut(), self.autoplay_context.as_mut()]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
ctx.index.track = 0;
|
||||||
|
ctx.index.page = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update_restrictions()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_context_uri_from_context(context: &Context) -> Option<&String> {
|
||||||
|
if !context.uri.starts_with(SEARCH_IDENTIFIER) {
|
||||||
|
return Some(&context.uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
context
|
||||||
|
.pages
|
||||||
|
.first()
|
||||||
|
.and_then(|p| p.tracks.first().map(|t| &t.uri))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_active_context(&mut self, new_context: ContextType) {
|
||||||
|
self.active_context = new_context;
|
||||||
|
|
||||||
|
let ctx = match self.get_context(&new_context) {
|
||||||
|
Err(why) => {
|
||||||
|
debug!("couldn't load context info because: {why}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok(ctx) => ctx,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut restrictions = ctx.restrictions.clone();
|
||||||
|
let metadata = ctx.metadata.clone();
|
||||||
|
|
||||||
|
let player = self.player_mut();
|
||||||
|
|
||||||
|
player.context_metadata.clear();
|
||||||
|
player.restrictions.clear();
|
||||||
|
|
||||||
|
if let Some(restrictions) = restrictions.take() {
|
||||||
|
player.restrictions = MessageField::some(restrictions);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, value) in metadata {
|
||||||
|
player.context_metadata.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_context(&mut self, mut context: Context, ty: UpdateContext) -> Result<(), Error> {
|
||||||
|
if context.pages.iter().all(|p| p.tracks.is_empty()) {
|
||||||
|
error!("context didn't have any tracks: {context:#?}");
|
||||||
|
return Err(StateError::ContextHasNoTracks.into());
|
||||||
|
} else if context.uri.starts_with(LOCAL_FILES_IDENTIFIER) {
|
||||||
|
return Err(StateError::UnsupportedLocalPlayBack.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(ty, UpdateContext::Default) {
|
||||||
|
self.next_contexts.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut first_page = None;
|
||||||
|
for page in context.pages {
|
||||||
|
if first_page.is_none() && !page.tracks.is_empty() {
|
||||||
|
first_page = Some(page);
|
||||||
|
} else {
|
||||||
|
self.next_contexts.push(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let page = match first_page {
|
||||||
|
None => Err(StateError::ContextHasNoTracks)?,
|
||||||
|
Some(p) => p,
|
||||||
|
};
|
||||||
|
|
||||||
|
let prev_context = match ty {
|
||||||
|
UpdateContext::Default => self.context.as_ref(),
|
||||||
|
UpdateContext::Autoplay => self.autoplay_context.as_ref(),
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"updated context {ty:?} from <{}> ({} tracks) to <{}> ({} tracks)",
|
||||||
|
self.context_uri(),
|
||||||
|
prev_context
|
||||||
|
.map(|c| c.tracks.len().to_string())
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
context.uri,
|
||||||
|
page.tracks.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
match ty {
|
||||||
|
UpdateContext::Default => {
|
||||||
|
let mut new_context = self.state_context_from_page(
|
||||||
|
page,
|
||||||
|
context.restrictions.take(),
|
||||||
|
Some(&context.uri),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
// when we update the same context, we should try to preserve the previous position
|
||||||
|
// otherwise we might load the entire context twice
|
||||||
|
if !self.context_uri().contains(SEARCH_IDENTIFIER)
|
||||||
|
&& self.context_uri() == &context.uri
|
||||||
|
{
|
||||||
|
match Self::find_index_in_context(Some(&new_context), |t| {
|
||||||
|
self.current_track(|t| &t.uri) == &t.uri
|
||||||
|
}) {
|
||||||
|
Ok(new_pos) => {
|
||||||
|
debug!("found new index of current track, updating new_context index to {new_pos}");
|
||||||
|
new_context.index.track = (new_pos + 1) as u32;
|
||||||
|
}
|
||||||
|
// the track isn't anymore in the context
|
||||||
|
Err(_) if matches!(self.active_context, ContextType::Default) => {
|
||||||
|
warn!("current track was removed, setting pos to last known index");
|
||||||
|
new_context.index.track = self.player().index.track
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
// enforce reloading the context
|
||||||
|
self.clear_next_tracks(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.context = Some(new_context);
|
||||||
|
|
||||||
|
if !context.url.contains(SEARCH_IDENTIFIER) {
|
||||||
|
self.player_mut().context_url = context.url;
|
||||||
|
} else {
|
||||||
|
self.player_mut().context_url.clear()
|
||||||
|
}
|
||||||
|
self.player_mut().context_uri = context.uri;
|
||||||
|
}
|
||||||
|
UpdateContext::Autoplay => {
|
||||||
|
self.autoplay_context = Some(self.state_context_from_page(
|
||||||
|
page,
|
||||||
|
context.restrictions.take(),
|
||||||
|
Some(&context.uri),
|
||||||
|
Some(Provider::Autoplay),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_context_from_page(
|
||||||
|
&mut self,
|
||||||
|
page: ContextPage,
|
||||||
|
restrictions: Option<Restrictions>,
|
||||||
|
new_context_uri: Option<&str>,
|
||||||
|
provider: Option<Provider>,
|
||||||
|
) -> StateContext {
|
||||||
|
let new_context_uri = new_context_uri.unwrap_or(self.context_uri());
|
||||||
|
|
||||||
|
let tracks = page
|
||||||
|
.tracks
|
||||||
|
.iter()
|
||||||
|
.flat_map(|track| {
|
||||||
|
match self.context_to_provided_track(track, Some(new_context_uri), provider.clone())
|
||||||
|
{
|
||||||
|
Ok(t) => Some(t),
|
||||||
|
Err(why) => {
|
||||||
|
error!("couldn't convert {track:#?} into ProvidedTrack: {why}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
StateContext {
|
||||||
|
tracks,
|
||||||
|
restrictions,
|
||||||
|
metadata: page.metadata,
|
||||||
|
index: ContextIndex::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn merge_context(&mut self, context: Option<Context>) -> Option<()> {
|
||||||
|
let mut context = context?;
|
||||||
|
if self.context_uri() != &context.uri {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_context = self.context.as_mut()?;
|
||||||
|
let new_page = context.pages.pop()?;
|
||||||
|
|
||||||
|
for new_track in new_page.tracks {
|
||||||
|
if new_track.uri.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(position) =
|
||||||
|
Self::find_index_in_context(Some(current_context), |t| t.uri == new_track.uri)
|
||||||
|
{
|
||||||
|
let context_track = current_context.tracks.get_mut(position)?;
|
||||||
|
|
||||||
|
for (key, value) in new_track.metadata {
|
||||||
|
warn!("merging metadata {key} {value}");
|
||||||
|
context_track.metadata.insert(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the uid provided from another context might be actual uid of an item
|
||||||
|
if !new_track.uid.is_empty() {
|
||||||
|
context_track.uid = new_track.uid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_context_index(
|
||||||
|
&mut self,
|
||||||
|
ty: ContextType,
|
||||||
|
new_index: usize,
|
||||||
|
) -> Result<(), StateError> {
|
||||||
|
let context = match ty {
|
||||||
|
ContextType::Default => self.context.as_mut(),
|
||||||
|
ContextType::Shuffle => self.shuffle_context.as_mut(),
|
||||||
|
ContextType::Autoplay => self.autoplay_context.as_mut(),
|
||||||
|
}
|
||||||
|
.ok_or(StateError::NoContext(ty))?;
|
||||||
|
|
||||||
|
context.index.track = new_index as u32;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn context_to_provided_track(
|
||||||
|
&self,
|
||||||
|
ctx_track: &ContextTrack,
|
||||||
|
context_uri: Option<&str>,
|
||||||
|
provider: Option<Provider>,
|
||||||
|
) -> Result<ProvidedTrack, Error> {
|
||||||
|
let id = if !ctx_track.uri.is_empty() {
|
||||||
|
if ctx_track.uri.contains(['?', '%']) {
|
||||||
|
Err(StateError::InvalidTrackUri(ctx_track.uri.clone()))?
|
||||||
|
}
|
||||||
|
|
||||||
|
SpotifyId::from_uri(&ctx_track.uri)?
|
||||||
|
} else if !ctx_track.gid.is_empty() {
|
||||||
|
SpotifyId::from_raw(&ctx_track.gid)?
|
||||||
|
} else {
|
||||||
|
Err(StateError::InvalidTrackUri(String::new()))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider = if self.unavailable_uri.contains(&ctx_track.uri) {
|
||||||
|
Provider::Unavailable
|
||||||
|
} else {
|
||||||
|
provider.unwrap_or(Provider::Context)
|
||||||
|
};
|
||||||
|
|
||||||
|
// assumption: the uid is used as unique-id of any item
|
||||||
|
// - queue resorting is done by each client and orients itself by the given uid
|
||||||
|
// - if no uid is present, resorting doesn't work or behaves not as intended
|
||||||
|
let uid = if ctx_track.uid.is_empty() {
|
||||||
|
// so setting providing a unique id should allow to resort the queue
|
||||||
|
Uuid::new_v4().as_simple().to_string()
|
||||||
|
} else {
|
||||||
|
ctx_track.uid.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut metadata = HashMap::new();
|
||||||
|
for (k, v) in &ctx_track.metadata {
|
||||||
|
metadata.insert(k.to_string(), v.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut track = ProvidedTrack {
|
||||||
|
uri: id.to_uri()?.replace("unknown", "track"),
|
||||||
|
uid,
|
||||||
|
metadata,
|
||||||
|
provider: provider.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(context_uri) = context_uri {
|
||||||
|
track.set_context_uri(context_uri.to_string());
|
||||||
|
track.set_entity_uri(context_uri.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(provider, Provider::Autoplay) {
|
||||||
|
track.set_autoplay(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fill_context_from_page(&mut self, page: ContextPage) -> Result<(), Error> {
|
||||||
|
let context = self.state_context_from_page(page, None, None, None);
|
||||||
|
let ctx = self
|
||||||
|
.context
|
||||||
|
.as_mut()
|
||||||
|
.ok_or(StateError::NoContext(ContextType::Default))?;
|
||||||
|
|
||||||
|
for t in context.tracks {
|
||||||
|
ctx.tracks.push(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_load_next_context(&mut self) -> Result<LoadNext, Error> {
|
||||||
|
let next = match self.next_contexts.first() {
|
||||||
|
None => return Ok(LoadNext::Empty),
|
||||||
|
Some(_) => self.next_contexts.remove(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
if next.tracks.is_empty() {
|
||||||
|
if next.page_url.is_empty() {
|
||||||
|
Err(StateError::NoContext(ContextType::Default))?
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update_current_index(|i| i.page += 1);
|
||||||
|
return Ok(LoadNext::PageUrl(next.page_url));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.fill_context_from_page(next)?;
|
||||||
|
self.fill_up_next_tracks()?;
|
||||||
|
|
||||||
|
Ok(LoadNext::Done)
|
||||||
|
}
|
||||||
|
}
|
65
connect/src/state/handle.rs
Normal file
65
connect/src/state/handle.rs
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
use crate::state::{context::ResetContext, ConnectState};
|
||||||
|
use librespot_core::{dealer::protocol::SetQueueCommand, Error};
|
||||||
|
use protobuf::MessageField;
|
||||||
|
|
||||||
|
impl ConnectState {
|
||||||
|
pub fn handle_shuffle(&mut self, shuffle: bool) -> Result<(), Error> {
|
||||||
|
self.set_shuffle(shuffle);
|
||||||
|
|
||||||
|
if shuffle {
|
||||||
|
return self.shuffle();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.reset_context(ResetContext::DefaultIndex);
|
||||||
|
|
||||||
|
if self.current_track(MessageField::is_none) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx = self.context.as_ref();
|
||||||
|
let current_index =
|
||||||
|
ConnectState::find_index_in_context(ctx, |c| self.current_track(|t| c.uri == t.uri))?;
|
||||||
|
|
||||||
|
self.reset_playback_to_position(Some(current_index))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_set_queue(&mut self, set_queue: SetQueueCommand) {
|
||||||
|
self.set_next_tracks(set_queue.next_tracks);
|
||||||
|
self.set_prev_tracks(set_queue.prev_tracks);
|
||||||
|
self.update_queue_revision();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_set_repeat(
|
||||||
|
&mut self,
|
||||||
|
context: Option<bool>,
|
||||||
|
track: Option<bool>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
// doesn't need any state updates, because it should only change how the current song is played
|
||||||
|
if let Some(track) = track {
|
||||||
|
self.set_repeat_track(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(context, Some(context) if self.repeat_context() == context) || context.is_none()
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(context) = context {
|
||||||
|
self.set_repeat_context(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.repeat_context() {
|
||||||
|
self.set_shuffle(false);
|
||||||
|
self.reset_context(ResetContext::DefaultIndex);
|
||||||
|
|
||||||
|
let ctx = self.context.as_ref();
|
||||||
|
let current_track = ConnectState::find_index_in_context(ctx, |t| {
|
||||||
|
self.current_track(|t| &t.uri) == &t.uri
|
||||||
|
})?;
|
||||||
|
self.reset_playback_to_position(Some(current_track))
|
||||||
|
} else {
|
||||||
|
self.update_restrictions();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
84
connect/src/state/metadata.rs
Normal file
84
connect/src/state/metadata.rs
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
use librespot_protocol::player::{ContextTrack, ProvidedTrack};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
const CONTEXT_URI: &str = "context_uri";
|
||||||
|
const ENTITY_URI: &str = "entity_uri";
|
||||||
|
const IS_QUEUED: &str = "is_queued";
|
||||||
|
const IS_AUTOPLAY: &str = "autoplay.is_autoplay";
|
||||||
|
|
||||||
|
const HIDDEN: &str = "hidden";
|
||||||
|
const ITERATION: &str = "iteration";
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub trait Metadata {
|
||||||
|
fn metadata(&self) -> &HashMap<String, String>;
|
||||||
|
fn metadata_mut(&mut self) -> &mut HashMap<String, String>;
|
||||||
|
|
||||||
|
fn is_from_queue(&self) -> bool {
|
||||||
|
matches!(self.metadata().get(IS_QUEUED), Some(is_queued) if is_queued.eq("true"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_from_autoplay(&self) -> bool {
|
||||||
|
matches!(self.metadata().get(IS_AUTOPLAY), Some(is_autoplay) if is_autoplay.eq("true"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_hidden(&self) -> bool {
|
||||||
|
matches!(self.metadata().get(HIDDEN), Some(is_hidden) if is_hidden.eq("true"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_context_uri(&self) -> Option<&String> {
|
||||||
|
self.metadata().get(CONTEXT_URI)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_iteration(&self) -> Option<&String> {
|
||||||
|
self.metadata().get(ITERATION)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_queued(&mut self, queued: bool) {
|
||||||
|
self.metadata_mut()
|
||||||
|
.insert(IS_QUEUED.to_string(), queued.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_autoplay(&mut self, autoplay: bool) {
|
||||||
|
self.metadata_mut()
|
||||||
|
.insert(IS_AUTOPLAY.to_string(), autoplay.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_hidden(&mut self, hidden: bool) {
|
||||||
|
self.metadata_mut()
|
||||||
|
.insert(HIDDEN.to_string(), hidden.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_context_uri(&mut self, uri: String) {
|
||||||
|
self.metadata_mut().insert(CONTEXT_URI.to_string(), uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_entity_uri(&mut self, uri: String) {
|
||||||
|
self.metadata_mut().insert(ENTITY_URI.to_string(), uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_iteration(&mut self, iter: i64) {
|
||||||
|
self.metadata_mut()
|
||||||
|
.insert(ITERATION.to_string(), iter.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Metadata for ContextTrack {
|
||||||
|
fn metadata(&self) -> &HashMap<String, String> {
|
||||||
|
&self.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_mut(&mut self) -> &mut HashMap<String, String> {
|
||||||
|
&mut self.metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Metadata for ProvidedTrack {
|
||||||
|
fn metadata(&self) -> &HashMap<String, String> {
|
||||||
|
&self.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_mut(&mut self) -> &mut HashMap<String, String> {
|
||||||
|
&mut self.metadata
|
||||||
|
}
|
||||||
|
}
|
88
connect/src/state/options.rs
Normal file
88
connect/src/state/options.rs
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
use crate::state::context::ContextType;
|
||||||
|
use crate::state::{ConnectState, StateError};
|
||||||
|
use librespot_core::Error;
|
||||||
|
use librespot_protocol::player::{ContextIndex, ContextPlayerOptions};
|
||||||
|
use protobuf::MessageField;
|
||||||
|
use rand::prelude::SliceRandom;
|
||||||
|
|
||||||
|
impl ConnectState {
|
||||||
|
fn add_options_if_empty(&mut self) {
|
||||||
|
if self.player().options.is_none() {
|
||||||
|
self.player_mut().options = MessageField::some(ContextPlayerOptions::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_repeat_context(&mut self, repeat: bool) {
|
||||||
|
self.add_options_if_empty();
|
||||||
|
if let Some(options) = self.player_mut().options.as_mut() {
|
||||||
|
options.repeating_context = repeat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_repeat_track(&mut self, repeat: bool) {
|
||||||
|
self.add_options_if_empty();
|
||||||
|
if let Some(options) = self.player_mut().options.as_mut() {
|
||||||
|
options.repeating_track = repeat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_shuffle(&mut self, shuffle: bool) {
|
||||||
|
self.add_options_if_empty();
|
||||||
|
if let Some(options) = self.player_mut().options.as_mut() {
|
||||||
|
options.shuffling_context = shuffle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shuffle(&mut self) -> Result<(), Error> {
|
||||||
|
if let Some(reason) = self
|
||||||
|
.player()
|
||||||
|
.restrictions
|
||||||
|
.disallow_toggling_shuffle_reasons
|
||||||
|
.first()
|
||||||
|
{
|
||||||
|
Err(StateError::CurrentlyDisallowed {
|
||||||
|
action: "shuffle".to_string(),
|
||||||
|
reason: reason.clone(),
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
|
||||||
|
self.clear_prev_track();
|
||||||
|
self.clear_next_tracks(true);
|
||||||
|
|
||||||
|
let current_uri = self.current_track(|t| &t.uri);
|
||||||
|
|
||||||
|
let ctx = self
|
||||||
|
.context
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(StateError::NoContext(ContextType::Default))?;
|
||||||
|
|
||||||
|
let current_track = Self::find_index_in_context(Some(ctx), |t| &t.uri == current_uri)?;
|
||||||
|
|
||||||
|
let mut shuffle_context = ctx.clone();
|
||||||
|
// we don't need to include the current track, because it is already being played
|
||||||
|
shuffle_context.tracks.remove(current_track);
|
||||||
|
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
shuffle_context.tracks.shuffle(&mut rng);
|
||||||
|
shuffle_context.index = ContextIndex::new();
|
||||||
|
|
||||||
|
self.shuffle_context = Some(shuffle_context);
|
||||||
|
self.set_active_context(ContextType::Shuffle);
|
||||||
|
self.fill_up_context = ContextType::Shuffle;
|
||||||
|
self.fill_up_next_tracks()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shuffling_context(&self) -> bool {
|
||||||
|
self.player().options.shuffling_context
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn repeat_context(&self) -> bool {
|
||||||
|
self.player().options.repeating_context
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn repeat_track(&self) -> bool {
|
||||||
|
self.player().options.repeating_track
|
||||||
|
}
|
||||||
|
}
|
66
connect/src/state/provider.rs
Normal file
66
connect/src/state/provider.rs
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
use librespot_protocol::player::ProvidedTrack;
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
|
||||||
|
// providers used by spotify
|
||||||
|
const PROVIDER_CONTEXT: &str = "context";
|
||||||
|
const PROVIDER_QUEUE: &str = "queue";
|
||||||
|
const PROVIDER_AUTOPLAY: &str = "autoplay";
|
||||||
|
|
||||||
|
// custom providers, used to identify certain states that we can't handle preemptively, yet
|
||||||
|
/// it seems like spotify just knows that the track isn't available, currently we don't have an
|
||||||
|
/// option to do the same, so we stay with the old solution for now
|
||||||
|
const PROVIDER_UNAVAILABLE: &str = "unavailable";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Provider {
|
||||||
|
Context,
|
||||||
|
Queue,
|
||||||
|
Autoplay,
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Provider {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}",
|
||||||
|
match self {
|
||||||
|
Provider::Context => PROVIDER_CONTEXT,
|
||||||
|
Provider::Queue => PROVIDER_QUEUE,
|
||||||
|
Provider::Autoplay => PROVIDER_AUTOPLAY,
|
||||||
|
Provider::Unavailable => PROVIDER_UNAVAILABLE,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IsProvider {
|
||||||
|
fn is_autoplay(&self) -> bool;
|
||||||
|
fn is_context(&self) -> bool;
|
||||||
|
fn is_queue(&self) -> bool;
|
||||||
|
fn is_unavailable(&self) -> bool;
|
||||||
|
|
||||||
|
fn set_provider(&mut self, provider: Provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IsProvider for ProvidedTrack {
|
||||||
|
fn is_autoplay(&self) -> bool {
|
||||||
|
self.provider == PROVIDER_AUTOPLAY
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_context(&self) -> bool {
|
||||||
|
self.provider == PROVIDER_CONTEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_queue(&self) -> bool {
|
||||||
|
self.provider == PROVIDER_QUEUE
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_unavailable(&self) -> bool {
|
||||||
|
self.provider == PROVIDER_UNAVAILABLE
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_provider(&mut self, provider: Provider) {
|
||||||
|
self.provider = provider.to_string()
|
||||||
|
}
|
||||||
|
}
|
61
connect/src/state/restrictions.rs
Normal file
61
connect/src/state/restrictions.rs
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
use crate::state::provider::IsProvider;
|
||||||
|
use crate::state::ConnectState;
|
||||||
|
use librespot_protocol::player::Restrictions;
|
||||||
|
use protobuf::MessageField;
|
||||||
|
|
||||||
|
impl ConnectState {
|
||||||
|
pub fn clear_restrictions(&mut self) {
|
||||||
|
let player = self.player_mut();
|
||||||
|
|
||||||
|
player.restrictions.clear();
|
||||||
|
player.context_restrictions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_restrictions(&mut self) {
|
||||||
|
const NO_PREV: &str = "no previous tracks";
|
||||||
|
const AUTOPLAY: &str = "autoplay";
|
||||||
|
const ENDLESS_CONTEXT: &str = "endless_context";
|
||||||
|
|
||||||
|
let prev_tracks_is_empty = self.prev_tracks().is_empty();
|
||||||
|
let player = self.player_mut();
|
||||||
|
if let Some(restrictions) = player.restrictions.as_mut() {
|
||||||
|
if player.is_playing {
|
||||||
|
restrictions.disallow_pausing_reasons.clear();
|
||||||
|
restrictions.disallow_resuming_reasons = vec!["not_paused".to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
if player.is_paused {
|
||||||
|
restrictions.disallow_resuming_reasons.clear();
|
||||||
|
restrictions.disallow_pausing_reasons = vec!["not_playing".to_string()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if player.restrictions.is_none() {
|
||||||
|
player.restrictions = MessageField::some(Restrictions::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(restrictions) = player.restrictions.as_mut() {
|
||||||
|
if prev_tracks_is_empty {
|
||||||
|
restrictions.disallow_peeking_prev_reasons = vec![NO_PREV.to_string()];
|
||||||
|
restrictions.disallow_skipping_prev_reasons = vec![NO_PREV.to_string()];
|
||||||
|
} else {
|
||||||
|
restrictions.disallow_peeking_prev_reasons.clear();
|
||||||
|
restrictions.disallow_skipping_prev_reasons.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if player.track.is_autoplay() {
|
||||||
|
restrictions.disallow_toggling_shuffle_reasons = vec![AUTOPLAY.to_string()];
|
||||||
|
restrictions.disallow_toggling_repeat_context_reasons = vec![AUTOPLAY.to_string()];
|
||||||
|
restrictions.disallow_toggling_repeat_track_reasons = vec![AUTOPLAY.to_string()];
|
||||||
|
} else if player.options.repeating_context {
|
||||||
|
restrictions.disallow_toggling_shuffle_reasons = vec![ENDLESS_CONTEXT.to_string()]
|
||||||
|
} else {
|
||||||
|
restrictions.disallow_toggling_shuffle_reasons.clear();
|
||||||
|
restrictions
|
||||||
|
.disallow_toggling_repeat_context_reasons
|
||||||
|
.clear();
|
||||||
|
restrictions.disallow_toggling_repeat_track_reasons.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
422
connect/src/state/tracks.rs
Normal file
422
connect/src/state/tracks.rs
Normal file
|
@ -0,0 +1,422 @@
|
||||||
|
use crate::state::{
|
||||||
|
context::ContextType,
|
||||||
|
metadata::Metadata,
|
||||||
|
provider::{IsProvider, Provider},
|
||||||
|
ConnectState, StateError, SPOTIFY_MAX_NEXT_TRACKS_SIZE, SPOTIFY_MAX_PREV_TRACKS_SIZE,
|
||||||
|
};
|
||||||
|
use librespot_core::{Error, SpotifyId};
|
||||||
|
use librespot_protocol::player::ProvidedTrack;
|
||||||
|
use protobuf::MessageField;
|
||||||
|
|
||||||
|
// identifier used as part of the uid
|
||||||
|
pub const IDENTIFIER_DELIMITER: &str = "delimiter";
|
||||||
|
|
||||||
|
impl<'ct> ConnectState {
|
||||||
|
fn new_delimiter(iteration: i64) -> ProvidedTrack {
|
||||||
|
let mut delimiter = ProvidedTrack {
|
||||||
|
uri: format!("spotify:{IDENTIFIER_DELIMITER}"),
|
||||||
|
uid: format!("{IDENTIFIER_DELIMITER}{iteration}"),
|
||||||
|
provider: Provider::Context.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
delimiter.set_hidden(true);
|
||||||
|
delimiter.add_iteration(iteration);
|
||||||
|
|
||||||
|
delimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_prev(&mut self, prev: ProvidedTrack) {
|
||||||
|
let prev_tracks = self.prev_tracks_mut();
|
||||||
|
// add prev track, while preserving a length of 10
|
||||||
|
if prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE {
|
||||||
|
// todo: O(n), but technically only maximal O(SPOTIFY_MAX_PREV_TRACKS_SIZE) aka O(10)
|
||||||
|
let _ = prev_tracks.remove(0);
|
||||||
|
}
|
||||||
|
prev_tracks.push(prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_next_track(&mut self) -> Option<ProvidedTrack> {
|
||||||
|
if self.next_tracks().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
// todo: O(n), but technically only maximal O(SPOTIFY_MAX_NEXT_TRACKS_SIZE) aka O(80)
|
||||||
|
Some(self.next_tracks_mut().remove(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// bottom => top, aka the last track of the list is the prev track
|
||||||
|
fn prev_tracks_mut(&mut self) -> &mut Vec<ProvidedTrack> {
|
||||||
|
&mut self.player_mut().prev_tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// bottom => top, aka the last track of the list is the prev track
|
||||||
|
pub(super) fn prev_tracks(&self) -> &Vec<ProvidedTrack> {
|
||||||
|
&self.player().prev_tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// top => bottom, aka the first track of the list is the next track
|
||||||
|
fn next_tracks_mut(&mut self) -> &mut Vec<ProvidedTrack> {
|
||||||
|
&mut self.player_mut().next_tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// top => bottom, aka the first track of the list is the next track
|
||||||
|
pub(super) fn next_tracks(&self) -> &Vec<ProvidedTrack> {
|
||||||
|
&self.player().next_tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_current_track(&mut self, index: usize) -> Result<(), Error> {
|
||||||
|
let context = self.get_context(&self.active_context)?;
|
||||||
|
|
||||||
|
let new_track = context
|
||||||
|
.tracks
|
||||||
|
.get(index)
|
||||||
|
.ok_or(StateError::CanNotFindTrackInContext(
|
||||||
|
Some(index),
|
||||||
|
context.tracks.len(),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"set track to: {} at {} of {} tracks",
|
||||||
|
index,
|
||||||
|
new_track.uri,
|
||||||
|
context.tracks.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
self.set_track(new_track.clone());
|
||||||
|
|
||||||
|
self.update_current_index(|i| i.track = index as u32);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to the next track
|
||||||
|
///
|
||||||
|
/// Updates the current track to the next track. Adds the old track
|
||||||
|
/// to prev tracks and fills up the next tracks from the current context
|
||||||
|
pub fn next_track(&mut self) -> Result<Option<u32>, Error> {
|
||||||
|
// when we skip in repeat track, we don't repeat the current track anymore
|
||||||
|
if self.repeat_track() {
|
||||||
|
self.set_repeat_track(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let old_track = self.player_mut().track.take();
|
||||||
|
|
||||||
|
if let Some(old_track) = old_track {
|
||||||
|
// only add songs from our context to our previous tracks
|
||||||
|
if old_track.is_context() || old_track.is_autoplay() {
|
||||||
|
self.push_prev(old_track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_track = loop {
|
||||||
|
match self.get_next_track() {
|
||||||
|
Some(next) if next.uid.starts_with(IDENTIFIER_DELIMITER) => {
|
||||||
|
self.push_prev(next);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Some(next) if next.is_unavailable() => continue,
|
||||||
|
other => break other,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_track = match new_track {
|
||||||
|
None => return Ok(None),
|
||||||
|
Some(t) => t,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.fill_up_next_tracks()?;
|
||||||
|
|
||||||
|
let update_index = if new_track.is_queue() {
|
||||||
|
None
|
||||||
|
} else if new_track.is_autoplay() {
|
||||||
|
self.set_active_context(ContextType::Autoplay);
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let ctx = self.context.as_ref();
|
||||||
|
let new_index = Self::find_index_in_context(ctx, |c| c.uri == new_track.uri);
|
||||||
|
match new_index {
|
||||||
|
Ok(new_index) => Some(new_index as u32),
|
||||||
|
Err(why) => {
|
||||||
|
error!("didn't find the track in the current context: {why}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(update_index) = update_index {
|
||||||
|
self.update_current_index(|i| i.track = update_index)
|
||||||
|
} else {
|
||||||
|
self.player_mut().index.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.set_track(new_track);
|
||||||
|
self.update_restrictions();
|
||||||
|
|
||||||
|
Ok(Some(self.player().index.track))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to the prev track
|
||||||
|
///
|
||||||
|
/// Updates the current track to the prev track. Adds the old track
|
||||||
|
/// to next tracks (when from the context) and fills up the prev tracks from the
|
||||||
|
/// current context
|
||||||
|
pub fn prev_track(&mut self) -> Result<Option<&MessageField<ProvidedTrack>>, Error> {
|
||||||
|
let old_track = self.player_mut().track.take();
|
||||||
|
|
||||||
|
if let Some(old_track) = old_track {
|
||||||
|
if old_track.is_context() || old_track.is_autoplay() {
|
||||||
|
// todo: O(n)
|
||||||
|
self.next_tracks_mut().insert(0, old_track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle possible delimiter
|
||||||
|
if matches!(self.prev_tracks().last(), Some(prev) if prev.uid.starts_with(IDENTIFIER_DELIMITER))
|
||||||
|
{
|
||||||
|
let delimiter = self
|
||||||
|
.prev_tracks_mut()
|
||||||
|
.pop()
|
||||||
|
.expect("item that was prechecked");
|
||||||
|
|
||||||
|
let next_tracks = self.next_tracks_mut();
|
||||||
|
if next_tracks.len() >= SPOTIFY_MAX_NEXT_TRACKS_SIZE {
|
||||||
|
let _ = next_tracks.pop();
|
||||||
|
}
|
||||||
|
// todo: O(n)
|
||||||
|
next_tracks.insert(0, delimiter)
|
||||||
|
}
|
||||||
|
|
||||||
|
while self.next_tracks().len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE {
|
||||||
|
let _ = self.next_tracks_mut().pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_track = match self.prev_tracks_mut().pop() {
|
||||||
|
None => return Ok(None),
|
||||||
|
Some(t) => t,
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches!(self.active_context, ContextType::Autoplay if new_track.is_context()) {
|
||||||
|
// transition back to default context
|
||||||
|
self.set_active_context(ContextType::Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.fill_up_next_tracks()?;
|
||||||
|
self.set_track(new_track);
|
||||||
|
|
||||||
|
if self.player().index.track == 0 {
|
||||||
|
warn!("prev: trying to skip into negative, index update skipped")
|
||||||
|
} else {
|
||||||
|
self.update_current_index(|i| i.track -= 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update_restrictions();
|
||||||
|
|
||||||
|
Ok(Some(self.current_track(|t| t)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_track<F: Fn(&'ct MessageField<ProvidedTrack>) -> R, R>(
|
||||||
|
&'ct self,
|
||||||
|
access: F,
|
||||||
|
) -> R {
|
||||||
|
access(&self.player().track)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_track(&mut self, track: ProvidedTrack) {
|
||||||
|
self.player_mut().track = MessageField::some(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_next_tracks(&mut self, mut tracks: Vec<ProvidedTrack>) {
|
||||||
|
// mobile only sends a set_queue command instead of an add_to_queue command
|
||||||
|
// in addition to handling the mobile add_to_queue handling, this should also handle
|
||||||
|
// a mass queue addition
|
||||||
|
tracks
|
||||||
|
.iter_mut()
|
||||||
|
.filter(|t| t.is_from_queue())
|
||||||
|
.for_each(|t| {
|
||||||
|
t.set_provider(Provider::Queue);
|
||||||
|
// technically we could preserve the queue-uid here,
|
||||||
|
// but it seems to work without that, so we just override it
|
||||||
|
t.uid = format!("q{}", self.queue_count);
|
||||||
|
self.queue_count += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
self.player_mut().next_tracks = tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_prev_tracks(&mut self, tracks: Vec<ProvidedTrack>) {
|
||||||
|
self.player_mut().prev_tracks = tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_prev_track(&mut self) {
|
||||||
|
self.prev_tracks_mut().clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_next_tracks(&mut self, keep_queued: bool) {
|
||||||
|
if !keep_queued {
|
||||||
|
self.next_tracks_mut().clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// respect queued track and don't throw them out of our next played tracks
|
||||||
|
let first_non_queued_track = self
|
||||||
|
.next_tracks()
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(_, track)| !track.is_queue());
|
||||||
|
|
||||||
|
if let Some((non_queued_track, _)) = first_non_queued_track {
|
||||||
|
while self.next_tracks().len() > non_queued_track
|
||||||
|
&& self.next_tracks_mut().pop().is_some()
|
||||||
|
{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fill_up_next_tracks(&mut self) -> Result<(), StateError> {
|
||||||
|
let ctx = self.get_context(&self.fill_up_context)?;
|
||||||
|
let mut new_index = ctx.index.track as usize;
|
||||||
|
let mut iteration = ctx.index.page;
|
||||||
|
|
||||||
|
while self.next_tracks().len() < SPOTIFY_MAX_NEXT_TRACKS_SIZE {
|
||||||
|
let ctx = self.get_context(&self.fill_up_context)?;
|
||||||
|
let track = match ctx.tracks.get(new_index) {
|
||||||
|
None if self.repeat_context() => {
|
||||||
|
let delimiter = Self::new_delimiter(iteration.into());
|
||||||
|
iteration += 1;
|
||||||
|
new_index = 0;
|
||||||
|
delimiter
|
||||||
|
}
|
||||||
|
None if !matches!(self.fill_up_context, ContextType::Autoplay)
|
||||||
|
&& self.autoplay_context.is_some() =>
|
||||||
|
{
|
||||||
|
self.update_context_index(self.fill_up_context, new_index)?;
|
||||||
|
|
||||||
|
// transition to autoplay as fill up context
|
||||||
|
self.fill_up_context = ContextType::Autoplay;
|
||||||
|
new_index = self.get_context(&ContextType::Autoplay)?.index.track as usize;
|
||||||
|
|
||||||
|
// add delimiter to only display the current context
|
||||||
|
Self::new_delimiter(iteration.into())
|
||||||
|
}
|
||||||
|
None if self.autoplay_context.is_some() => {
|
||||||
|
match self
|
||||||
|
.get_context(&ContextType::Autoplay)?
|
||||||
|
.tracks
|
||||||
|
.get(new_index)
|
||||||
|
{
|
||||||
|
None => break,
|
||||||
|
Some(ct) => {
|
||||||
|
new_index += 1;
|
||||||
|
ct.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
Some(ct) if ct.is_unavailable() => {
|
||||||
|
new_index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Some(ct) => {
|
||||||
|
new_index += 1;
|
||||||
|
ct.clone()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.next_tracks_mut().push(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update_context_index(self.fill_up_context, new_index)?;
|
||||||
|
|
||||||
|
// the web-player needs a revision update, otherwise the queue isn't updated in the ui
|
||||||
|
self.update_queue_revision();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preview_next_track(&mut self) -> Option<SpotifyId> {
|
||||||
|
let next = if self.repeat_track() {
|
||||||
|
self.current_track(|t| &t.uri)
|
||||||
|
} else {
|
||||||
|
&self.next_tracks().first()?.uri
|
||||||
|
};
|
||||||
|
|
||||||
|
SpotifyId::from_uri(next).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_next_tracks(&self, min: Option<usize>) -> bool {
|
||||||
|
if let Some(min) = min {
|
||||||
|
self.next_tracks().len() >= min
|
||||||
|
} else {
|
||||||
|
!self.next_tracks().is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev_autoplay_track_uris(&self) -> Vec<String> {
|
||||||
|
let mut prev = self
|
||||||
|
.prev_tracks()
|
||||||
|
.iter()
|
||||||
|
.flat_map(|t| t.is_autoplay().then_some(t.uri.clone()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if self.current_track(|t| t.is_autoplay()) {
|
||||||
|
prev.push(self.current_track(|t| t.uri.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
prev
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_unavailable(&mut self, id: SpotifyId) -> Result<(), Error> {
|
||||||
|
let uri = id.to_uri()?;
|
||||||
|
|
||||||
|
debug!("marking {uri} as unavailable");
|
||||||
|
|
||||||
|
let next_tracks = self.next_tracks_mut();
|
||||||
|
while let Some(pos) = next_tracks.iter().position(|t| t.uri == uri) {
|
||||||
|
let _ = next_tracks.remove(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
for next_track in next_tracks {
|
||||||
|
Self::mark_as_unavailable_for_match(next_track, &uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
let prev_tracks = self.prev_tracks_mut();
|
||||||
|
while let Some(pos) = prev_tracks.iter().position(|t| t.uri == uri) {
|
||||||
|
let _ = prev_tracks.remove(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
for prev_track in prev_tracks {
|
||||||
|
Self::mark_as_unavailable_for_match(prev_track, &uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.unavailable_uri.push(uri);
|
||||||
|
self.fill_up_next_tracks()?;
|
||||||
|
self.update_queue_revision();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_to_queue(&mut self, mut track: ProvidedTrack, rev_update: bool) {
|
||||||
|
track.uid = format!("q{}", self.queue_count);
|
||||||
|
self.queue_count += 1;
|
||||||
|
|
||||||
|
track.set_provider(Provider::Queue);
|
||||||
|
if !track.is_from_queue() {
|
||||||
|
track.set_queued(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let next_tracks = self.next_tracks_mut();
|
||||||
|
if let Some(next_not_queued_track) = next_tracks.iter().position(|t| !t.is_queue()) {
|
||||||
|
next_tracks.insert(next_not_queued_track, track);
|
||||||
|
} else {
|
||||||
|
next_tracks.push(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
while next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE {
|
||||||
|
next_tracks.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if rev_update {
|
||||||
|
self.update_queue_revision();
|
||||||
|
}
|
||||||
|
self.update_restrictions();
|
||||||
|
}
|
||||||
|
}
|
146
connect/src/state/transfer.rs
Normal file
146
connect/src/state/transfer.rs
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
use crate::state::context::ContextType;
|
||||||
|
use crate::state::metadata::Metadata;
|
||||||
|
use crate::state::provider::{IsProvider, Provider};
|
||||||
|
use crate::state::{ConnectState, StateError};
|
||||||
|
use librespot_core::Error;
|
||||||
|
use librespot_protocol::player::{ProvidedTrack, TransferState};
|
||||||
|
use protobuf::MessageField;
|
||||||
|
|
||||||
|
impl ConnectState {
|
||||||
|
pub fn current_track_from_transfer(
|
||||||
|
&self,
|
||||||
|
transfer: &TransferState,
|
||||||
|
) -> Result<ProvidedTrack, Error> {
|
||||||
|
let track = if transfer.queue.is_playing_queue {
|
||||||
|
transfer.queue.tracks.first()
|
||||||
|
} else {
|
||||||
|
transfer.playback.current_track.as_ref()
|
||||||
|
}
|
||||||
|
.ok_or(StateError::CouldNotResolveTrackFromTransfer)?;
|
||||||
|
|
||||||
|
self.context_to_provided_track(
|
||||||
|
track,
|
||||||
|
Some(&transfer.current_session.context.uri),
|
||||||
|
transfer.queue.is_playing_queue.then_some(Provider::Queue),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// handles the initially transferable data
|
||||||
|
pub fn handle_initial_transfer(&mut self, transfer: &mut TransferState) {
|
||||||
|
let current_context_metadata = self.context.as_ref().map(|c| c.metadata.clone());
|
||||||
|
let player = self.player_mut();
|
||||||
|
|
||||||
|
player.is_buffering = false;
|
||||||
|
|
||||||
|
if let Some(options) = transfer.options.take() {
|
||||||
|
player.options = MessageField::some(options);
|
||||||
|
}
|
||||||
|
player.is_paused = transfer.playback.is_paused;
|
||||||
|
player.is_playing = !transfer.playback.is_paused;
|
||||||
|
|
||||||
|
if transfer.playback.playback_speed != 0. {
|
||||||
|
player.playback_speed = transfer.playback.playback_speed
|
||||||
|
} else {
|
||||||
|
player.playback_speed = 1.;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(session) = transfer.current_session.as_mut() {
|
||||||
|
player.play_origin = session.play_origin.take().into();
|
||||||
|
player.suppressions = session.suppressions.take().into();
|
||||||
|
|
||||||
|
if let Some(mut ctx) = session.context.take() {
|
||||||
|
player.restrictions = ctx.restrictions.take().into();
|
||||||
|
for (key, value) in ctx.metadata {
|
||||||
|
player.context_metadata.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
player.context_url.clear();
|
||||||
|
player.context_uri.clear();
|
||||||
|
|
||||||
|
if let Some(metadata) = current_context_metadata {
|
||||||
|
for (key, value) in metadata {
|
||||||
|
player.context_metadata.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.clear_prev_track();
|
||||||
|
self.clear_next_tracks(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// completes the transfer, loading the queue and updating metadata
|
||||||
|
pub fn finish_transfer(&mut self, transfer: TransferState) -> Result<(), Error> {
|
||||||
|
let track = match self.player().track.as_ref() {
|
||||||
|
None => self.current_track_from_transfer(&transfer)?,
|
||||||
|
Some(track) => track.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let context_ty = if self.current_track(|t| t.is_from_autoplay()) {
|
||||||
|
ContextType::Autoplay
|
||||||
|
} else {
|
||||||
|
ContextType::Default
|
||||||
|
};
|
||||||
|
|
||||||
|
self.set_active_context(context_ty);
|
||||||
|
self.fill_up_context = context_ty;
|
||||||
|
|
||||||
|
let ctx = self.get_context(&self.active_context).ok();
|
||||||
|
|
||||||
|
let current_index = if track.is_queue() {
|
||||||
|
Self::find_index_in_context(ctx, |c| c.uid == transfer.current_session.current_uid)
|
||||||
|
.map(|i| if i > 0 { i - 1 } else { i })
|
||||||
|
} else {
|
||||||
|
Self::find_index_in_context(ctx, |c| c.uri == track.uri || c.uid == track.uid)
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"active track is <{}> with index {current_index:?} in {:?} context, has {} tracks",
|
||||||
|
track.uri,
|
||||||
|
self.active_context,
|
||||||
|
ctx.map(|c| c.tracks.len()).unwrap_or_default()
|
||||||
|
);
|
||||||
|
|
||||||
|
if self.player().track.is_none() {
|
||||||
|
self.set_track(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_index = current_index.ok();
|
||||||
|
if let Some(current_index) = current_index {
|
||||||
|
self.update_current_index(|i| i.track = current_index as u32);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"setting up next and prev: index is at {current_index:?} while shuffle {}",
|
||||||
|
self.shuffling_context()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (i, track) in transfer.queue.tracks.iter().enumerate() {
|
||||||
|
if transfer.queue.is_playing_queue && i == 0 {
|
||||||
|
// if we are currently playing from the queue,
|
||||||
|
// don't add the first queued item, because we are currently playing that item
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(queued_track) = self.context_to_provided_track(
|
||||||
|
track,
|
||||||
|
Some(self.context_uri()),
|
||||||
|
Some(Provider::Queue),
|
||||||
|
) {
|
||||||
|
self.add_to_queue(queued_track, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.shuffling_context() {
|
||||||
|
self.set_current_track(current_index.unwrap_or_default())?;
|
||||||
|
self.set_shuffle(true);
|
||||||
|
self.shuffle()?;
|
||||||
|
} else {
|
||||||
|
self.reset_playback_to_position(current_index)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update_restrictions();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -60,6 +60,8 @@ tokio-util = { version = "0.7", features = ["codec"] }
|
||||||
url = "2"
|
url = "2"
|
||||||
uuid = { version = "1", default-features = false, features = ["fast-rng", "v4"] }
|
uuid = { version = "1", default-features = false, features = ["fast-rng", "v4"] }
|
||||||
data-encoding = "2.5"
|
data-encoding = "2.5"
|
||||||
|
flate2 = "1.0.33"
|
||||||
|
protobuf-json-mapping = "3.5"
|
||||||
|
|
||||||
# Eventually, this should use rustls-platform-verifier to unify the platform-specific dependencies
|
# Eventually, this should use rustls-platform-verifier to unify the platform-specific dependencies
|
||||||
# but currently, hyper-proxy2 and tokio-tungstenite do not support it.
|
# but currently, hyper-proxy2 and tokio-tungstenite do not support it.
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
use std::{fmt, path::PathBuf, str::FromStr};
|
use std::{fmt, path::PathBuf, str::FromStr};
|
||||||
|
|
||||||
|
use librespot_protocol::devices::DeviceType as ProtoDeviceType;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
pub(crate) const KEYMASTER_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd";
|
pub(crate) const KEYMASTER_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd";
|
||||||
|
@ -146,3 +147,29 @@ impl fmt::Display for DeviceType {
|
||||||
f.write_str(str)
|
f.write_str(str)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<DeviceType> for ProtoDeviceType {
|
||||||
|
fn from(value: DeviceType) -> Self {
|
||||||
|
match value {
|
||||||
|
DeviceType::Unknown => ProtoDeviceType::UNKNOWN,
|
||||||
|
DeviceType::Computer => ProtoDeviceType::COMPUTER,
|
||||||
|
DeviceType::Tablet => ProtoDeviceType::TABLET,
|
||||||
|
DeviceType::Smartphone => ProtoDeviceType::SMARTPHONE,
|
||||||
|
DeviceType::Speaker => ProtoDeviceType::SPEAKER,
|
||||||
|
DeviceType::Tv => ProtoDeviceType::TV,
|
||||||
|
DeviceType::Avr => ProtoDeviceType::AVR,
|
||||||
|
DeviceType::Stb => ProtoDeviceType::STB,
|
||||||
|
DeviceType::AudioDongle => ProtoDeviceType::AUDIO_DONGLE,
|
||||||
|
DeviceType::GameConsole => ProtoDeviceType::GAME_CONSOLE,
|
||||||
|
DeviceType::CastAudio => ProtoDeviceType::CAST_VIDEO,
|
||||||
|
DeviceType::CastVideo => ProtoDeviceType::CAST_AUDIO,
|
||||||
|
DeviceType::Automobile => ProtoDeviceType::AUTOMOBILE,
|
||||||
|
DeviceType::Smartwatch => ProtoDeviceType::SMARTWATCH,
|
||||||
|
DeviceType::Chromebook => ProtoDeviceType::CHROMEBOOK,
|
||||||
|
DeviceType::UnknownSpotify => ProtoDeviceType::UNKNOWN_SPOTIFY,
|
||||||
|
DeviceType::CarThing => ProtoDeviceType::CAR_THING,
|
||||||
|
DeviceType::Observer => ProtoDeviceType::OBSERVER,
|
||||||
|
DeviceType::HomeThing => ProtoDeviceType::HOME_THING,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -233,8 +233,8 @@ where
|
||||||
Ok(message)
|
Ok(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_into_accumulator<'a, 'b, T: AsyncRead + Unpin>(
|
async fn read_into_accumulator<'b, T: AsyncRead + Unpin>(
|
||||||
connection: &'a mut T,
|
connection: &mut T,
|
||||||
size: usize,
|
size: usize,
|
||||||
acc: &'b mut Vec<u8>,
|
acc: &'b mut Vec<u8>,
|
||||||
) -> io::Result<&'b mut [u8]> {
|
) -> io::Result<&'b mut [u8]> {
|
||||||
|
|
174
core/src/dealer/manager.rs
Normal file
174
core/src/dealer/manager.rs
Normal file
|
@ -0,0 +1,174 @@
|
||||||
|
use futures_core::Stream;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use std::{cell::OnceCell, pin::Pin, str::FromStr};
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
protocol::Message, Builder, Dealer, GetUrlResult, Request, RequestHandler, Responder, Response,
|
||||||
|
Subscription,
|
||||||
|
};
|
||||||
|
use crate::{Error, Session};
|
||||||
|
|
||||||
|
component! {
|
||||||
|
DealerManager: DealerManagerInner {
|
||||||
|
builder: OnceCell<Builder> = OnceCell::from(Builder::new()),
|
||||||
|
dealer: OnceCell<Dealer> = OnceCell::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type BoxedStream<T> = Pin<Box<dyn Stream<Item = T> + Send>>;
|
||||||
|
pub type BoxedStreamResult<T> = BoxedStream<Result<T, Error>>;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
enum DealerError {
|
||||||
|
#[error("Builder wasn't available")]
|
||||||
|
BuilderNotAvailable,
|
||||||
|
#[error("Websocket couldn't be started because: {0}")]
|
||||||
|
LaunchFailure(Error),
|
||||||
|
#[error("Failed to set dealer")]
|
||||||
|
CouldNotSetDealer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DealerError> for Error {
|
||||||
|
fn from(err: DealerError) -> Self {
|
||||||
|
Error::failed_precondition(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Reply {
|
||||||
|
Success,
|
||||||
|
Failure,
|
||||||
|
Unanswered,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type RequestReply = (Request, mpsc::UnboundedSender<Reply>);
|
||||||
|
type RequestReceiver = mpsc::UnboundedReceiver<RequestReply>;
|
||||||
|
type RequestSender = mpsc::UnboundedSender<RequestReply>;
|
||||||
|
|
||||||
|
struct DealerRequestHandler(RequestSender);
|
||||||
|
|
||||||
|
impl DealerRequestHandler {
|
||||||
|
pub fn new() -> (Self, RequestReceiver) {
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
(DealerRequestHandler(tx), rx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestHandler for DealerRequestHandler {
|
||||||
|
fn handle_request(&self, request: Request, responder: Responder) {
|
||||||
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
if let Err(why) = self.0.send((request, tx)) {
|
||||||
|
error!("failed sending dealer request {why}");
|
||||||
|
responder.send(Response { success: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let reply = rx.recv().await.unwrap_or(Reply::Failure);
|
||||||
|
debug!("replying to ws request: {reply:?}");
|
||||||
|
match reply {
|
||||||
|
Reply::Unanswered => responder.force_unanswered(),
|
||||||
|
Reply::Success | Reply::Failure => responder.send(Response {
|
||||||
|
success: matches!(reply, Reply::Success),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DealerManager {
|
||||||
|
async fn get_url(session: Session) -> GetUrlResult {
|
||||||
|
let (host, port) = session.apresolver().resolve("dealer").await?;
|
||||||
|
let token = session.login5().auth_token().await?.access_token;
|
||||||
|
let url = format!("wss://{host}:{port}/?access_token={token}");
|
||||||
|
let url = Url::from_str(&url)?;
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_listen_for(&self, url: impl Into<String>) -> Result<Subscription, Error> {
|
||||||
|
let url = url.into();
|
||||||
|
self.lock(|inner| {
|
||||||
|
if let Some(dealer) = inner.dealer.get() {
|
||||||
|
dealer.subscribe(&[&url])
|
||||||
|
} else if let Some(builder) = inner.builder.get_mut() {
|
||||||
|
builder.subscribe(&[&url])
|
||||||
|
} else {
|
||||||
|
Err(DealerError::BuilderNotAvailable.into())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn listen_for<T>(
|
||||||
|
&self,
|
||||||
|
uri: impl Into<String>,
|
||||||
|
t: impl Fn(Message) -> Result<T, Error> + Send + 'static,
|
||||||
|
) -> Result<BoxedStreamResult<T>, Error> {
|
||||||
|
Ok(Box::pin(self.add_listen_for(uri)?.map(t)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_handle_for(&self, url: impl Into<String>) -> Result<RequestReceiver, Error> {
|
||||||
|
let url = url.into();
|
||||||
|
|
||||||
|
let (handler, receiver) = DealerRequestHandler::new();
|
||||||
|
self.lock(|inner| {
|
||||||
|
if let Some(dealer) = inner.dealer.get() {
|
||||||
|
dealer.add_handler(&url, handler).map(|_| receiver)
|
||||||
|
} else if let Some(builder) = inner.builder.get_mut() {
|
||||||
|
builder.add_handler(&url, handler).map(|_| receiver)
|
||||||
|
} else {
|
||||||
|
Err(DealerError::BuilderNotAvailable.into())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_for(&self, uri: impl Into<String>) -> Result<BoxedStream<RequestReply>, Error> {
|
||||||
|
Ok(Box::pin(
|
||||||
|
self.add_handle_for(uri).map(UnboundedReceiverStream::new)?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handles(&self, uri: &str) -> bool {
|
||||||
|
self.lock(|inner| {
|
||||||
|
if let Some(dealer) = inner.dealer.get() {
|
||||||
|
dealer.handles(uri)
|
||||||
|
} else if let Some(builder) = inner.builder.get() {
|
||||||
|
builder.handles(uri)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start(&self) -> Result<(), Error> {
|
||||||
|
debug!("Launching dealer");
|
||||||
|
|
||||||
|
let session = self.session();
|
||||||
|
// the url has to be a function that can retrieve a new url,
|
||||||
|
// otherwise when we later try to reconnect with the initial url/token
|
||||||
|
// and the token is expired we will just get 401 error
|
||||||
|
let get_url = move || Self::get_url(session.clone());
|
||||||
|
|
||||||
|
let dealer = self
|
||||||
|
.lock(move |inner| inner.builder.take())
|
||||||
|
.ok_or(DealerError::BuilderNotAvailable)?
|
||||||
|
.launch(get_url, None)
|
||||||
|
.await
|
||||||
|
.map_err(DealerError::LaunchFailure)?;
|
||||||
|
|
||||||
|
self.lock(|inner| inner.dealer.set(dealer))
|
||||||
|
.map_err(|_| DealerError::CouldNotSetDealer)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn close(&self) {
|
||||||
|
if let Some(dealer) = self.lock(|inner| inner.dealer.take()) {
|
||||||
|
dealer.close().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,7 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
use crate::Error;
|
use crate::Error;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum HandlerMapError {
|
pub enum HandlerMapError {
|
||||||
|
@ -28,6 +27,10 @@ impl<T> Default for HandlerMap<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> HandlerMap<T> {
|
impl<T> HandlerMap<T> {
|
||||||
|
pub fn contains(&self, path: &str) -> bool {
|
||||||
|
matches!(self, HandlerMap::Branch(map) if map.contains_key(path))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn insert<'a>(
|
pub fn insert<'a>(
|
||||||
&mut self,
|
&mut self,
|
||||||
mut path: impl Iterator<Item = &'a str>,
|
mut path: impl Iterator<Item = &'a str>,
|
||||||
|
@ -107,6 +110,22 @@ impl<T> SubscriberMap<T> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn contains<'a>(&self, mut path: impl Iterator<Item = &'a str>) -> bool {
|
||||||
|
if !self.subscribed.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(next) = path.next() {
|
||||||
|
if let Some(next_map) = self.children.get(next) {
|
||||||
|
return next_map.contains(path);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return !self.is_empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.children.is_empty() && self.subscribed.is_empty()
|
self.children.is_empty() && self.subscribed.is_empty()
|
||||||
}
|
}
|
||||||
|
@ -115,16 +134,22 @@ impl<T> SubscriberMap<T> {
|
||||||
&mut self,
|
&mut self,
|
||||||
mut path: impl Iterator<Item = &'a str>,
|
mut path: impl Iterator<Item = &'a str>,
|
||||||
fun: &mut impl FnMut(&T) -> bool,
|
fun: &mut impl FnMut(&T) -> bool,
|
||||||
) {
|
) -> bool {
|
||||||
self.subscribed.retain(|x| fun(x));
|
let mut handled_by_any = false;
|
||||||
|
self.subscribed.retain(|x| {
|
||||||
|
handled_by_any = true;
|
||||||
|
fun(x)
|
||||||
|
});
|
||||||
|
|
||||||
if let Some(next) = path.next() {
|
if let Some(next) = path.next() {
|
||||||
if let Some(y) = self.children.get_mut(next) {
|
if let Some(y) = self.children.get_mut(next) {
|
||||||
y.retain(path, fun);
|
handled_by_any = handled_by_any || y.retain(path, fun);
|
||||||
if y.is_empty() {
|
if y.is_empty() {
|
||||||
self.children.remove(next);
|
self.children.remove(next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handled_by_any
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod manager;
|
||||||
mod maps;
|
mod maps;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
|
|
||||||
|
@ -28,8 +29,10 @@ use tokio_tungstenite::tungstenite;
|
||||||
use tungstenite::error::UrlError;
|
use tungstenite::error::UrlError;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use self::maps::*;
|
use self::{
|
||||||
use self::protocol::*;
|
maps::*,
|
||||||
|
protocol::{Message, MessageOrRequest, Request, WebsocketMessage, WebsocketRequest},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
socket,
|
socket,
|
||||||
|
@ -39,7 +42,14 @@ use crate::{
|
||||||
|
|
||||||
type WsMessage = tungstenite::Message;
|
type WsMessage = tungstenite::Message;
|
||||||
type WsError = tungstenite::Error;
|
type WsError = tungstenite::Error;
|
||||||
type WsResult<T> = Result<T, tungstenite::Error>;
|
type WsResult<T> = Result<T, Error>;
|
||||||
|
type GetUrlResult = Result<Url, Error>;
|
||||||
|
|
||||||
|
impl From<WsError> for Error {
|
||||||
|
fn from(err: WsError) -> Self {
|
||||||
|
Error::failed_precondition(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const WEBSOCKET_CLOSE_TIMEOUT: Duration = Duration::from_secs(3);
|
const WEBSOCKET_CLOSE_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
@ -48,11 +58,11 @@ const PING_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
const RECONNECT_INTERVAL: Duration = Duration::from_secs(10);
|
const RECONNECT_INTERVAL: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
pub struct Response {
|
struct Response {
|
||||||
pub success: bool,
|
pub success: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Responder {
|
struct Responder {
|
||||||
key: String,
|
key: String,
|
||||||
tx: mpsc::UnboundedSender<WsMessage>,
|
tx: mpsc::UnboundedSender<WsMessage>,
|
||||||
sent: bool,
|
sent: bool,
|
||||||
|
@ -101,7 +111,7 @@ impl Drop for Responder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait IntoResponse {
|
trait IntoResponse {
|
||||||
fn respond(self, responder: Responder);
|
fn respond(self, responder: Responder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -132,7 +142,7 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait RequestHandler: Send + 'static {
|
trait RequestHandler: Send + 'static {
|
||||||
fn handle_request(&self, request: Request, responder: Responder);
|
fn handle_request(&self, request: Request, responder: Responder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -156,8 +166,10 @@ impl Stream for Subscription {
|
||||||
fn split_uri(s: &str) -> Option<impl Iterator<Item = &'_ str>> {
|
fn split_uri(s: &str) -> Option<impl Iterator<Item = &'_ str>> {
|
||||||
let (scheme, sep, rest) = if let Some(rest) = s.strip_prefix("hm://") {
|
let (scheme, sep, rest) = if let Some(rest) = s.strip_prefix("hm://") {
|
||||||
("hm", '/', rest)
|
("hm", '/', rest)
|
||||||
} else if let Some(rest) = s.strip_suffix("spotify:") {
|
} else if let Some(rest) = s.strip_prefix("spotify:") {
|
||||||
("spotify", ':', rest)
|
("spotify", ':', rest)
|
||||||
|
} else if s.contains('/') {
|
||||||
|
("", '/', s)
|
||||||
} else {
|
} else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
|
@ -169,7 +181,7 @@ fn split_uri(s: &str) -> Option<impl Iterator<Item = &'_ str>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Error)]
|
#[derive(Debug, Clone, Error)]
|
||||||
pub enum AddHandlerError {
|
enum AddHandlerError {
|
||||||
#[error("There is already a handler for the given uri")]
|
#[error("There is already a handler for the given uri")]
|
||||||
AlreadyHandled,
|
AlreadyHandled,
|
||||||
#[error("The specified uri {0} is invalid")]
|
#[error("The specified uri {0} is invalid")]
|
||||||
|
@ -186,7 +198,7 @@ impl From<AddHandlerError> for Error {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Error)]
|
#[derive(Debug, Clone, Error)]
|
||||||
pub enum SubscriptionError {
|
enum SubscriptionError {
|
||||||
#[error("The specified uri is invalid")]
|
#[error("The specified uri is invalid")]
|
||||||
InvalidUri(String),
|
InvalidUri(String),
|
||||||
}
|
}
|
||||||
|
@ -224,8 +236,23 @@ fn subscribe(
|
||||||
Ok(Subscription(rx))
|
Ok(Subscription(rx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handles(
|
||||||
|
req_map: &HandlerMap<Box<dyn RequestHandler>>,
|
||||||
|
msg_map: &SubscriberMap<MessageHandler>,
|
||||||
|
uri: &str,
|
||||||
|
) -> bool {
|
||||||
|
if req_map.contains(uri) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
match split_uri(uri) {
|
||||||
|
None => false,
|
||||||
|
Some(mut split) => msg_map.contains(&mut split),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Builder {
|
struct Builder {
|
||||||
message_handlers: SubscriberMap<MessageHandler>,
|
message_handlers: SubscriberMap<MessageHandler>,
|
||||||
request_handlers: HandlerMap<Box<dyn RequestHandler>>,
|
request_handlers: HandlerMap<Box<dyn RequestHandler>>,
|
||||||
}
|
}
|
||||||
|
@ -267,22 +294,26 @@ impl Builder {
|
||||||
subscribe(&mut self.message_handlers, uris)
|
subscribe(&mut self.message_handlers, uris)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn handles(&self, uri: &str) -> bool {
|
||||||
|
handles(&self.request_handlers, &self.message_handlers, uri)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn launch_in_background<Fut, F>(self, get_url: F, proxy: Option<Url>) -> Dealer
|
pub fn launch_in_background<Fut, F>(self, get_url: F, proxy: Option<Url>) -> Dealer
|
||||||
where
|
where
|
||||||
Fut: Future<Output = Url> + Send + 'static,
|
Fut: Future<Output = GetUrlResult> + Send + 'static,
|
||||||
F: (FnMut() -> Fut) + Send + 'static,
|
F: (Fn() -> Fut) + Send + 'static,
|
||||||
{
|
{
|
||||||
create_dealer!(self, shared -> run(shared, None, get_url, proxy))
|
create_dealer!(self, shared -> run(shared, None, get_url, proxy))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn launch<Fut, F>(self, mut get_url: F, proxy: Option<Url>) -> WsResult<Dealer>
|
pub async fn launch<Fut, F>(self, get_url: F, proxy: Option<Url>) -> WsResult<Dealer>
|
||||||
where
|
where
|
||||||
Fut: Future<Output = Url> + Send + 'static,
|
Fut: Future<Output = GetUrlResult> + Send + 'static,
|
||||||
F: (FnMut() -> Fut) + Send + 'static,
|
F: (Fn() -> Fut) + Send + 'static,
|
||||||
{
|
{
|
||||||
let dealer = create_dealer!(self, shared -> {
|
let dealer = create_dealer!(self, shared -> {
|
||||||
// Try to connect.
|
// Try to connect.
|
||||||
let url = get_url().await;
|
let url = get_url().await?;
|
||||||
let tasks = connect(&url, proxy.as_ref(), &shared).await?;
|
let tasks = connect(&url, proxy.as_ref(), &shared).await?;
|
||||||
|
|
||||||
// If a connection is established, continue in a background task.
|
// If a connection is established, continue in a background task.
|
||||||
|
@ -303,15 +334,47 @@ struct DealerShared {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DealerShared {
|
impl DealerShared {
|
||||||
fn dispatch_message(&self, msg: Message) {
|
fn dispatch_message(&self, mut msg: WebsocketMessage) {
|
||||||
|
let msg = match msg.handle_payload() {
|
||||||
|
Ok(value) => Message {
|
||||||
|
headers: msg.headers,
|
||||||
|
payload: value,
|
||||||
|
uri: msg.uri,
|
||||||
|
},
|
||||||
|
Err(why) => {
|
||||||
|
warn!("failure during data parsing for {}: {why}", msg.uri);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(split) = split_uri(&msg.uri) {
|
if let Some(split) = split_uri(&msg.uri) {
|
||||||
self.message_handlers
|
if self
|
||||||
|
.message_handlers
|
||||||
.lock()
|
.lock()
|
||||||
.retain(split, &mut |tx| tx.send(msg.clone()).is_ok());
|
.retain(split, &mut |tx| tx.send(msg.clone()).is_ok())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
warn!("No subscriber for msg.uri: {}", msg.uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dispatch_request(&self, request: Request, send_tx: &mpsc::UnboundedSender<WsMessage>) {
|
fn dispatch_request(
|
||||||
|
&self,
|
||||||
|
request: WebsocketRequest,
|
||||||
|
send_tx: &mpsc::UnboundedSender<WsMessage>,
|
||||||
|
) {
|
||||||
|
trace!("dealer request {}", &request.message_ident);
|
||||||
|
|
||||||
|
let payload_request = match request.handle_payload() {
|
||||||
|
Ok(payload) => payload,
|
||||||
|
Err(why) => {
|
||||||
|
warn!("request payload handling failed because of {why}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ResponseSender will automatically send "success: false" if it is dropped without an answer.
|
// ResponseSender will automatically send "success: false" if it is dropped without an answer.
|
||||||
let responder = Responder::new(request.key.clone(), send_tx.clone());
|
let responder = Responder::new(request.key.clone(), send_tx.clone());
|
||||||
|
|
||||||
|
@ -325,13 +388,11 @@ impl DealerShared {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
{
|
let handler_map = self.request_handlers.lock();
|
||||||
let handler_map = self.request_handlers.lock();
|
|
||||||
|
|
||||||
if let Some(handler) = handler_map.get(split) {
|
if let Some(handler) = handler_map.get(split) {
|
||||||
handler.handle_request(request, responder);
|
handler.handle_request(payload_request, responder);
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
warn!("No handler for message_ident: {}", &request.message_ident);
|
warn!("No handler for message_ident: {}", &request.message_ident);
|
||||||
|
@ -355,9 +416,9 @@ impl DealerShared {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Dealer {
|
struct Dealer {
|
||||||
shared: Arc<DealerShared>,
|
shared: Arc<DealerShared>,
|
||||||
handle: TimeoutOnDrop<()>,
|
handle: TimeoutOnDrop<Result<(), Error>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Dealer {
|
impl Dealer {
|
||||||
|
@ -376,6 +437,14 @@ impl Dealer {
|
||||||
subscribe(&mut self.shared.message_handlers.lock(), uris)
|
subscribe(&mut self.shared.message_handlers.lock(), uris)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn handles(&self, uri: &str) -> bool {
|
||||||
|
handles(
|
||||||
|
&self.shared.request_handlers.lock(),
|
||||||
|
&self.shared.message_handlers.lock(),
|
||||||
|
uri,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn close(mut self) {
|
pub async fn close(mut self) {
|
||||||
debug!("closing dealer");
|
debug!("closing dealer");
|
||||||
|
|
||||||
|
@ -402,7 +471,7 @@ async fn connect(
|
||||||
let default_port = match address.scheme() {
|
let default_port = match address.scheme() {
|
||||||
"ws" => 80,
|
"ws" => 80,
|
||||||
"wss" => 443,
|
"wss" => 443,
|
||||||
_ => return Err(WsError::Url(UrlError::UnsupportedUrlScheme)),
|
_ => return Err(WsError::Url(UrlError::UnsupportedUrlScheme).into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let port = address.port().unwrap_or(default_port);
|
let port = address.port().unwrap_or(default_port);
|
||||||
|
@ -484,13 +553,13 @@ async fn connect(
|
||||||
Some(Ok(msg)) => match msg {
|
Some(Ok(msg)) => match msg {
|
||||||
WsMessage::Text(t) => match serde_json::from_str(&t) {
|
WsMessage::Text(t) => match serde_json::from_str(&t) {
|
||||||
Ok(m) => shared.dispatch(m, &send_tx),
|
Ok(m) => shared.dispatch(m, &send_tx),
|
||||||
Err(e) => info!("Received invalid message: {}", e),
|
Err(e) => warn!("Message couldn't be parsed: {e}. Message was {t}"),
|
||||||
},
|
},
|
||||||
WsMessage::Binary(_) => {
|
WsMessage::Binary(_) => {
|
||||||
info!("Received invalid binary message");
|
info!("Received invalid binary message");
|
||||||
}
|
}
|
||||||
WsMessage::Pong(_) => {
|
WsMessage::Pong(_) => {
|
||||||
debug!("Received pong");
|
trace!("Received pong");
|
||||||
pong_received.store(true, atomic::Ordering::Relaxed);
|
pong_received.store(true, atomic::Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
_ => (), // tungstenite handles Close and Ping automatically
|
_ => (), // tungstenite handles Close and Ping automatically
|
||||||
|
@ -522,7 +591,7 @@ async fn connect(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("Sent ping");
|
trace!("Sent ping");
|
||||||
|
|
||||||
sleep(PING_TIMEOUT).await;
|
sleep(PING_TIMEOUT).await;
|
||||||
|
|
||||||
|
@ -556,8 +625,9 @@ async fn run<F, Fut>(
|
||||||
initial_tasks: Option<(JoinHandle<()>, JoinHandle<()>)>,
|
initial_tasks: Option<(JoinHandle<()>, JoinHandle<()>)>,
|
||||||
mut get_url: F,
|
mut get_url: F,
|
||||||
proxy: Option<Url>,
|
proxy: Option<Url>,
|
||||||
) where
|
) -> Result<(), Error>
|
||||||
Fut: Future<Output = Url> + Send + 'static,
|
where
|
||||||
|
Fut: Future<Output = GetUrlResult> + Send + 'static,
|
||||||
F: (FnMut() -> Fut) + Send + 'static,
|
F: (FnMut() -> Fut) + Send + 'static,
|
||||||
{
|
{
|
||||||
let init_task = |t| Some(TimeoutOnDrop::new(t, WEBSOCKET_CLOSE_TIMEOUT));
|
let init_task = |t| Some(TimeoutOnDrop::new(t, WEBSOCKET_CLOSE_TIMEOUT));
|
||||||
|
@ -593,7 +663,7 @@ async fn run<F, Fut>(
|
||||||
break
|
break
|
||||||
},
|
},
|
||||||
e = get_url() => e
|
e = get_url() => e
|
||||||
};
|
}?;
|
||||||
|
|
||||||
match connect(&url, proxy.as_ref(), &shared).await {
|
match connect(&url, proxy.as_ref(), &shared).await {
|
||||||
Ok((s, r)) => tasks = (init_task(s), init_task(r)),
|
Ok((s, r)) => tasks = (init_task(s), init_task(r)),
|
||||||
|
@ -609,4 +679,6 @@ async fn run<F, Fut>(
|
||||||
let tasks = tasks.0.into_iter().chain(tasks.1);
|
let tasks = tasks.0.into_iter().chain(tasks.1);
|
||||||
|
|
||||||
let _ = join_all(tasks).await;
|
let _ = join_all(tasks).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,19 +1,58 @@
|
||||||
|
pub mod request;
|
||||||
|
|
||||||
|
pub use request::*;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::io::{Error as IoError, Read};
|
||||||
|
|
||||||
|
use crate::Error;
|
||||||
|
use base64::prelude::BASE64_STANDARD;
|
||||||
|
use base64::{DecodeError, Engine};
|
||||||
|
use flate2::read::GzDecoder;
|
||||||
|
use log::LevelFilter;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serde_json::Error as SerdeError;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
pub type JsonValue = serde_json::Value;
|
const IGNORE_UNKNOWN: protobuf_json_mapping::ParseOptions = protobuf_json_mapping::ParseOptions {
|
||||||
pub type JsonObject = serde_json::Map<String, JsonValue>;
|
ignore_unknown_fields: true,
|
||||||
|
_future_options: (),
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
type JsonValue = serde_json::Value;
|
||||||
pub struct Payload {
|
|
||||||
pub message_id: i32,
|
#[derive(Debug, Error)]
|
||||||
pub sent_by_device_id: String,
|
enum ProtocolError {
|
||||||
pub command: JsonObject,
|
#[error("base64 decoding failed: {0}")]
|
||||||
|
Base64(DecodeError),
|
||||||
|
#[error("gzip decoding failed: {0}")]
|
||||||
|
GZip(IoError),
|
||||||
|
#[error("deserialization failed: {0}")]
|
||||||
|
Deserialization(SerdeError),
|
||||||
|
#[error("payload had more then one value. had {0} values")]
|
||||||
|
MoreThenOneValue(usize),
|
||||||
|
#[error("received unexpected data {0:#?}")]
|
||||||
|
UnexpectedData(PayloadValue),
|
||||||
|
#[error("payload was empty")]
|
||||||
|
Empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ProtocolError> for Error {
|
||||||
|
fn from(err: ProtocolError) -> Self {
|
||||||
|
match err {
|
||||||
|
ProtocolError::UnexpectedData(_) => Error::unavailable(err),
|
||||||
|
_ => Error::failed_precondition(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct Request {
|
pub(super) struct Payload {
|
||||||
|
pub compressed: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub(super) struct WebsocketRequest {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub headers: HashMap<String, String>,
|
pub headers: HashMap<String, String>,
|
||||||
pub message_ident: String,
|
pub message_ident: String,
|
||||||
|
@ -22,18 +61,133 @@ pub struct Request {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct Message {
|
pub(super) struct WebsocketMessage {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub headers: HashMap<String, String>,
|
pub headers: HashMap<String, String>,
|
||||||
pub method: Option<String>,
|
pub method: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub payloads: Vec<JsonValue>,
|
pub payloads: Vec<MessagePayloadValue>,
|
||||||
pub uri: String,
|
pub uri: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum MessagePayloadValue {
|
||||||
|
String(String),
|
||||||
|
Bytes(Vec<u8>),
|
||||||
|
Json(JsonValue),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub(super) enum MessageOrRequest {
|
pub(super) enum MessageOrRequest {
|
||||||
Message(Message),
|
Message(WebsocketMessage),
|
||||||
Request(Request),
|
Request(WebsocketRequest),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum PayloadValue {
|
||||||
|
Empty,
|
||||||
|
Raw(Vec<u8>),
|
||||||
|
Json(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Message {
|
||||||
|
pub headers: HashMap<String, String>,
|
||||||
|
pub payload: PayloadValue,
|
||||||
|
pub uri: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Message {
|
||||||
|
pub fn from_json<M: protobuf::MessageFull>(value: Self) -> Result<M, Error> {
|
||||||
|
use protobuf_json_mapping::*;
|
||||||
|
match value.payload {
|
||||||
|
PayloadValue::Json(json) => match parse_from_str::<M>(&json) {
|
||||||
|
Ok(message) => Ok(message),
|
||||||
|
Err(_) => match parse_from_str_with_options(&json, &IGNORE_UNKNOWN) {
|
||||||
|
Ok(message) => Ok(message),
|
||||||
|
Err(why) => Err(Error::failed_precondition(why)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
other => Err(ProtocolError::UnexpectedData(other).into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_raw<M: protobuf::Message>(value: Self) -> Result<M, Error> {
|
||||||
|
match value.payload {
|
||||||
|
PayloadValue::Raw(bytes) => {
|
||||||
|
M::parse_from_bytes(&bytes).map_err(Error::failed_precondition)
|
||||||
|
}
|
||||||
|
other => Err(ProtocolError::UnexpectedData(other).into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebsocketMessage {
|
||||||
|
pub fn handle_payload(&mut self) -> Result<PayloadValue, Error> {
|
||||||
|
if self.payloads.is_empty() {
|
||||||
|
return Ok(PayloadValue::Empty);
|
||||||
|
} else if self.payloads.len() > 1 {
|
||||||
|
return Err(ProtocolError::MoreThenOneValue(self.payloads.len()).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = self.payloads.pop().ok_or(ProtocolError::Empty)?;
|
||||||
|
let bytes = match payload {
|
||||||
|
MessagePayloadValue::String(string) => BASE64_STANDARD
|
||||||
|
.decode(string)
|
||||||
|
.map_err(ProtocolError::Base64)?,
|
||||||
|
MessagePayloadValue::Bytes(bytes) => bytes,
|
||||||
|
MessagePayloadValue::Json(json) => return Ok(PayloadValue::Json(json.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
handle_transfer_encoding(&self.headers, bytes).map(PayloadValue::Raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebsocketRequest {
|
||||||
|
pub fn handle_payload(&self) -> Result<Request, Error> {
|
||||||
|
let payload_bytes = BASE64_STANDARD
|
||||||
|
.decode(&self.payload.compressed)
|
||||||
|
.map_err(ProtocolError::Base64)?;
|
||||||
|
|
||||||
|
let payload = handle_transfer_encoding(&self.headers, payload_bytes)?;
|
||||||
|
let payload = String::from_utf8(payload)?;
|
||||||
|
|
||||||
|
if log::max_level() >= LevelFilter::Trace {
|
||||||
|
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&payload) {
|
||||||
|
trace!("websocket request: {json:#?}");
|
||||||
|
} else {
|
||||||
|
trace!("websocket request: {payload}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_str(&payload)
|
||||||
|
.map_err(ProtocolError::Deserialization)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_transfer_encoding(
|
||||||
|
headers: &HashMap<String, String>,
|
||||||
|
data: Vec<u8>,
|
||||||
|
) -> Result<Vec<u8>, Error> {
|
||||||
|
let encoding = headers.get("Transfer-Encoding").map(String::as_str);
|
||||||
|
if let Some(encoding) = encoding {
|
||||||
|
trace!("message was send with {encoding} encoding ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matches!(encoding, Some("gzip")) {
|
||||||
|
return Ok(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut gz = GzDecoder::new(&data[..]);
|
||||||
|
let mut bytes = vec![];
|
||||||
|
match gz.read_to_end(&mut bytes) {
|
||||||
|
Ok(i) if i == bytes.len() => Ok(bytes),
|
||||||
|
Ok(_) => Err(Error::failed_precondition(
|
||||||
|
"read bytes mismatched with expected bytes",
|
||||||
|
)),
|
||||||
|
Err(why) => Err(ProtocolError::GZip(why).into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
208
core/src/dealer/protocol/request.rs
Normal file
208
core/src/dealer/protocol/request.rs
Normal file
|
@ -0,0 +1,208 @@
|
||||||
|
use crate::deserialize_with::*;
|
||||||
|
use librespot_protocol::player::{
|
||||||
|
Context, ContextPlayerOptionOverrides, PlayOrigin, ProvidedTrack, TransferState,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct Request {
|
||||||
|
pub message_id: u32,
|
||||||
|
// todo: did only send target_alias_id: null so far, maybe we just ignore it, will see
|
||||||
|
// pub target_alias_id: Option<()>,
|
||||||
|
pub sent_by_device_id: String,
|
||||||
|
pub command: Command,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(tag = "endpoint", rename_all = "snake_case")]
|
||||||
|
pub enum Command {
|
||||||
|
Transfer(TransferCommand),
|
||||||
|
#[serde(deserialize_with = "boxed")]
|
||||||
|
Play(Box<PlayCommand>),
|
||||||
|
Pause(PauseCommand),
|
||||||
|
SeekTo(SeekToCommand),
|
||||||
|
SetShufflingContext(SetValueCommand),
|
||||||
|
SetRepeatingTrack(SetValueCommand),
|
||||||
|
SetRepeatingContext(SetValueCommand),
|
||||||
|
AddToQueue(AddToQueueCommand),
|
||||||
|
SetQueue(SetQueueCommand),
|
||||||
|
SetOptions(SetOptionsCommand),
|
||||||
|
UpdateContext(UpdateContextCommand),
|
||||||
|
SkipNext(SkipNextCommand),
|
||||||
|
// commands that don't send any context (at least not usually...)
|
||||||
|
SkipPrev(GenericCommand),
|
||||||
|
Resume(GenericCommand),
|
||||||
|
// catch unknown commands, so that we can implement them later
|
||||||
|
#[serde(untagged)]
|
||||||
|
Unknown(Value),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Command {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
use Command::*;
|
||||||
|
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"endpoint: {}{}",
|
||||||
|
matches!(self, Unknown(_))
|
||||||
|
.then_some("unknown ")
|
||||||
|
.unwrap_or_default(),
|
||||||
|
match self {
|
||||||
|
Transfer(_) => "transfer",
|
||||||
|
Play(_) => "play",
|
||||||
|
Pause(_) => "pause",
|
||||||
|
SeekTo(_) => "seek_to",
|
||||||
|
SetShufflingContext(_) => "set_shuffling_context",
|
||||||
|
SetRepeatingContext(_) => "set_repeating_context",
|
||||||
|
SetRepeatingTrack(_) => "set_repeating_track",
|
||||||
|
AddToQueue(_) => "add_to_queue",
|
||||||
|
SetQueue(_) => "set_queue",
|
||||||
|
SetOptions(_) => "set_options",
|
||||||
|
UpdateContext(_) => "update_context",
|
||||||
|
SkipNext(_) => "skip_next",
|
||||||
|
SkipPrev(_) => "skip_prev",
|
||||||
|
Resume(_) => "resume",
|
||||||
|
Unknown(json) => {
|
||||||
|
json.as_object()
|
||||||
|
.and_then(|obj| obj.get("endpoint").map(|v| v.as_str()))
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or("???")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct TransferCommand {
|
||||||
|
#[serde(default, deserialize_with = "base64_proto")]
|
||||||
|
pub data: Option<TransferState>,
|
||||||
|
pub options: TransferOptions,
|
||||||
|
pub from_device_identifier: String,
|
||||||
|
pub logging_params: LoggingParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct PlayCommand {
|
||||||
|
#[serde(deserialize_with = "json_proto")]
|
||||||
|
pub context: Context,
|
||||||
|
#[serde(deserialize_with = "json_proto")]
|
||||||
|
pub play_origin: PlayOrigin,
|
||||||
|
pub options: PlayOptions,
|
||||||
|
pub logging_params: LoggingParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct PauseCommand {
|
||||||
|
// does send options with it, but seems to be empty, investigate which options are send here
|
||||||
|
pub logging_params: LoggingParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct SeekToCommand {
|
||||||
|
pub value: u32,
|
||||||
|
pub position: u32,
|
||||||
|
pub logging_params: LoggingParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct SkipNextCommand {
|
||||||
|
#[serde(default, deserialize_with = "option_json_proto")]
|
||||||
|
pub track: Option<ProvidedTrack>,
|
||||||
|
pub logging_params: LoggingParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct SetValueCommand {
|
||||||
|
pub value: bool,
|
||||||
|
pub logging_params: LoggingParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct AddToQueueCommand {
|
||||||
|
#[serde(deserialize_with = "json_proto")]
|
||||||
|
pub track: ProvidedTrack,
|
||||||
|
pub logging_params: LoggingParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct SetQueueCommand {
|
||||||
|
#[serde(deserialize_with = "vec_json_proto")]
|
||||||
|
pub next_tracks: Vec<ProvidedTrack>,
|
||||||
|
#[serde(deserialize_with = "vec_json_proto")]
|
||||||
|
pub prev_tracks: Vec<ProvidedTrack>,
|
||||||
|
// this queue revision is actually the last revision, so using it will not update the web ui
|
||||||
|
// might be that internally they use the last revision to create the next revision
|
||||||
|
pub queue_revision: String,
|
||||||
|
pub logging_params: LoggingParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct SetOptionsCommand {
|
||||||
|
pub shuffling_context: Option<bool>,
|
||||||
|
pub repeating_context: Option<bool>,
|
||||||
|
pub repeating_track: Option<bool>,
|
||||||
|
pub options: Option<OptionsOptions>,
|
||||||
|
pub logging_params: LoggingParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct UpdateContextCommand {
|
||||||
|
#[serde(deserialize_with = "json_proto")]
|
||||||
|
pub context: Context,
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct GenericCommand {
|
||||||
|
pub logging_params: LoggingParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct TransferOptions {
|
||||||
|
pub restore_paused: String,
|
||||||
|
pub restore_position: String,
|
||||||
|
pub restore_track: String,
|
||||||
|
pub retain_session: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct PlayOptions {
|
||||||
|
pub skip_to: SkipTo,
|
||||||
|
#[serde(default, deserialize_with = "option_json_proto")]
|
||||||
|
pub player_options_override: Option<ContextPlayerOptionOverrides>,
|
||||||
|
pub license: Option<String>,
|
||||||
|
// possible to send wie web-api
|
||||||
|
pub seek_to: Option<u32>,
|
||||||
|
// mobile
|
||||||
|
pub always_play_something: Option<bool>,
|
||||||
|
pub audio_stream: Option<String>,
|
||||||
|
pub initially_paused: Option<bool>,
|
||||||
|
pub prefetch_level: Option<String>,
|
||||||
|
pub system_initiated: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct OptionsOptions {
|
||||||
|
only_for_local_device: bool,
|
||||||
|
override_restrictions: bool,
|
||||||
|
system_initiated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct SkipTo {
|
||||||
|
pub track_uid: Option<String>,
|
||||||
|
pub track_uri: Option<String>,
|
||||||
|
pub track_index: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct LoggingParams {
|
||||||
|
pub interaction_ids: Option<Vec<String>>,
|
||||||
|
pub device_identifier: Option<String>,
|
||||||
|
pub command_initiated_time: Option<i64>,
|
||||||
|
pub page_instance_ids: Option<Vec<String>>,
|
||||||
|
pub command_id: Option<String>,
|
||||||
|
}
|
94
core/src/deserialize_with.rs
Normal file
94
core/src/deserialize_with.rs
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
use base64::prelude::BASE64_STANDARD;
|
||||||
|
use base64::Engine;
|
||||||
|
use protobuf::MessageFull;
|
||||||
|
use serde::de::{Error, Unexpected};
|
||||||
|
use serde::{Deserialize, Deserializer};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
const IGNORE_UNKNOWN: protobuf_json_mapping::ParseOptions = protobuf_json_mapping::ParseOptions {
|
||||||
|
ignore_unknown_fields: true,
|
||||||
|
_future_options: (),
|
||||||
|
};
|
||||||
|
|
||||||
|
fn parse_value_to_msg<T: MessageFull>(
|
||||||
|
value: &Value,
|
||||||
|
) -> Result<T, protobuf_json_mapping::ParseError> {
|
||||||
|
protobuf_json_mapping::parse_from_str_with_options::<T>(&value.to_string(), &IGNORE_UNKNOWN)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base64_proto<'de, T, D>(de: D) -> Result<Option<T>, D::Error>
|
||||||
|
where
|
||||||
|
T: MessageFull,
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let v: String = Deserialize::deserialize(de)?;
|
||||||
|
let bytes = BASE64_STANDARD
|
||||||
|
.decode(v)
|
||||||
|
.map_err(|e| Error::custom(e.to_string()))?;
|
||||||
|
|
||||||
|
T::parse_from_bytes(&bytes).map(Some).map_err(Error::custom)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn json_proto<'de, T, D>(de: D) -> Result<T, D::Error>
|
||||||
|
where
|
||||||
|
T: MessageFull,
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let v: Value = Deserialize::deserialize(de)?;
|
||||||
|
parse_value_to_msg(&v).map_err(|why| {
|
||||||
|
warn!("deserialize_json_proto: {v}");
|
||||||
|
error!("deserialize_json_proto: {why}");
|
||||||
|
Error::custom(why)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn option_json_proto<'de, T, D>(de: D) -> Result<Option<T>, D::Error>
|
||||||
|
where
|
||||||
|
T: MessageFull,
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let v: Value = Deserialize::deserialize(de)?;
|
||||||
|
parse_value_to_msg(&v).map(Some).map_err(Error::custom)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn vec_json_proto<'de, T, D>(de: D) -> Result<Vec<T>, D::Error>
|
||||||
|
where
|
||||||
|
T: MessageFull,
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let v: Value = Deserialize::deserialize(de)?;
|
||||||
|
let array = match v {
|
||||||
|
Value::Array(array) => array,
|
||||||
|
_ => return Err(Error::custom("the value wasn't an array")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = array
|
||||||
|
.iter()
|
||||||
|
.flat_map(parse_value_to_msg)
|
||||||
|
.collect::<Vec<T>>();
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn boxed<'de, T, D>(de: D) -> Result<Box<T>, D::Error>
|
||||||
|
where
|
||||||
|
T: Deserialize<'de>,
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let v: T = Deserialize::deserialize(de)?;
|
||||||
|
Ok(Box::new(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bool_from_string<'de, D>(de: D) -> Result<bool, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
match String::deserialize(de)?.as_ref() {
|
||||||
|
"true" => Ok(true),
|
||||||
|
"false" => Ok(false),
|
||||||
|
other => Err(Error::invalid_value(
|
||||||
|
Unexpected::Str(other),
|
||||||
|
&"true or false",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
|
@ -499,3 +499,9 @@ impl From<Utf8Error> for Error {
|
||||||
Self::new(ErrorKind::FailedPrecondition, err)
|
Self::new(ErrorKind::FailedPrecondition, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<protobuf_json_mapping::ParseError> for Error {
|
||||||
|
fn from(err: protobuf_json_mapping::ParseError) -> Self {
|
||||||
|
Self::failed_precondition(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -208,7 +208,7 @@ impl HttpClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if code != StatusCode::OK {
|
if !code.is_success() {
|
||||||
return Err(HttpClientError::StatusCode(code).into());
|
return Err(HttpClientError::StatusCode(code).into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,8 @@ pub mod config;
|
||||||
mod connection;
|
mod connection;
|
||||||
pub mod date;
|
pub mod date;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
mod dealer;
|
pub mod dealer;
|
||||||
|
pub mod deserialize_with;
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub mod diffie_hellman;
|
pub mod diffie_hellman;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|
|
@ -276,12 +276,15 @@ impl MercuryManager {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if !found {
|
if found {
|
||||||
|
Ok(())
|
||||||
|
} else if self.session().dealer().handles(&response.uri) {
|
||||||
|
trace!("mercury response <{}> is handled by dealer", response.uri);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
debug!("unknown subscription uri={}", &response.uri);
|
debug!("unknown subscription uri={}", &response.uri);
|
||||||
trace!("response pushed over Mercury: {:?}", response);
|
trace!("response pushed over Mercury: {:?}", response);
|
||||||
Err(MercuryError::Response(response).into())
|
Err(MercuryError::Response(response).into())
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
} else if let Some(cb) = pending.callback {
|
} else if let Some(cb) = pending.callback {
|
||||||
cb.send(Ok(response)).map_err(|_| MercuryError::Channel)?;
|
cb.send(Ok(response)).map_err(|_| MercuryError::Channel)?;
|
||||||
|
|
|
@ -9,23 +9,7 @@ use std::{
|
||||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use byteorder::{BigEndian, ByteOrder};
|
use crate::dealer::manager::DealerManager;
|
||||||
use bytes::Bytes;
|
|
||||||
use futures_core::TryStream;
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
use librespot_protocol::authentication::AuthenticationType;
|
|
||||||
use num_traits::FromPrimitive;
|
|
||||||
use once_cell::sync::OnceCell;
|
|
||||||
use parking_lot::RwLock;
|
|
||||||
use pin_project_lite::pin_project;
|
|
||||||
use quick_xml::events::Event;
|
|
||||||
use thiserror::Error;
|
|
||||||
use tokio::{
|
|
||||||
sync::mpsc,
|
|
||||||
time::{sleep, Duration as TokioDuration, Instant as TokioInstant, Sleep},
|
|
||||||
};
|
|
||||||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
apresolve::{ApResolver, SocketAddress},
|
apresolve::{ApResolver, SocketAddress},
|
||||||
audio_key::AudioKeyManager,
|
audio_key::AudioKeyManager,
|
||||||
|
@ -43,6 +27,23 @@ use crate::{
|
||||||
token::TokenProvider,
|
token::TokenProvider,
|
||||||
Error,
|
Error,
|
||||||
};
|
};
|
||||||
|
use byteorder::{BigEndian, ByteOrder};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures_core::TryStream;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use librespot_protocol::authentication::AuthenticationType;
|
||||||
|
use num_traits::FromPrimitive;
|
||||||
|
use once_cell::sync::OnceCell;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use pin_project_lite::pin_project;
|
||||||
|
use quick_xml::events::Event;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::{
|
||||||
|
sync::mpsc,
|
||||||
|
time::{sleep, Duration as TokioDuration, Instant as TokioInstant, Sleep},
|
||||||
|
};
|
||||||
|
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum SessionError {
|
pub enum SessionError {
|
||||||
|
@ -78,6 +79,7 @@ pub struct UserData {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
struct SessionData {
|
struct SessionData {
|
||||||
|
session_id: String,
|
||||||
client_id: String,
|
client_id: String,
|
||||||
client_name: String,
|
client_name: String,
|
||||||
client_brand_name: String,
|
client_brand_name: String,
|
||||||
|
@ -100,6 +102,7 @@ struct SessionInternal {
|
||||||
audio_key: OnceCell<AudioKeyManager>,
|
audio_key: OnceCell<AudioKeyManager>,
|
||||||
channel: OnceCell<ChannelManager>,
|
channel: OnceCell<ChannelManager>,
|
||||||
mercury: OnceCell<MercuryManager>,
|
mercury: OnceCell<MercuryManager>,
|
||||||
|
dealer: OnceCell<DealerManager>,
|
||||||
spclient: OnceCell<SpClient>,
|
spclient: OnceCell<SpClient>,
|
||||||
token_provider: OnceCell<TokenProvider>,
|
token_provider: OnceCell<TokenProvider>,
|
||||||
login5: OnceCell<Login5Manager>,
|
login5: OnceCell<Login5Manager>,
|
||||||
|
@ -128,6 +131,8 @@ impl Session {
|
||||||
|
|
||||||
let session_data = SessionData {
|
let session_data = SessionData {
|
||||||
client_id: config.client_id.clone(),
|
client_id: config.client_id.clone(),
|
||||||
|
// can be any guid, doesn't need to be simple
|
||||||
|
session_id: Uuid::new_v4().as_simple().to_string(),
|
||||||
..SessionData::default()
|
..SessionData::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -141,6 +146,7 @@ impl Session {
|
||||||
audio_key: OnceCell::new(),
|
audio_key: OnceCell::new(),
|
||||||
channel: OnceCell::new(),
|
channel: OnceCell::new(),
|
||||||
mercury: OnceCell::new(),
|
mercury: OnceCell::new(),
|
||||||
|
dealer: OnceCell::new(),
|
||||||
spclient: OnceCell::new(),
|
spclient: OnceCell::new(),
|
||||||
token_provider: OnceCell::new(),
|
token_provider: OnceCell::new(),
|
||||||
login5: OnceCell::new(),
|
login5: OnceCell::new(),
|
||||||
|
@ -303,6 +309,12 @@ impl Session {
|
||||||
.get_or_init(|| MercuryManager::new(self.weak()))
|
.get_or_init(|| MercuryManager::new(self.weak()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn dealer(&self) -> &DealerManager {
|
||||||
|
self.0
|
||||||
|
.dealer
|
||||||
|
.get_or_init(|| DealerManager::new(self.weak()))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn spclient(&self) -> &SpClient {
|
pub fn spclient(&self) -> &SpClient {
|
||||||
self.0.spclient.get_or_init(|| SpClient::new(self.weak()))
|
self.0.spclient.get_or_init(|| SpClient::new(self.weak()))
|
||||||
}
|
}
|
||||||
|
@ -373,6 +385,14 @@ impl Session {
|
||||||
self.0.data.read().user_data.clone()
|
self.0.data.read().user_data.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn session_id(&self) -> String {
|
||||||
|
self.0.data.read().session_id.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_session_id(&self, session_id: String) {
|
||||||
|
self.0.data.write().session_id = session_id.to_owned();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn device_id(&self) -> &str {
|
pub fn device_id(&self) -> &str {
|
||||||
&self.config().device_id
|
&self.config().device_id
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,20 +3,6 @@ use std::{
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use bytes::Bytes;
|
|
||||||
use data_encoding::HEXUPPER_PERMISSIVE;
|
|
||||||
use futures_util::future::IntoStream;
|
|
||||||
use http::header::HeaderValue;
|
|
||||||
use hyper::{
|
|
||||||
header::{HeaderName, ACCEPT, AUTHORIZATION, CONTENT_TYPE, RANGE},
|
|
||||||
HeaderMap, Method, Request,
|
|
||||||
};
|
|
||||||
use hyper_util::client::legacy::ResponseFuture;
|
|
||||||
use protobuf::{Enum, Message, MessageFull};
|
|
||||||
use rand::RngCore;
|
|
||||||
use sysinfo::System;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
use crate::config::{os_version, OS};
|
use crate::config::{os_version, OS};
|
||||||
use crate::{
|
use crate::{
|
||||||
apresolve::SocketAddress,
|
apresolve::SocketAddress,
|
||||||
|
@ -37,6 +23,20 @@ use crate::{
|
||||||
version::spotify_semantic_version,
|
version::spotify_semantic_version,
|
||||||
Error, FileId, SpotifyId,
|
Error, FileId, SpotifyId,
|
||||||
};
|
};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use data_encoding::HEXUPPER_PERMISSIVE;
|
||||||
|
use futures_util::future::IntoStream;
|
||||||
|
use http::header::HeaderValue;
|
||||||
|
use hyper::{
|
||||||
|
header::{HeaderName, ACCEPT, AUTHORIZATION, CONTENT_TYPE, RANGE},
|
||||||
|
HeaderMap, Method, Request,
|
||||||
|
};
|
||||||
|
use hyper_util::client::legacy::ResponseFuture;
|
||||||
|
use librespot_protocol::{autoplay_context_request::AutoplayContextRequest, player::Context};
|
||||||
|
use protobuf::{Enum, Message, MessageFull};
|
||||||
|
use rand::RngCore;
|
||||||
|
use sysinfo::System;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
component! {
|
component! {
|
||||||
SpClient : SpClientInner {
|
SpClient : SpClientInner {
|
||||||
|
@ -50,11 +50,20 @@ pub type SpClientResult = Result<Bytes, Error>;
|
||||||
|
|
||||||
#[allow(clippy::declare_interior_mutable_const)]
|
#[allow(clippy::declare_interior_mutable_const)]
|
||||||
pub const CLIENT_TOKEN: HeaderName = HeaderName::from_static("client-token");
|
pub const CLIENT_TOKEN: HeaderName = HeaderName::from_static("client-token");
|
||||||
|
#[allow(clippy::declare_interior_mutable_const)]
|
||||||
|
const CONNECTION_ID: HeaderName = HeaderName::from_static("x-spotify-connection-id");
|
||||||
|
|
||||||
|
const NO_METRICS_AND_SALT: RequestOptions = RequestOptions {
|
||||||
|
metrics: false,
|
||||||
|
salt: false,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum SpClientError {
|
pub enum SpClientError {
|
||||||
#[error("missing attribute {0}")]
|
#[error("missing attribute {0}")]
|
||||||
Attribute(String),
|
Attribute(String),
|
||||||
|
#[error("expected data but received none")]
|
||||||
|
NoData,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<SpClientError> for Error {
|
impl From<SpClientError> for Error {
|
||||||
|
@ -75,6 +84,20 @@ impl Default for RequestStrategy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct RequestOptions {
|
||||||
|
metrics: bool,
|
||||||
|
salt: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RequestOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
metrics: true,
|
||||||
|
salt: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SpClient {
|
impl SpClient {
|
||||||
pub fn set_strategy(&self, strategy: RequestStrategy) {
|
pub fn set_strategy(&self, strategy: RequestStrategy) {
|
||||||
self.lock(|inner| inner.strategy = strategy)
|
self.lock(|inner| inner.strategy = strategy)
|
||||||
|
@ -354,7 +377,25 @@ impl SpClient {
|
||||||
headers: Option<HeaderMap>,
|
headers: Option<HeaderMap>,
|
||||||
message: &M,
|
message: &M,
|
||||||
) -> SpClientResult {
|
) -> SpClientResult {
|
||||||
let body = protobuf::text_format::print_to_string(message);
|
self.request_with_protobuf_and_options(
|
||||||
|
method,
|
||||||
|
endpoint,
|
||||||
|
headers,
|
||||||
|
message,
|
||||||
|
&Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn request_with_protobuf_and_options<M: Message + MessageFull>(
|
||||||
|
&self,
|
||||||
|
method: &Method,
|
||||||
|
endpoint: &str,
|
||||||
|
headers: Option<HeaderMap>,
|
||||||
|
message: &M,
|
||||||
|
options: &RequestOptions,
|
||||||
|
) -> SpClientResult {
|
||||||
|
let body = message.write_to_bytes()?;
|
||||||
|
|
||||||
let mut headers = headers.unwrap_or_default();
|
let mut headers = headers.unwrap_or_default();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
|
@ -362,7 +403,7 @@ impl SpClient {
|
||||||
HeaderValue::from_static("application/x-protobuf"),
|
HeaderValue::from_static("application/x-protobuf"),
|
||||||
);
|
);
|
||||||
|
|
||||||
self.request(method, endpoint, Some(headers), Some(&body))
|
self.request_with_options(method, endpoint, Some(headers), Some(&body), options)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -376,7 +417,8 @@ impl SpClient {
|
||||||
let mut headers = headers.unwrap_or_default();
|
let mut headers = headers.unwrap_or_default();
|
||||||
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||||
|
|
||||||
self.request(method, endpoint, Some(headers), body).await
|
self.request(method, endpoint, Some(headers), body.map(|s| s.as_bytes()))
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn request(
|
pub async fn request(
|
||||||
|
@ -384,7 +426,19 @@ impl SpClient {
|
||||||
method: &Method,
|
method: &Method,
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
headers: Option<HeaderMap>,
|
headers: Option<HeaderMap>,
|
||||||
body: Option<&str>,
|
body: Option<&[u8]>,
|
||||||
|
) -> SpClientResult {
|
||||||
|
self.request_with_options(method, endpoint, headers, body, &Default::default())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn request_with_options(
|
||||||
|
&self,
|
||||||
|
method: &Method,
|
||||||
|
endpoint: &str,
|
||||||
|
headers: Option<HeaderMap>,
|
||||||
|
body: Option<&[u8]>,
|
||||||
|
options: &RequestOptions,
|
||||||
) -> SpClientResult {
|
) -> SpClientResult {
|
||||||
let mut tries: usize = 0;
|
let mut tries: usize = 0;
|
||||||
let mut last_response;
|
let mut last_response;
|
||||||
|
@ -399,31 +453,33 @@ impl SpClient {
|
||||||
let mut url = self.base_url().await?;
|
let mut url = self.base_url().await?;
|
||||||
url.push_str(endpoint);
|
url.push_str(endpoint);
|
||||||
|
|
||||||
let separator = match url.find('?') {
|
|
||||||
Some(_) => "&",
|
|
||||||
None => "?",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add metrics. There is also an optional `partner` key with a value like
|
// Add metrics. There is also an optional `partner` key with a value like
|
||||||
// `vodafone-uk` but we've yet to discover how we can find that value.
|
// `vodafone-uk` but we've yet to discover how we can find that value.
|
||||||
// For the sake of documentation you could also do "product=free" but
|
// For the sake of documentation you could also do "product=free" but
|
||||||
// we only support premium anyway.
|
// we only support premium anyway.
|
||||||
let _ = write!(
|
if options.metrics && !url.contains("product=0") {
|
||||||
url,
|
let _ = write!(
|
||||||
"{}product=0&country={}",
|
url,
|
||||||
separator,
|
"{}product=0&country={}",
|
||||||
self.session().country()
|
util::get_next_query_separator(&url),
|
||||||
);
|
self.session().country()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Defeat caches. Spotify-generated URLs already contain this.
|
// Defeat caches. Spotify-generated URLs already contain this.
|
||||||
if !url.contains("salt=") {
|
if options.salt && !url.contains("salt=") {
|
||||||
let _ = write!(url, "&salt={}", rand::thread_rng().next_u32());
|
let _ = write!(
|
||||||
|
url,
|
||||||
|
"{}salt={}",
|
||||||
|
util::get_next_query_separator(&url),
|
||||||
|
rand::thread_rng().next_u32()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut request = Request::builder()
|
let mut request = Request::builder()
|
||||||
.method(method)
|
.method(method)
|
||||||
.uri(url)
|
.uri(url)
|
||||||
.body(body.to_owned().into())?;
|
.body(Bytes::copy_from_slice(body))?;
|
||||||
|
|
||||||
// Reconnection logic: keep getting (cached) tokens because they might have expired.
|
// Reconnection logic: keep getting (cached) tokens because they might have expired.
|
||||||
let token = self.session().login5().auth_token().await?;
|
let token = self.session().login5().auth_token().await?;
|
||||||
|
@ -481,20 +537,34 @@ impl SpClient {
|
||||||
last_response
|
last_response
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn put_connect_state(
|
pub async fn put_connect_state_request(&self, state: &PutStateRequest) -> SpClientResult {
|
||||||
&self,
|
|
||||||
connection_id: &str,
|
|
||||||
state: &PutStateRequest,
|
|
||||||
) -> SpClientResult {
|
|
||||||
let endpoint = format!("/connect-state/v1/devices/{}", self.session().device_id());
|
let endpoint = format!("/connect-state/v1/devices/{}", self.session().device_id());
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert("X-Spotify-Connection-Id", connection_id.parse()?);
|
headers.insert(CONNECTION_ID, self.session().connection_id().parse()?);
|
||||||
|
|
||||||
self.request_with_protobuf(&Method::PUT, &endpoint, Some(headers), state)
|
self.request_with_protobuf(&Method::PUT, &endpoint, Some(headers), state)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_connect_state_request(&self) -> SpClientResult {
|
||||||
|
let endpoint = format!("/connect-state/v1/devices/{}", self.session().device_id());
|
||||||
|
self.request(&Method::DELETE, &endpoint, None, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_connect_state_inactive(&self, notify: bool) -> SpClientResult {
|
||||||
|
let endpoint = format!(
|
||||||
|
"/connect-state/v1/devices/{}/inactive?notify={notify}",
|
||||||
|
self.session().device_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(CONNECTION_ID, self.session().connection_id().parse()?);
|
||||||
|
|
||||||
|
self.request(&Method::PUT, &endpoint, Some(headers), None)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_metadata(&self, scope: &str, id: &SpotifyId) -> SpClientResult {
|
pub async fn get_metadata(&self, scope: &str, id: &SpotifyId) -> SpClientResult {
|
||||||
let endpoint = format!("/metadata/4/{}/{}", scope, id.to_base16()?);
|
let endpoint = format!("/metadata/4/{}/{}", scope, id.to_base16()?);
|
||||||
self.request(&Method::GET, &endpoint, None, None).await
|
self.request(&Method::GET, &endpoint, None, None).await
|
||||||
|
@ -738,4 +808,76 @@ impl SpClient {
|
||||||
|
|
||||||
self.request_url(&url).await
|
self.request_url(&url).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request the context for an uri
|
||||||
|
///
|
||||||
|
/// ## Query entry found in the wild:
|
||||||
|
/// - include_video=true
|
||||||
|
/// ## Remarks:
|
||||||
|
/// - track
|
||||||
|
/// - returns a single page with a single track
|
||||||
|
/// - when requesting a single track with a query in the request, the returned track uri
|
||||||
|
/// **will** contain the query
|
||||||
|
/// - artists
|
||||||
|
/// - returns 2 pages with tracks: 10 most popular tracks and latest/popular album
|
||||||
|
/// - remaining pages are albums of the artists and are only provided as page_url
|
||||||
|
/// - search
|
||||||
|
/// - is massively influenced by the provided query
|
||||||
|
/// - the query result shown by the search expects no query at all
|
||||||
|
/// - uri looks like "spotify:search:never+gonna"
|
||||||
|
pub async fn get_context(&self, uri: &str) -> Result<Context, Error> {
|
||||||
|
let uri = format!("/context-resolve/v1/{uri}");
|
||||||
|
|
||||||
|
let res = self
|
||||||
|
.request_with_options(&Method::GET, &uri, None, None, &NO_METRICS_AND_SALT)
|
||||||
|
.await?;
|
||||||
|
let ctx_json = String::from_utf8(res.to_vec())?;
|
||||||
|
if ctx_json.is_empty() {
|
||||||
|
Err(SpClientError::NoData)?
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx = protobuf_json_mapping::parse_from_str::<Context>(&ctx_json);
|
||||||
|
|
||||||
|
if ctx.is_err() {
|
||||||
|
trace!("failed parsing context: {ctx_json}")
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ctx?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_autoplay_context(
|
||||||
|
&self,
|
||||||
|
context_request: &AutoplayContextRequest,
|
||||||
|
) -> Result<Context, Error> {
|
||||||
|
let res = self
|
||||||
|
.request_with_protobuf_and_options(
|
||||||
|
&Method::POST,
|
||||||
|
"/context-resolve/v1/autoplay",
|
||||||
|
None,
|
||||||
|
context_request,
|
||||||
|
&NO_METRICS_AND_SALT,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let ctx_json = String::from_utf8(res.to_vec())?;
|
||||||
|
if ctx_json.is_empty() {
|
||||||
|
Err(SpClientError::NoData)?
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx = protobuf_json_mapping::parse_from_str::<Context>(&ctx_json);
|
||||||
|
|
||||||
|
if ctx.is_err() {
|
||||||
|
trace!("failed parsing context: {ctx_json}")
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ctx?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_rootlist(&self, from: usize, length: Option<usize>) -> SpClientResult {
|
||||||
|
let length = length.unwrap_or(120);
|
||||||
|
let user = self.session().username();
|
||||||
|
let endpoint = format!("/playlist/v2/user/{user}/rootlist?decorate=revision,attributes,length,owner,capabilities,status_code&from={from}&length={length}");
|
||||||
|
|
||||||
|
self.request(&Method::GET, &endpoint, None, None).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -423,19 +423,6 @@ impl TryFrom<&Vec<u8>> for SpotifyId {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<&protocol::spirc::TrackRef> for SpotifyId {
|
|
||||||
type Error = crate::Error;
|
|
||||||
fn try_from(track: &protocol::spirc::TrackRef) -> Result<Self, Self::Error> {
|
|
||||||
match SpotifyId::from_raw(track.gid()) {
|
|
||||||
Ok(mut id) => {
|
|
||||||
id.item_type = SpotifyItemType::Track;
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
Err(_) => SpotifyId::from_uri(track.uri()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<&protocol::metadata::Album> for SpotifyId {
|
impl TryFrom<&protocol::metadata::Album> for SpotifyId {
|
||||||
type Error = crate::Error;
|
type Error = crate::Error;
|
||||||
fn try_from(album: &protocol::metadata::Album) -> Result<Self, Self::Error> {
|
fn try_from(album: &protocol::metadata::Album) -> Result<Self, Self::Error> {
|
||||||
|
|
|
@ -165,3 +165,10 @@ pub fn solve_hash_cash(
|
||||||
|
|
||||||
Ok(now.elapsed())
|
Ok(now.elapsed())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_next_query_separator(url: &str) -> &'static str {
|
||||||
|
match url.find('?') {
|
||||||
|
Some(_) => "&",
|
||||||
|
None => "?",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -25,6 +25,9 @@ pub const SPOTIFY_SEMANTIC_VERSION: &str = "1.2.31.1205.g4d59ad7c";
|
||||||
/// The protocol version of the Spotify mobile app.
|
/// The protocol version of the Spotify mobile app.
|
||||||
pub const SPOTIFY_MOBILE_VERSION: &str = "8.6.84";
|
pub const SPOTIFY_MOBILE_VERSION: &str = "8.6.84";
|
||||||
|
|
||||||
|
/// The general spirc version
|
||||||
|
pub const SPOTIFY_SPIRC_VERSION: &str = "3.2.6";
|
||||||
|
|
||||||
/// The user agent to fall back to, if one could not be determined dynamically.
|
/// The user agent to fall back to, if one could not be determined dynamically.
|
||||||
pub const FALLBACK_USER_AGENT: &str = "Spotify/117300517 Linux/0 (librespot)";
|
pub const FALLBACK_USER_AGENT: &str = "Spotify/117300517 Linux/0 (librespot)";
|
||||||
|
|
||||||
|
|
79
docs/dealer.md
Normal file
79
docs/dealer.md
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
# Dealer
|
||||||
|
|
||||||
|
When talking about the dealer, we are speaking about a websocket that represents the player as
|
||||||
|
spotify-connect device. The dealer is primarily used to receive updates and not to update the
|
||||||
|
state.
|
||||||
|
|
||||||
|
## Messages and Requests
|
||||||
|
|
||||||
|
There are two types of messages that are received via the dealer, Messages and Requests.
|
||||||
|
Messages are fire-and-forget and don't need a responses, while request expect a reply if the
|
||||||
|
request was processed successfully or failed.
|
||||||
|
|
||||||
|
Because we publish our device with support for gzip, the message payload might be BASE64 encoded
|
||||||
|
and gzip compressed. If that is the case, the related headers send an entry for "Transfer-Encoding"
|
||||||
|
with the value of "gzip".
|
||||||
|
|
||||||
|
### Messages
|
||||||
|
|
||||||
|
Most messages librespot handles send bytes that can be easily converted into their respective
|
||||||
|
protobuf definition. Some outliers send json that can be usually mapped to an existing protobuf
|
||||||
|
definition. We use `protobuf-json-mapping` to a similar protobuf definition
|
||||||
|
|
||||||
|
> Note: The json sometimes doesn't map exactly and can provide more fields than the protobuf
|
||||||
|
> definition expects. For messages, we usually ignore unknown fields.
|
||||||
|
|
||||||
|
There are two types of messages, "informational" and "fire and forget commands".
|
||||||
|
|
||||||
|
**Informational:**
|
||||||
|
|
||||||
|
Informational messages send any changes done by the current user or of a client where the current user
|
||||||
|
is logged in. These messages contain for example changes to a own playlist, additions to the liked songs
|
||||||
|
or any update that a client sends.
|
||||||
|
|
||||||
|
**Fire and Forget commands:**
|
||||||
|
|
||||||
|
These are messages that send information that are requests to the current player. These are only send to
|
||||||
|
the active player. Volume update requests and the logout request are send as fire-forget-commands.
|
||||||
|
|
||||||
|
### Requests
|
||||||
|
|
||||||
|
The request payload is sent as json. There are almost usable protobuf definitions (see
|
||||||
|
files named like `es_<command in snakecase>(_request).proto`) for the commands, but they don't
|
||||||
|
align up with the expected values and are missing some major information we need for handling some
|
||||||
|
commands. Because of that we have our own model for the specific commands, see
|
||||||
|
[core/src/dealer/protocol/request.rs](../core/src/dealer/protocol/request.rs).
|
||||||
|
|
||||||
|
All request modify the player-state.
|
||||||
|
|
||||||
|
## Details
|
||||||
|
|
||||||
|
This sections is for details and special hiccups in regards to handling that isn't completely intuitive.
|
||||||
|
|
||||||
|
### UIDs
|
||||||
|
|
||||||
|
A spotify item is identifiable by their uri. The `ContextTrack` and `ProvidedTrack` both have a `uid`
|
||||||
|
field. When we receive a context via the `context-resolver` it can return items (`ContextTrack`) that
|
||||||
|
may have their respective uid set. Some context like the collection and albums don't provide this
|
||||||
|
information.
|
||||||
|
|
||||||
|
When a `uid` is missing, resorting the next tracks in an official client gets confused and sends
|
||||||
|
incorrect data via the `set_queue` request. To prevent this behavior we generate a uid for each
|
||||||
|
track that doesn't have an uid. Queue items become a "queue-uid" which is just a `q` with an
|
||||||
|
incrementing number.
|
||||||
|
|
||||||
|
### Metadata
|
||||||
|
|
||||||
|
For some client's (especially mobile) the metadata of a track is very important to display the
|
||||||
|
context correct. For example the "autoplay" metadata is relevant to display the correct context
|
||||||
|
info.
|
||||||
|
|
||||||
|
Metadata can also be used to store data like the iteration when repeating a context.
|
||||||
|
|
||||||
|
### Repeat
|
||||||
|
|
||||||
|
The context repeating implementation is partly mimicked from the official client. The official
|
||||||
|
client allows skipping into negative iterations, this is currently not supported.
|
||||||
|
|
||||||
|
Repeating is realized by filling the next tracks with multiple contexts separated by delimiters.
|
||||||
|
By that we only have to handle the delimiter when skipping to the next and previous track.
|
|
@ -9,13 +9,13 @@ use librespot::{
|
||||||
player::Player,
|
player::Player,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
use librespot_connect::spirc::PlayingTrack;
|
||||||
use librespot_connect::{
|
use librespot_connect::{
|
||||||
config::ConnectConfig,
|
|
||||||
spirc::{Spirc, SpircLoadCommand},
|
spirc::{Spirc, SpircLoadCommand},
|
||||||
|
state::ConnectStateConfig,
|
||||||
};
|
};
|
||||||
use librespot_metadata::{Album, Metadata};
|
use librespot_metadata::{Album, Metadata};
|
||||||
use librespot_playback::mixer::{softmixer::SoftMixer, Mixer, MixerConfig};
|
use librespot_playback::mixer::{softmixer::SoftMixer, Mixer, MixerConfig};
|
||||||
use librespot_protocol::spirc::TrackRef;
|
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::join;
|
use tokio::join;
|
||||||
|
@ -25,7 +25,7 @@ async fn main() {
|
||||||
let session_config = SessionConfig::default();
|
let session_config = SessionConfig::default();
|
||||||
let player_config = PlayerConfig::default();
|
let player_config = PlayerConfig::default();
|
||||||
let audio_format = AudioFormat::default();
|
let audio_format = AudioFormat::default();
|
||||||
let connect_config = ConnectConfig::default();
|
let connect_config = ConnectStateConfig::default();
|
||||||
|
|
||||||
let mut args: Vec<_> = env::args().collect();
|
let mut args: Vec<_> = env::args().collect();
|
||||||
let context_uri = if args.len() == 3 {
|
let context_uri = if args.len() == 3 {
|
||||||
|
@ -64,14 +64,6 @@ async fn main() {
|
||||||
let album = Album::get(&session, &SpotifyId::from_uri(&context_uri).unwrap())
|
let album = Album::get(&session, &SpotifyId::from_uri(&context_uri).unwrap())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let tracks = album
|
|
||||||
.tracks()
|
|
||||||
.map(|track_id| {
|
|
||||||
let mut track = TrackRef::new();
|
|
||||||
track.set_gid(Vec::from(track_id.to_raw()));
|
|
||||||
track
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"Playing album: {} by {}",
|
"Playing album: {} by {}",
|
||||||
|
@ -87,10 +79,12 @@ async fn main() {
|
||||||
.load(SpircLoadCommand {
|
.load(SpircLoadCommand {
|
||||||
context_uri,
|
context_uri,
|
||||||
start_playing: true,
|
start_playing: true,
|
||||||
|
seek_to: 0,
|
||||||
shuffle: false,
|
shuffle: false,
|
||||||
repeat: false,
|
repeat: false,
|
||||||
playing_track_index: 0, // the index specifies which track in the context starts playing, in this case the first in the album
|
repeat_track: false,
|
||||||
tracks,
|
// the index specifies which track in the context starts playing, in this case the first in the album
|
||||||
|
playing_track: PlayingTrack::Index(0),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
});
|
});
|
||||||
|
|
|
@ -94,7 +94,7 @@ impl<'a> Open for PortAudioSink<'a> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Sink for PortAudioSink<'a> {
|
impl Sink for PortAudioSink<'_> {
|
||||||
fn start(&mut self) -> SinkResult<()> {
|
fn start(&mut self) -> SinkResult<()> {
|
||||||
macro_rules! start_sink {
|
macro_rules! start_sink {
|
||||||
(ref mut $stream: ident, ref $parameters: ident) => {{
|
(ref mut $stream: ident, ref $parameters: ident) => {{
|
||||||
|
@ -175,12 +175,12 @@ impl<'a> Sink for PortAudioSink<'a> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Drop for PortAudioSink<'a> {
|
impl Drop for PortAudioSink<'_> {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
portaudio_rs::terminate().unwrap();
|
portaudio_rs::terminate().unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> PortAudioSink<'a> {
|
impl PortAudioSink<'_> {
|
||||||
pub const NAME: &'static str = "portaudio";
|
pub const NAME: &'static str = "portaudio";
|
||||||
}
|
}
|
||||||
|
|
|
@ -145,7 +145,7 @@ fn create_sink(
|
||||||
},
|
},
|
||||||
Some(device_name) => {
|
Some(device_name) => {
|
||||||
host.output_devices()?
|
host.output_devices()?
|
||||||
.find(|d| d.name().ok().map_or(false, |name| name == device_name)) // Ignore devices for which getting name fails
|
.find(|d| d.name().ok().is_some_and(|name| name == device_name)) // Ignore devices for which getting name fails
|
||||||
.ok_or_else(|| RodioError::DeviceNotAvailable(device_name.to_string()))?
|
.ok_or_else(|| RodioError::DeviceNotAvailable(device_name.to_string()))?
|
||||||
}
|
}
|
||||||
None => host
|
None => host
|
||||||
|
|
|
@ -123,7 +123,10 @@ enum PlayerCommand {
|
||||||
},
|
},
|
||||||
EmitFilterExplicitContentChangedEvent(bool),
|
EmitFilterExplicitContentChangedEvent(bool),
|
||||||
EmitShuffleChangedEvent(bool),
|
EmitShuffleChangedEvent(bool),
|
||||||
EmitRepeatChangedEvent(bool),
|
EmitRepeatChangedEvent {
|
||||||
|
context: bool,
|
||||||
|
track: bool,
|
||||||
|
},
|
||||||
EmitAutoPlayChangedEvent(bool),
|
EmitAutoPlayChangedEvent(bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -218,7 +221,8 @@ pub enum PlayerEvent {
|
||||||
shuffle: bool,
|
shuffle: bool,
|
||||||
},
|
},
|
||||||
RepeatChanged {
|
RepeatChanged {
|
||||||
repeat: bool,
|
context: bool,
|
||||||
|
track: bool,
|
||||||
},
|
},
|
||||||
AutoPlayChanged {
|
AutoPlayChanged {
|
||||||
auto_play: bool,
|
auto_play: bool,
|
||||||
|
@ -607,8 +611,8 @@ impl Player {
|
||||||
self.command(PlayerCommand::EmitShuffleChangedEvent(shuffle));
|
self.command(PlayerCommand::EmitShuffleChangedEvent(shuffle));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn emit_repeat_changed_event(&self, repeat: bool) {
|
pub fn emit_repeat_changed_event(&self, context: bool, track: bool) {
|
||||||
self.command(PlayerCommand::EmitRepeatChangedEvent(repeat));
|
self.command(PlayerCommand::EmitRepeatChangedEvent { context, track });
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn emit_auto_play_changed_event(&self, auto_play: bool) {
|
pub fn emit_auto_play_changed_event(&self, auto_play: bool) {
|
||||||
|
@ -2104,8 +2108,8 @@ impl PlayerInternal {
|
||||||
self.send_event(PlayerEvent::VolumeChanged { volume })
|
self.send_event(PlayerEvent::VolumeChanged { volume })
|
||||||
}
|
}
|
||||||
|
|
||||||
PlayerCommand::EmitRepeatChangedEvent(repeat) => {
|
PlayerCommand::EmitRepeatChangedEvent { context, track } => {
|
||||||
self.send_event(PlayerEvent::RepeatChanged { repeat })
|
self.send_event(PlayerEvent::RepeatChanged { context, track })
|
||||||
}
|
}
|
||||||
|
|
||||||
PlayerCommand::EmitShuffleChangedEvent(shuffle) => {
|
PlayerCommand::EmitShuffleChangedEvent(shuffle) => {
|
||||||
|
@ -2336,9 +2340,10 @@ impl fmt::Debug for PlayerCommand {
|
||||||
.debug_tuple("EmitShuffleChangedEvent")
|
.debug_tuple("EmitShuffleChangedEvent")
|
||||||
.field(&shuffle)
|
.field(&shuffle)
|
||||||
.finish(),
|
.finish(),
|
||||||
PlayerCommand::EmitRepeatChangedEvent(repeat) => f
|
PlayerCommand::EmitRepeatChangedEvent { context, track } => f
|
||||||
.debug_tuple("EmitRepeatChangedEvent")
|
.debug_tuple("EmitRepeatChangedEvent")
|
||||||
.field(&repeat)
|
.field(&context)
|
||||||
|
.field(&track)
|
||||||
.finish(),
|
.finish(),
|
||||||
PlayerCommand::EmitAutoPlayChangedEvent(auto_play) => f
|
PlayerCommand::EmitAutoPlayChangedEvent(auto_play) => f
|
||||||
.debug_tuple("EmitAutoPlayChangedEvent")
|
.debug_tuple("EmitAutoPlayChangedEvent")
|
||||||
|
|
|
@ -37,6 +37,8 @@ fn compile() {
|
||||||
proto_dir.join("spotify/login5/v3/user_info.proto"),
|
proto_dir.join("spotify/login5/v3/user_info.proto"),
|
||||||
proto_dir.join("storage-resolve.proto"),
|
proto_dir.join("storage-resolve.proto"),
|
||||||
proto_dir.join("user_attributes.proto"),
|
proto_dir.join("user_attributes.proto"),
|
||||||
|
proto_dir.join("autoplay_context_request.proto"),
|
||||||
|
proto_dir.join("social_connect_v2.proto"),
|
||||||
// TODO: remove these legacy protobufs when we are on the new API completely
|
// TODO: remove these legacy protobufs when we are on the new API completely
|
||||||
proto_dir.join("authentication.proto"),
|
proto_dir.join("authentication.proto"),
|
||||||
proto_dir.join("canvaz.proto"),
|
proto_dir.join("canvaz.proto"),
|
||||||
|
@ -45,7 +47,6 @@ fn compile() {
|
||||||
proto_dir.join("keyexchange.proto"),
|
proto_dir.join("keyexchange.proto"),
|
||||||
proto_dir.join("mercury.proto"),
|
proto_dir.join("mercury.proto"),
|
||||||
proto_dir.join("pubsub.proto"),
|
proto_dir.join("pubsub.proto"),
|
||||||
proto_dir.join("spirc.proto"),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let slices = files.iter().map(Deref::deref).collect::<Vec<_>>();
|
let slices = files.iter().map(Deref::deref).collect::<Vec<_>>();
|
||||||
|
|
55
src/main.rs
55
src/main.rs
|
@ -1,7 +1,3 @@
|
||||||
use data_encoding::HEXLOWER;
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
use log::{debug, error, info, trace, warn};
|
|
||||||
use sha1::{Digest, Sha1};
|
|
||||||
use std::{
|
use std::{
|
||||||
env,
|
env,
|
||||||
fs::create_dir_all,
|
fs::create_dir_all,
|
||||||
|
@ -12,12 +8,13 @@ use std::{
|
||||||
str::FromStr,
|
str::FromStr,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
use sysinfo::{ProcessesToUpdate, System};
|
|
||||||
use thiserror::Error;
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
#[cfg(feature = "alsa-backend")]
|
||||||
|
use librespot::playback::mixer::alsamixer::AlsaMixer;
|
||||||
use librespot::{
|
use librespot::{
|
||||||
connect::{config::ConnectConfig, spirc::Spirc},
|
connect::{spirc::Spirc, state::ConnectStateConfig},
|
||||||
core::{
|
core::{
|
||||||
authentication::Credentials, cache::Cache, config::DeviceType, version, Session,
|
authentication::Credentials, cache::Cache, config::DeviceType, version, Session,
|
||||||
SessionConfig,
|
SessionConfig,
|
||||||
|
@ -33,9 +30,11 @@ use librespot::{
|
||||||
player::{coefficient_to_duration, duration_to_coefficient, Player},
|
player::{coefficient_to_duration, duration_to_coefficient, Player},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
use log::{debug, error, info, trace, warn};
|
||||||
#[cfg(feature = "alsa-backend")]
|
use sha1::{Digest, Sha1};
|
||||||
use librespot::playback::mixer::alsamixer::AlsaMixer;
|
use sysinfo::{ProcessesToUpdate, System};
|
||||||
|
use thiserror::Error;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
mod player_event_handler;
|
mod player_event_handler;
|
||||||
use player_event_handler::{run_program_on_sink_events, EventHandler};
|
use player_event_handler::{run_program_on_sink_events, EventHandler};
|
||||||
|
@ -208,7 +207,7 @@ struct Setup {
|
||||||
cache: Option<Cache>,
|
cache: Option<Cache>,
|
||||||
player_config: PlayerConfig,
|
player_config: PlayerConfig,
|
||||||
session_config: SessionConfig,
|
session_config: SessionConfig,
|
||||||
connect_config: ConnectConfig,
|
connect_config: ConnectStateConfig,
|
||||||
mixer_config: MixerConfig,
|
mixer_config: MixerConfig,
|
||||||
credentials: Option<Credentials>,
|
credentials: Option<Credentials>,
|
||||||
enable_oauth: bool,
|
enable_oauth: bool,
|
||||||
|
@ -1371,7 +1370,7 @@ fn get_setup() -> Setup {
|
||||||
});
|
});
|
||||||
|
|
||||||
let connect_config = {
|
let connect_config = {
|
||||||
let connect_default_config = ConnectConfig::default();
|
let connect_default_config = ConnectStateConfig::default();
|
||||||
|
|
||||||
let name = opt_str(NAME).unwrap_or_else(|| connect_default_config.name.clone());
|
let name = opt_str(NAME).unwrap_or_else(|| connect_default_config.name.clone());
|
||||||
|
|
||||||
|
@ -1431,14 +1430,11 @@ fn get_setup() -> Setup {
|
||||||
#[cfg(feature = "alsa-backend")]
|
#[cfg(feature = "alsa-backend")]
|
||||||
let default_value = &format!(
|
let default_value = &format!(
|
||||||
"{}, or the current value when the alsa mixer is used.",
|
"{}, or the current value when the alsa mixer is used.",
|
||||||
connect_default_config.initial_volume.unwrap_or_default()
|
connect_default_config.initial_volume
|
||||||
);
|
);
|
||||||
|
|
||||||
#[cfg(not(feature = "alsa-backend"))]
|
#[cfg(not(feature = "alsa-backend"))]
|
||||||
let default_value = &connect_default_config
|
let default_value = &connect_default_config.initial_volume.to_string();
|
||||||
.initial_volume
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
invalid_error_msg(
|
invalid_error_msg(
|
||||||
INITIAL_VOLUME,
|
INITIAL_VOLUME,
|
||||||
|
@ -1485,14 +1481,21 @@ fn get_setup() -> Setup {
|
||||||
|
|
||||||
let is_group = opt_present(DEVICE_IS_GROUP);
|
let is_group = opt_present(DEVICE_IS_GROUP);
|
||||||
|
|
||||||
let has_volume_ctrl = !matches!(mixer_config.volume_ctrl, VolumeCtrl::Fixed);
|
if let Some(initial_volume) = initial_volume {
|
||||||
|
ConnectStateConfig {
|
||||||
ConnectConfig {
|
name,
|
||||||
name,
|
device_type,
|
||||||
device_type,
|
is_group,
|
||||||
is_group,
|
initial_volume: initial_volume.into(),
|
||||||
initial_volume,
|
..Default::default()
|
||||||
has_volume_ctrl,
|
}
|
||||||
|
} else {
|
||||||
|
ConnectStateConfig {
|
||||||
|
name,
|
||||||
|
device_type,
|
||||||
|
is_group,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -226,9 +226,10 @@ impl EventHandler {
|
||||||
env_vars.insert("PLAYER_EVENT", "shuffle_changed".to_string());
|
env_vars.insert("PLAYER_EVENT", "shuffle_changed".to_string());
|
||||||
env_vars.insert("SHUFFLE", shuffle.to_string());
|
env_vars.insert("SHUFFLE", shuffle.to_string());
|
||||||
}
|
}
|
||||||
PlayerEvent::RepeatChanged { repeat } => {
|
PlayerEvent::RepeatChanged { context, track } => {
|
||||||
env_vars.insert("PLAYER_EVENT", "repeat_changed".to_string());
|
env_vars.insert("PLAYER_EVENT", "repeat_changed".to_string());
|
||||||
env_vars.insert("REPEAT", repeat.to_string());
|
env_vars.insert("REPEAT", context.to_string());
|
||||||
|
env_vars.insert("REPEAT_TRACK", track.to_string());
|
||||||
}
|
}
|
||||||
PlayerEvent::AutoPlayChanged { auto_play } => {
|
PlayerEvent::AutoPlayChanged { auto_play } => {
|
||||||
env_vars.insert("PLAYER_EVENT", "auto_play_changed".to_string());
|
env_vars.insert("PLAYER_EVENT", "auto_play_changed".to_string());
|
||||||
|
|
Loading…
Reference in a new issue