From 5839b3619288088ba55fd4c8933bb7bf808923f7 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 10 Dec 2024 20:36:09 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 9 + Cargo.lock | 35 +- connect/Cargo.toml | 3 +- connect/src/config.rs | 22 - connect/src/context.rs | 121 -- connect/src/lib.rs | 4 +- connect/src/model.rs | 188 +++ connect/src/spirc.rs | 1963 ++++++++++++----------- connect/src/state.rs | 448 ++++++ connect/src/state/context.rs | 415 +++++ connect/src/state/handle.rs | 65 + connect/src/state/metadata.rs | 84 + connect/src/state/options.rs | 88 + connect/src/state/provider.rs | 66 + connect/src/state/restrictions.rs | 61 + connect/src/state/tracks.rs | 422 +++++ connect/src/state/transfer.rs | 146 ++ core/Cargo.toml | 2 + core/src/config.rs | 27 + core/src/connection/handshake.rs | 4 +- core/src/dealer/manager.rs | 174 ++ core/src/dealer/maps.rs | 35 +- core/src/dealer/mod.rs | 144 +- core/src/dealer/protocol.rs | 178 +- core/src/dealer/protocol/request.rs | 208 +++ core/src/deserialize_with.rs | 94 ++ core/src/error.rs | 6 + core/src/http_client.rs | 2 +- core/src/lib.rs | 3 +- core/src/mercury/mod.rs | 9 +- core/src/session.rs | 54 +- core/src/spclient.rs | 218 ++- core/src/spotify_id.rs | 13 - core/src/util.rs | 7 + core/src/version.rs | 3 + docs/dealer.md | 79 + examples/play_connect.rs | 20 +- playback/src/audio_backend/portaudio.rs | 6 +- playback/src/audio_backend/rodio.rs | 2 +- playback/src/player.rs | 21 +- protocol/build.rs | 3 +- src/main.rs | 55 +- src/player_event_handler.rs | 5 +- 43 files changed, 4229 insertions(+), 1283 deletions(-) delete mode 100644 connect/src/config.rs delete mode 100644 connect/src/context.rs create mode 100644 connect/src/model.rs create mode 100644 connect/src/state.rs create mode 100644 connect/src/state/context.rs create mode 100644 connect/src/state/handle.rs create mode 100644 connect/src/state/metadata.rs create mode 100644 connect/src/state/options.rs create mode 100644 connect/src/state/provider.rs create mode 100644 connect/src/state/restrictions.rs create mode 100644 connect/src/state/tracks.rs create mode 100644 connect/src/state/transfer.rs create mode 100644 core/src/dealer/manager.rs create mode 100644 core/src/dealer/protocol/request.rs create mode 100644 core/src/deserialize_with.rs create mode 100644 docs/dealer.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fb81847..bb13097c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 +- [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 - [core] Fix "no native root CA certificates found" on platforms unsupported diff --git a/Cargo.lock b/Cargo.lock index 6944c96d..36e67519 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -630,6 +630,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.20" @@ -894,6 +903,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "fnv" version = "1.0.7" @@ -1941,7 +1960,6 @@ dependencies = [ name = "librespot-connect" version = "0.6.0-dev" dependencies = [ - "form_urlencoded", "futures-util", "librespot-core", "librespot-playback", @@ -1949,11 +1967,11 @@ dependencies = [ "log", "protobuf", "rand", - "serde", "serde_json", "thiserror", "tokio", "tokio-stream", + "uuid", ] [[package]] @@ -1965,6 +1983,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", + "flate2", "form_urlencoded", "futures-core", "futures-util", @@ -1991,6 +2010,7 @@ dependencies = [ "pin-project-lite", "priority-queue", "protobuf", + "protobuf-json-mapping", "quick-xml", "rand", "rsa", @@ -2744,6 +2764,17 @@ dependencies = [ "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]] name = "protobuf-parse" version = "3.7.1" diff --git a/connect/Cargo.toml b/connect/Cargo.toml index e35d3c77..7ed3fab7 100644 --- a/connect/Cargo.toml +++ b/connect/Cargo.toml @@ -9,16 +9,15 @@ repository = "https://github.com/librespot-org/librespot" edition = "2021" [dependencies] -form_urlencoded = "1.0" futures-util = "0.3" log = "0.4" protobuf = "3.5" rand = "0.8" -serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "1.0" tokio = { version = "1", features = ["macros", "parking_lot", "sync"] } tokio-stream = "0.1" +uuid = { version = "1.11.0", features = ["v4"] } [dependencies.librespot-core] path = "../core" diff --git a/connect/src/config.rs b/connect/src/config.rs deleted file mode 100644 index 278ecf17..00000000 --- a/connect/src/config.rs +++ /dev/null @@ -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, - 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, - } - } -} diff --git a/connect/src/context.rs b/connect/src/context.rs deleted file mode 100644 index 9428faac..00000000 --- a/connect/src/context.rs +++ /dev/null @@ -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, - #[serde(rename = "imageUri")] - pub image_uri: String, - pub seeds: Vec, - #[serde(deserialize_with = "deserialize_protobuf_TrackRef")] - pub tracks: Vec, - pub next_page_url: String, - pub correlation_id: String, - pub related_artists: Vec, -} - -#[derive(Deserialize, Debug, Default, Clone)] -pub struct PageContext { - #[serde(deserialize_with = "deserialize_protobuf_TrackRef")] - pub tracks: Vec, - 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 -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, D::Error> -where - D: serde::Deserializer<'d>, -{ - let v: Vec = 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::, D::Error>>() -} diff --git a/connect/src/lib.rs b/connect/src/lib.rs index 193e5db5..3cfbbca1 100644 --- a/connect/src/lib.rs +++ b/connect/src/lib.rs @@ -5,6 +5,6 @@ use librespot_core as core; use librespot_playback as playback; use librespot_protocol as protocol; -pub mod config; -pub mod context; +mod model; pub mod spirc; +pub mod state; diff --git a/connect/src/model.rs b/connect/src/model.rs new file mode 100644 index 00000000..f9165eae --- /dev/null +++ b/connect/src/model.rs @@ -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 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, + 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, fallback: impl Into, 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::>() + .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(&self, state: &mut H) { + self.context_uri().hash(state); + self.resolve_uri().hash(state); + self.autoplay.hash(state); + self.update.hash(state); + } +} + +impl From for Context { + fn from(value: ResolveContext) -> Self { + value.context + } +} diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index c3942651..b9240851 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,106 +1,115 @@ -use std::{ - future::Future, - pin::Pin, - sync::atomic::{AtomicUsize, Ordering}, - sync::Arc, - time::{SystemTime, UNIX_EPOCH}, -}; - -use futures_util::{stream::FusedStream, FutureExt, StreamExt}; - -use protobuf::Message; -use rand::prelude::SliceRandom; -use thiserror::Error; -use tokio::sync::mpsc; -use tokio_stream::wrappers::UnboundedReceiverStream; - +pub use crate::model::{PlayingTrack, SpircLoadCommand}; +use crate::state::{context::ResetContext, metadata::Metadata}; use crate::{ - config::ConnectConfig, - context::PageContext, core::{ - authentication::Credentials, mercury::MercurySender, session::UserAttributes, - util::SeqGenerator, version, Error, Session, SpotifyId, + authentication::Credentials, + dealer::{ + manager::{BoxedStream, BoxedStreamResult, Reply, RequestReply}, + protocol::{Command, Message, Request}, + }, + session::UserAttributes, + Error, Session, SpotifyId, }, playback::{ mixer::Mixer, player::{Player, PlayerEvent, PlayerEventChannel}, }, protocol::{ - self, + autoplay_context_request::AutoplayContextRequest, + connect::{Cluster, ClusterUpdate, LogoutCommand, SetVolumeCommand}, explicit_content_pubsub::UserAttributesUpdate, - spirc::{DeviceState, Frame, MessageType, PlayStatus, State, TrackRef}, + player::{Context, TransferState}, + playlist4_external::PlaylistModificationInfo, + social_connect_v2::{session::_host_active_device_id, SessionUpdate}, user_attributes::UserAttributesMutation, }, }; +use crate::{ + model::{ResolveContext, SpircPlayStatus}, + state::{ + context::{ContextType, LoadNext, UpdateContext}, + provider::IsProvider, + {ConnectState, ConnectStateConfig}, + }, +}; +use futures_util::StreamExt; +use protobuf::MessageField; +use std::collections::HashMap; +use std::time::Instant; +use std::{ + future::Future, + sync::atomic::{AtomicUsize, Ordering}, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use thiserror::Error; +use tokio::{sync::mpsc, time::sleep}; #[derive(Debug, Error)] pub enum SpircError { #[error("response payload empty")] NoData, - #[error("playback of local files is not supported")] - UnsupportedLocalPlayBack, - #[error("message addressed at another ident: {0}")] - Ident(String), #[error("message pushed for another URI")] InvalidUri(String), + #[error("tried resolving not allowed context: {0:?}")] + NotAllowedContext(String), + #[error("failed to put connect state for new device")] + FailedDealerSetup, + #[error("unknown endpoint: {0:#?}")] + UnknownEndpoint(serde_json::Value), } impl From for Error { fn from(err: SpircError) -> Self { use SpircError::*; match err { - NoData | UnsupportedLocalPlayBack => Error::unavailable(err), - Ident(_) | InvalidUri(_) => Error::aborted(err), + NoData | NotAllowedContext(_) => Error::unavailable(err), + InvalidUri(_) | FailedDealerSetup => Error::aborted(err), + UnknownEndpoint(_) => Error::unimplemented(err), } } } -#[derive(Debug)] -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, - }, -} - -type BoxedStream = Pin + Send>>; - struct SpircTask { player: Arc, mixer: Arc, - sequence: SeqGenerator, + /// the state management object + connect_state: ConnectState, - ident: String, - device: DeviceState, - state: State, play_request_id: Option, play_status: SpircPlayStatus, - remote_update: BoxedStream>, - connection_id_update: BoxedStream>, - user_attributes_update: BoxedStream>, - user_attributes_mutation: BoxedStream>, - sender: MercurySender, + connection_id_update: BoxedStreamResult, + connect_state_update: BoxedStreamResult, + connect_state_volume_update: BoxedStreamResult, + connect_state_logout_request: BoxedStreamResult, + playlist_update: BoxedStreamResult, + session_update: BoxedStreamResult, + connect_state_command: BoxedStream, + user_attributes_update: BoxedStreamResult, + user_attributes_mutation: BoxedStreamResult, + commands: Option>, player_events: Option, shutdown: bool, session: Session, - resolve_context: Option, - autoplay_context: bool, - context: Option, + + /// the list of contexts to resolve + resolve_context: Vec, + + /// contexts may not be resolvable at the moment so we should ignore any further request + /// + /// an unavailable context is retried after [RETRY_UNAVAILABLE] + unavailable_contexts: HashMap, + + /// is set when transferring, and used after resolving the contexts to finish the transfer + pub transfer_state: Option, + + /// when set to true, it will update the volume after [VOLUME_UPDATE_DELAY], + /// when no other future resolves, otherwise resets the delay + update_volume: bool, spirc_id: usize, } @@ -108,7 +117,7 @@ struct SpircTask { static SPIRC_COUNTER: AtomicUsize = AtomicUsize::new(0); #[derive(Debug)] -pub enum SpircCommand { +enum SpircCommand { Play, PlayPause, Pause, @@ -119,6 +128,7 @@ pub enum SpircCommand { Shutdown, Shuffle(bool), Repeat(bool), + RepeatTrack(bool), Disconnect, SetPosition(u32), SetVolume(u16), @@ -126,216 +136,78 @@ pub enum SpircCommand { Load(SpircLoadCommand), } -#[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 shuffle: bool, - pub repeat: bool, - pub playing_track_index: u32, - pub tracks: Vec, -} +const CONTEXT_FETCH_THRESHOLD: usize = 2; -impl From for State { - fn from(command: SpircLoadCommand) -> Self { - let mut state = State::new(); - state.set_context_uri(command.context_uri); - state.set_status(if command.start_playing { - PlayStatus::kPlayStatusPlay - } else { - PlayStatus::kPlayStatusStop - }); - state.set_shuffle(command.shuffle); - state.set_repeat(command.repeat); - state.set_playing_track_index(command.playing_track_index); - state.track = command.tracks; - state - } -} - -const CONTEXT_TRACKS_HISTORY: usize = 10; -const CONTEXT_FETCH_THRESHOLD: u32 = 5; - -const VOLUME_STEPS: i64 = 64; const VOLUME_STEP_SIZE: u16 = 1024; // (u16::MAX + 1) / VOLUME_STEPS +// delay to resolve a bundle of context updates, delaying the update prevents duplicate context updates of the same type +const RESOLVE_CONTEXT_DELAY: Duration = Duration::from_millis(500); +// time after which an unavailable context is retried +const RETRY_UNAVAILABLE: Duration = Duration::from_secs(3600); +// delay to update volume after a certain amount of time, instead on each update request +const VOLUME_UPDATE_DELAY: Duration = Duration::from_secs(2); + pub struct Spirc { commands: mpsc::UnboundedSender, } -fn initial_state() -> State { - let mut frame = protocol::spirc::State::new(); - frame.set_repeat(false); - frame.set_shuffle(false); - frame.set_status(PlayStatus::kPlayStatusStop); - frame.set_position_ms(0); - frame.set_position_measured_at(0); - frame -} - -fn int_capability(typ: protocol::spirc::CapabilityType, val: i64) -> protocol::spirc::Capability { - let mut cap = protocol::spirc::Capability::new(); - cap.set_typ(typ); - cap.intValue.push(val); - cap -} - -fn initial_device_state(config: ConnectConfig) -> DeviceState { - let mut msg = DeviceState::new(); - msg.set_sw_version(version::SEMVER.to_string()); - msg.set_is_active(false); - msg.set_can_play(true); - msg.set_volume(0); - msg.set_name(config.name); - msg.capabilities.push(int_capability( - protocol::spirc::CapabilityType::kCanBePlayer, - 1, - )); - msg.capabilities.push(int_capability( - protocol::spirc::CapabilityType::kDeviceType, - config.device_type as i64, - )); - msg.capabilities.push(int_capability( - protocol::spirc::CapabilityType::kGaiaEqConnectId, - 1, - )); - // TODO: implement logout - msg.capabilities.push(int_capability( - protocol::spirc::CapabilityType::kSupportsLogout, - 0, - )); - msg.capabilities.push(int_capability( - protocol::spirc::CapabilityType::kIsObservable, - 1, - )); - msg.capabilities.push(int_capability( - protocol::spirc::CapabilityType::kVolumeSteps, - if config.has_volume_ctrl { - VOLUME_STEPS - } else { - 0 - }, - )); - msg.capabilities.push(int_capability( - protocol::spirc::CapabilityType::kSupportsPlaylistV2, - 1, - )); - msg.capabilities.push(int_capability( - protocol::spirc::CapabilityType::kSupportsExternalEpisodes, - 1, - )); - // TODO: how would such a rename command be triggered? Handle it. - msg.capabilities.push(int_capability( - protocol::spirc::CapabilityType::kSupportsRename, - 1, - )); - msg.capabilities.push(int_capability( - protocol::spirc::CapabilityType::kCommandAcks, - 0, - )); - // TODO: does this mean local files or the local network? - // LAN may be an interesting privacy toggle. - msg.capabilities.push(int_capability( - protocol::spirc::CapabilityType::kRestrictToLocal, - 0, - )); - // TODO: what does this hide, or who do we hide from? - // May be an interesting privacy toggle. - msg.capabilities - .push(int_capability(protocol::spirc::CapabilityType::kHidden, 0)); - let mut supported_types = protocol::spirc::Capability::new(); - supported_types.set_typ(protocol::spirc::CapabilityType::kSupportedTypes); - supported_types - .stringValue - .push("audio/episode".to_string()); - supported_types - .stringValue - .push("audio/episode+track".to_string()); - supported_types.stringValue.push("audio/track".to_string()); - // other known types: - // - "audio/ad" - // - "audio/interruption" - // - "audio/local" - // - "video/ad" - // - "video/episode" - msg.capabilities.push(supported_types); - msg -} - -fn url_encode(bytes: impl AsRef<[u8]>) -> String { - form_urlencoded::byte_serialize(bytes.as_ref()).collect() -} - impl Spirc { pub async fn new( - config: ConnectConfig, + config: ConnectStateConfig, session: Session, credentials: Credentials, player: Arc, mixer: Arc, ) -> Result<(Spirc, impl Future), Error> { + fn extract_connection_id(msg: Message) -> Result { + let connection_id = msg + .headers + .get("Spotify-Connection-Id") + .ok_or_else(|| SpircError::InvalidUri(msg.uri.clone()))?; + Ok(connection_id.to_owned()) + } + let spirc_id = SPIRC_COUNTER.fetch_add(1, Ordering::AcqRel); debug!("new Spirc[{}]", spirc_id); - let ident = session.device_id().to_owned(); + let connect_state = ConnectState::new(config, &session); - let remote_update = Box::pin( - session - .mercury() - .listen_for("hm://remote/user/") - .map(UnboundedReceiverStream::new) - .flatten_stream() - .map(|response| -> Result<(String, Frame), Error> { - let uri_split: Vec<&str> = response.uri.split('/').collect(); - let username = match uri_split.get(4) { - Some(s) => s.to_string(), - None => String::new(), - }; + let connection_id_update = session + .dealer() + .listen_for("hm://pusher/v1/connections/", extract_connection_id)?; - let data = response.payload.first().ok_or(SpircError::NoData)?; - Ok((username, Frame::parse_from_bytes(data)?)) - }), - ); + let connect_state_update = session + .dealer() + .listen_for("hm://connect-state/v1/cluster", Message::from_raw)?; - let connection_id_update = Box::pin( - session - .mercury() - .listen_for("hm://pusher/v1/connections/") - .map(UnboundedReceiverStream::new) - .flatten_stream() - .map(|response| -> Result { - let connection_id = response - .uri - .strip_prefix("hm://pusher/v1/connections/") - .ok_or_else(|| SpircError::InvalidUri(response.uri.clone()))?; - Ok(connection_id.to_owned()) - }), - ); + let connect_state_volume_update = session + .dealer() + .listen_for("hm://connect-state/v1/connect/volume", Message::from_raw)?; - let user_attributes_update = Box::pin( - session - .mercury() - .listen_for("spotify:user:attributes:update") - .map(UnboundedReceiverStream::new) - .flatten_stream() - .map(|response| -> Result { - let data = response.payload.first().ok_or(SpircError::NoData)?; - Ok(UserAttributesUpdate::parse_from_bytes(data)?) - }), - ); + let connect_state_logout_request = session + .dealer() + .listen_for("hm://connect-state/v1/connect/logout", Message::from_raw)?; - let user_attributes_mutation = Box::pin( - session - .mercury() - .listen_for("spotify:user:attributes:mutated") - .map(UnboundedReceiverStream::new) - .flatten_stream() - .map(|response| -> Result { - let data = response.payload.first().ok_or(SpircError::NoData)?; - Ok(UserAttributesMutation::parse_from_bytes(data)?) - }), - ); + let playlist_update = session + .dealer() + .listen_for("hm://playlist/v2/playlist/", Message::from_raw)?; + + let session_update = session + .dealer() + .listen_for("social-connect/v2/session_update", Message::from_json)?; + + let user_attributes_update = session + .dealer() + .listen_for("spotify:user:attributes:update", Message::from_raw)?; + + // can be trigger by toggling autoplay in a desktop client + let user_attributes_mutation = session + .dealer() + .listen_for("spotify:user:attributes:mutated", Message::from_raw)?; + + let connect_state_command = session + .dealer() + .handle_for("hm://connect-state/v1/player/command")?; // pre-acquire client_token, preventing multiple request while running let _ = session.spclient().client_token().await?; @@ -343,61 +215,59 @@ impl Spirc { // Connect *after* all message listeners are registered session.connect(credentials, true).await?; - let canonical_username = &session.username(); - debug!("canonical_username: {}", canonical_username); - let sender_uri = format!("hm://remote/user/{}/", url_encode(canonical_username)); - - let sender = session.mercury().sender(sender_uri); + // pre-acquire access_token (we need to be authenticated to retrieve a token) + let _ = session.login5().auth_token().await?; let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); - let initial_volume = config.initial_volume; - - let device = initial_device_state(config); - let player_events = player.get_player_event_channel(); let mut task = SpircTask { player, mixer, - sequence: SeqGenerator::new(1), + connect_state, - ident, - - device, - state: initial_state(), play_request_id: None, play_status: SpircPlayStatus::Stopped, - remote_update, connection_id_update, + connect_state_update, + connect_state_volume_update, + connect_state_logout_request, + playlist_update, + session_update, + connect_state_command, user_attributes_update, user_attributes_mutation, - sender, commands: Some(cmd_rx), player_events: Some(player_events), shutdown: false, session, - resolve_context: None, - autoplay_context: false, - context: None, + resolve_context: Vec::new(), + unavailable_contexts: HashMap::new(), + transfer_state: None, + update_volume: false, spirc_id, }; - if let Some(volume) = initial_volume { - task.set_volume(volume); - } else { - let current_volume = task.mixer.volume(); - task.set_volume(current_volume); - } - let spirc = Spirc { commands: cmd_tx }; - task.hello()?; + let initial_volume = task.connect_state.device_info().volume; + task.connect_state.set_volume(0); + + match initial_volume.try_into() { + Ok(volume) => { + task.set_volume(volume); + // we don't want to update the volume initially, + // we just want to set the mixer to the correct volume + task.update_volume = false; + } + Err(why) => error!("failed to update initial volume: {why}"), + }; Ok((spirc, task.run())) } @@ -432,6 +302,9 @@ impl Spirc { pub fn repeat(&self, repeat: bool) -> Result<(), Error> { Ok(self.commands.send(SpircCommand::Repeat(repeat))?) } + pub fn repeat_track(&self, repeat: bool) -> Result<(), Error> { + Ok(self.commands.send(SpircCommand::RepeatTrack(repeat))?) + } pub fn set_volume(&self, volume: u16) -> Result<(), Error> { Ok(self.commands.send(SpircCommand::SetVolume(volume))?) } @@ -451,217 +324,360 @@ impl Spirc { impl SpircTask { async fn run(mut self) { + // simplify unwrapping of received item or parsed result + macro_rules! unwrap { + ( $next:expr, |$some:ident| $use_some:expr ) => { + match $next { + Some($some) => $use_some, + None => { + error!("{} selected, but none received", stringify!($next)); + break; + } + } + }; + ( $next:expr, match |$ok:ident| $use_ok:expr ) => { + unwrap!($next, |$ok| match $ok { + Ok($ok) => $use_ok, + Err(why) => error!("could not parse {}: {}", stringify!($ok), why), + }) + }; + } + + if let Err(why) = self.session.dealer().start().await { + error!("starting dealer failed: {why}"); + return; + } + while !self.session.is_invalid() && !self.shutdown { let commands = self.commands.as_mut(); let player_events = self.player_events.as_mut(); - tokio::select! { - remote_update = self.remote_update.next() => match remote_update { - Some(result) => match result { - Ok((username, frame)) => { - if username != self.session.username() { - warn!("could not dispatch remote update: frame was intended for {}", username); - } else if let Err(e) = self.handle_remote_update(frame) { - error!("could not dispatch remote update: {}", e); - } - }, - Err(e) => error!("could not parse remote update: {}", e), - } - None => { - error!("remote update selected, but none received"); - break; - } - }, - user_attributes_update = self.user_attributes_update.next() => match user_attributes_update { - Some(result) => match result { - Ok(attributes) => self.handle_user_attributes_update(attributes), - Err(e) => error!("could not parse user attributes update: {}", e), - } - None => { - error!("user attributes update selected, but none received"); - break; - } - }, - user_attributes_mutation = self.user_attributes_mutation.next() => match user_attributes_mutation { - Some(result) => match result { - Ok(attributes) => self.handle_user_attributes_mutation(attributes), - Err(e) => error!("could not parse user attributes mutation: {}", e), - } - None => { - error!("user attributes mutation selected, but none received"); - break; - } - }, - connection_id_update = self.connection_id_update.next() => match connection_id_update { - Some(result) => match result { - Ok(connection_id) => { - self.handle_connection_id_update(connection_id); - // pre-acquire access_token, preventing multiple request while running - // pre-acquiring for the access_token will only last for one hour - // - // we need to fire the request after connecting, but can't do it right - // after, because by that we would miss certain packages, like this one - match self.session.login5().auth_token().await { - Ok(_) => debug!("successfully pre-acquire access_token and client_token"), - Err(why) => { - error!("{why}"); - break - } - } - }, - Err(e) => error!("could not parse connection ID update: {}", e), - } - None => { - error!("connection ID update selected, but none received"); + tokio::select! { + // startup of the dealer requires a connection_id, which is retrieved at the very beginning + connection_id_update = self.connection_id_update.next() => unwrap! { + connection_id_update, + match |connection_id| if let Err(why) = self.handle_connection_id_update(connection_id).await { + error!("failed handling connection id update: {why}"); break; } }, + // main dealer update of any remote device updates + cluster_update = self.connect_state_update.next() => unwrap! { + cluster_update, + match |cluster_update| if let Err(e) = self.handle_cluster_update(cluster_update).await { + error!("could not dispatch connect state update: {}", e); + } + }, + // main dealer request handling (dealer expects an answer) + request = self.connect_state_command.next() => unwrap! { + request, + |request| if let Err(e) = self.handle_connect_state_request(request).await { + error!("couldn't handle connect state command: {}", e); + } + }, + // volume request handling is send separately (it's more like a fire forget) + volume_update = self.connect_state_volume_update.next() => unwrap! { + volume_update, + match |volume_update| match volume_update.volume.try_into() { + Ok(volume) => self.set_volume(volume), + Err(why) => error!("can't update volume, failed to parse i32 to u16: {why}") + } + }, + logout_request = self.connect_state_logout_request.next() => unwrap! { + logout_request, + |logout_request| { + error!("received logout request, currently not supported: {logout_request:#?}"); + // todo: call logout handling + } + }, + playlist_update = self.playlist_update.next() => unwrap! { + playlist_update, + match |playlist_update| if let Err(why) = self.handle_playlist_modification(playlist_update) { + error!("failed to handle playlist modification: {why}") + } + }, + user_attributes_update = self.user_attributes_update.next() => unwrap! { + user_attributes_update, + match |attributes| self.handle_user_attributes_update(attributes) + }, + user_attributes_mutation = self.user_attributes_mutation.next() => unwrap! { + user_attributes_mutation, + match |attributes| self.handle_user_attributes_mutation(attributes) + }, + session_update = self.session_update.next() => unwrap! { + session_update, + match |session_update| self.handle_session_update(session_update) + }, cmd = async { commands?.recv().await }, if commands.is_some() => if let Some(cmd) = cmd { - if let Err(e) = self.handle_command(cmd) { + if let Err(e) = self.handle_command(cmd).await { debug!("could not dispatch command: {}", e); } }, event = async { player_events?.recv().await }, if player_events.is_some() => if let Some(event) = event { - if let Err(e) = self.handle_player_event(event) { + if let Err(e) = self.handle_player_event(event).await { error!("could not dispatch player event: {}", e); } }, - result = self.sender.flush(), if !self.sender.is_flushed() => if result.is_err() { - error!("Cannot flush spirc event sender."); - break; + _ = async { sleep(RESOLVE_CONTEXT_DELAY).await }, if !self.resolve_context.is_empty() => { + if let Err(why) = self.handle_resolve_context().await { + error!("ContextError: {why}") + } }, - context_uri = async { self.resolve_context.take() }, if self.resolve_context.is_some() => { - let context_uri = context_uri.unwrap(); // guaranteed above - if context_uri.contains("spotify:show:") || context_uri.contains("spotify:episode:") { - continue; // not supported by apollo stations + _ = async { sleep(VOLUME_UPDATE_DELAY).await }, if self.update_volume => { + self.update_volume = false; + + info!("delayed volume update for all devices: volume is now {}", self.connect_state.device_info().volume); + if let Err(why) = self.connect_state.notify_volume_changed(&self.session).await { + error!("error updating connect state for volume update: {why}") } - let context = if context_uri.starts_with("hm://") { - self.session.spclient().get_next_page(&context_uri).await - } else { - // only send previous tracks that were before the current playback position - let current_position = self.state.playing_track_index() as usize; - let previous_tracks = self.state.track[..current_position].iter().filter_map(|t| SpotifyId::try_from(t).ok()).collect(); - - let scope = if self.autoplay_context { - "stations" // this returns a `StationContext` but we deserialize it into a `PageContext` - } else { - "tracks" // this returns a `PageContext` - }; - - self.session.spclient().get_apollo_station(scope, &context_uri, None, previous_tracks, self.autoplay_context).await - }; - - match context { - Ok(value) => { - self.context = match serde_json::from_slice::(&value) { - Ok(context) => { - info!( - "Resolved {:?} tracks from <{:?}>", - context.tracks.len(), - self.state.context_uri(), - ); - Some(context) - } - Err(e) => { - error!("Unable to parse JSONContext {:?}", e); - None - } - }; - }, - Err(err) => { - error!("ContextError: {:?}", err) - } + // for some reason the web-player does need two separate updates, so that the + // position of the current track is retained, other clients also send a state + // update before they send the volume update + if let Err(why) = self.notify().await { + error!("error updating connect state for volume update: {why}") } }, else => break } } - if self.sender.flush().await.is_err() { - warn!("Cannot flush spirc event sender when done."); + if !self.shutdown && self.connect_state.is_active() { + if let Err(why) = self.notify().await { + warn!("notify before unexpected shutdown couldn't be send: {why}") + } + } + + // clears the session id, leaving an empty state + if let Err(why) = self.session.spclient().delete_connect_state_request().await { + warn!("deleting connect_state failed before unexpected shutdown: {why}") + } + self.session.dealer().close().await; + } + + async fn handle_resolve_context(&mut self) -> Result<(), Error> { + let mut last_resolve = None::; + while let Some(resolve) = self.resolve_context.pop() { + if matches!(last_resolve, Some(ref last_resolve) if last_resolve == &resolve) { + debug!("did already update the context for {resolve}"); + continue; + } else { + last_resolve = Some(resolve.clone()); + + let resolve_uri = match resolve.resolve_uri() { + Some(resolve) => resolve, + None => { + warn!("tried to resolve context without resolve_uri: {resolve}"); + return Ok(()); + } + }; + + debug!("resolving: {resolve}"); + // the autoplay endpoint can return a 404, when it tries to retrieve an + // autoplay context for an empty playlist as it seems + if let Err(why) = self + .resolve_context( + resolve_uri, + resolve.context_uri(), + resolve.autoplay(), + resolve.update(), + ) + .await + { + error!("failed resolving context <{resolve}>: {why}"); + self.unavailable_contexts.insert(resolve, Instant::now()); + continue; + } + + self.connect_state.merge_context(Some(resolve.into())); + } + } + + if let Some(transfer_state) = self.transfer_state.take() { + self.connect_state.finish_transfer(transfer_state)? + } + + if matches!(self.connect_state.active_context, ContextType::Default) { + let ctx = self.connect_state.context.as_ref(); + if matches!(ctx, Some(ctx) if ctx.tracks.is_empty()) { + self.connect_state.clear_next_tracks(true); + self.handle_next(None)?; + } + } + + self.connect_state.fill_up_next_tracks()?; + self.connect_state.update_restrictions(); + self.connect_state.update_queue_revision(); + + self.preload_autoplay_when_required(); + + self.notify().await + } + + async fn resolve_context( + &mut self, + resolve_uri: &str, + context_uri: &str, + autoplay: bool, + update: bool, + ) -> Result<(), Error> { + if !autoplay { + let mut ctx = self.session.spclient().get_context(resolve_uri).await?; + + if update { + ctx.uri = context_uri.to_string(); + ctx.url = format!("context://{context_uri}"); + + self.connect_state + .update_context(ctx, UpdateContext::Default)? + } else if matches!(ctx.pages.first(), Some(p) if !p.tracks.is_empty()) { + debug!( + "update context from single page, context {} had {} pages", + ctx.uri, + ctx.pages.len() + ); + self.connect_state + .fill_context_from_page(ctx.pages.remove(0))?; + } else { + error!("resolving context should only update the tracks, but had no page, or track. {ctx:#?}"); + }; + + if let Err(why) = self.notify().await { + error!("failed to update connect state, after updating the context: {why}") + } + + return Ok(()); + } + + if resolve_uri.contains("spotify:show:") || resolve_uri.contains("spotify:episode:") { + // autoplay is not supported for podcasts + Err(SpircError::NotAllowedContext(resolve_uri.to_string()))? + } + + let previous_tracks = self.connect_state.prev_autoplay_track_uris(); + + debug!( + "requesting autoplay context <{resolve_uri}> with {} previous tracks", + previous_tracks.len() + ); + + let ctx_request = AutoplayContextRequest { + context_uri: Some(resolve_uri.to_string()), + recent_track_uri: previous_tracks, + ..Default::default() + }; + + let context = self + .session + .spclient() + .get_autoplay_context(&ctx_request) + .await?; + + self.connect_state + .update_context(context, UpdateContext::Autoplay) + } + + fn add_resolve_context(&mut self, resolve: ResolveContext) { + let last_try = self + .unavailable_contexts + .get(&resolve) + .map(|i| i.duration_since(Instant::now())); + + let last_try = if matches!(last_try, Some(last_try) if last_try > RETRY_UNAVAILABLE) { + let _ = self.unavailable_contexts.remove(&resolve); + debug!( + "context was requested {}s ago, trying again to resolve the requested context", + last_try.expect("checked by condition").as_secs() + ); + None + } else { + last_try + }; + + if last_try.is_none() { + debug!("add resolve request: {resolve}"); + self.resolve_context.push(resolve); + } else { + debug!("tried loading unavailable context: {resolve}") } } - fn now_ms(&mut self) -> i64 { - let dur = match SystemTime::now().duration_since(UNIX_EPOCH) { - Ok(dur) => dur, - Err(err) => err.duration(), - }; + // todo: time_delta still necessary? + fn now_ms(&self) -> i64 { + let dur = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|err| err.duration()); dur.as_millis() as i64 + 1000 * self.session.time_delta() } - fn update_state_position(&mut self, position_ms: u32) { - let now = self.now_ms(); - self.state.set_position_measured_at(now as u64); - self.state.set_position_ms(position_ms); - } - - fn handle_command(&mut self, cmd: SpircCommand) -> Result<(), Error> { + async fn handle_command(&mut self, cmd: SpircCommand) -> Result<(), Error> { if matches!(cmd, SpircCommand::Shutdown) { trace!("Received SpircCommand::Shutdown"); - CommandSender::new(self, MessageType::kMessageTypeGoodbye).send()?; - self.handle_disconnect(); + self.handle_disconnect().await?; self.shutdown = true; if let Some(rx) = self.commands.as_mut() { rx.close() } Ok(()) - } else if self.device.is_active() { + } else if self.connect_state.is_active() { trace!("Received SpircCommand::{:?}", cmd); match cmd { SpircCommand::Play => { self.handle_play(); - self.notify(None) + self.notify().await } SpircCommand::PlayPause => { self.handle_play_pause(); - self.notify(None) + self.notify().await } SpircCommand::Pause => { self.handle_pause(); - self.notify(None) + self.notify().await } SpircCommand::Prev => { - self.handle_prev(); - self.notify(None) + self.handle_prev()?; + self.notify().await } SpircCommand::Next => { - self.handle_next(); - self.notify(None) + self.handle_next(None)?; + self.notify().await } SpircCommand::VolumeUp => { self.handle_volume_up(); - self.notify(None) + self.notify().await } SpircCommand::VolumeDown => { self.handle_volume_down(); - self.notify(None) + self.notify().await } SpircCommand::Disconnect => { - self.handle_disconnect(); - self.notify(None) + self.handle_disconnect().await?; + self.notify().await } SpircCommand::Shuffle(shuffle) => { - self.state.set_shuffle(shuffle); - self.notify(None) + self.connect_state.handle_shuffle(shuffle)?; + self.notify().await } SpircCommand::Repeat(repeat) => { - self.state.set_repeat(repeat); - self.notify(None) + self.connect_state.set_repeat_context(repeat); + self.notify().await + } + SpircCommand::RepeatTrack(repeat) => { + self.connect_state.set_repeat_track(repeat); + self.notify().await } SpircCommand::SetPosition(position) => { self.handle_seek(position); - self.notify(None) + self.notify().await } SpircCommand::SetVolume(volume) => { self.set_volume(volume); - self.notify(None) + self.notify().await } SpircCommand::Load(command) => { - self.handle_load(&command.into())?; - self.notify(None) + self.handle_load(command, None).await?; + self.notify().await } _ => Ok(()), } @@ -670,7 +686,7 @@ impl SpircTask { SpircCommand::Activate => { trace!("Received SpircCommand::{:?}", cmd); self.handle_activate(); - self.notify(None) + self.notify().await } _ => { warn!("SpircCommand::{:?} will be ignored while Not Active", cmd); @@ -680,7 +696,12 @@ impl SpircTask { } } - fn handle_player_event(&mut self, event: PlayerEvent) -> Result<(), Error> { + async fn handle_player_event(&mut self, event: PlayerEvent) -> Result<(), Error> { + if let PlayerEvent::TrackChanged { audio_item } = event { + self.connect_state.update_duration(audio_item.duration_ms); + return Ok(()); + } + // update play_request_id if let PlayerEvent::PlayRequestIdChanged { play_request_id } = event { self.play_request_id = Some(play_request_id); @@ -693,26 +714,25 @@ impl SpircTask { if let Some(play_request_id) = event.get_play_request_id() { if Some(play_request_id) == self.play_request_id { match event { - PlayerEvent::EndOfTrack { .. } => self.handle_end_of_track(), + PlayerEvent::EndOfTrack { .. } => self.handle_end_of_track().await, PlayerEvent::Loading { .. } => { match self.play_status { SpircPlayStatus::LoadingPlay { position_ms } => { - self.update_state_position(position_ms); - self.state.set_status(PlayStatus::kPlayStatusPlay); + self.connect_state + .update_position(position_ms, self.now_ms()); trace!("==> kPlayStatusPlay"); } SpircPlayStatus::LoadingPause { position_ms } => { - self.update_state_position(position_ms); - self.state.set_status(PlayStatus::kPlayStatusPause); + self.connect_state + .update_position(position_ms, self.now_ms()); trace!("==> kPlayStatusPause"); } _ => { - self.state.set_status(PlayStatus::kPlayStatusLoading); - self.update_state_position(0); + self.connect_state.update_position(0, self.now_ms()); trace!("==> kPlayStatusLoading"); } } - self.notify(None) + self.notify().await } PlayerEvent::Playing { position_ms, .. } | PlayerEvent::PositionCorrection { position_ms, .. } @@ -726,21 +746,22 @@ impl SpircTask { } => { if (*nominal_start_time - new_nominal_start_time).abs() > 100 { *nominal_start_time = new_nominal_start_time; - self.update_state_position(position_ms); - self.notify(None) + self.connect_state + .update_position(position_ms, self.now_ms()); + self.notify().await } else { Ok(()) } } SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::LoadingPause { .. } => { - self.state.set_status(PlayStatus::kPlayStatusPlay); - self.update_state_position(position_ms); + self.connect_state + .update_position(position_ms, self.now_ms()); self.play_status = SpircPlayStatus::Playing { nominal_start_time: new_nominal_start_time, preloading_of_next_track_triggered: false, }; - self.notify(None) + self.notify().await } _ => Ok(()), } @@ -752,23 +773,23 @@ impl SpircTask { trace!("==> kPlayStatusPause"); match self.play_status { SpircPlayStatus::Paused { .. } | SpircPlayStatus::Playing { .. } => { - self.state.set_status(PlayStatus::kPlayStatusPause); - self.update_state_position(new_position_ms); + self.connect_state + .update_position(new_position_ms, self.now_ms()); self.play_status = SpircPlayStatus::Paused { position_ms: new_position_ms, preloading_of_next_track_triggered: false, }; - self.notify(None) + self.notify().await } SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::LoadingPause { .. } => { - self.state.set_status(PlayStatus::kPlayStatusPause); - self.update_state_position(new_position_ms); + self.connect_state + .update_position(new_position_ms, self.now_ms()); self.play_status = SpircPlayStatus::Paused { position_ms: new_position_ms, preloading_of_next_track_triggered: false, }; - self.notify(None) + self.notify().await } _ => Ok(()), } @@ -778,9 +799,8 @@ impl SpircTask { match self.play_status { SpircPlayStatus::Stopped => Ok(()), _ => { - self.state.set_status(PlayStatus::kPlayStatusStop); self.play_status = SpircPlayStatus::Stopped; - self.notify(None) + self.notify().await } } } @@ -789,8 +809,11 @@ impl SpircTask { Ok(()) } PlayerEvent::Unavailable { track_id, .. } => { - self.handle_unavailable(track_id); - Ok(()) + self.handle_unavailable(track_id)?; + if self.connect_state.current_track(|t| &t.uri) == &track_id.to_uri()? { + self.handle_next(None)?; + } + self.notify().await } _ => Ok(()), } @@ -802,9 +825,57 @@ impl SpircTask { } } - fn handle_connection_id_update(&mut self, connection_id: String) { + async fn handle_connection_id_update(&mut self, connection_id: String) -> Result<(), Error> { trace!("Received connection ID update: {:?}", connection_id); self.session.set_connection_id(&connection_id); + + let cluster = match self + .connect_state + .notify_new_device_appeared(&self.session) + .await + { + Ok(res) => Cluster::parse_from_bytes(&res).ok(), + Err(why) => { + error!("{why:?}"); + None + } + } + .ok_or(SpircError::FailedDealerSetup)?; + + debug!( + "successfully put connect state for {} with connection-id {connection_id}", + self.session.device_id() + ); + + let same_session = cluster.player_state.session_id == self.session.session_id() + || cluster.player_state.session_id.is_empty(); + if !cluster.active_device_id.is_empty() || !same_session { + info!( + "active device is <{}> with session <{}>", + cluster.active_device_id, cluster.player_state.session_id + ); + return Ok(()); + } else if cluster.transfer_data.is_empty() { + debug!("got empty transfer state, do nothing"); + return Ok(()); + } else { + info!( + "trying to take over control automatically, session_id: {}", + cluster.player_state.session_id + ) + } + + use protobuf::Message; + + // todo: handle received pages from transfer, important to not always shuffle the first 10 tracks + // also important when the dealer is restarted, currently we just shuffle again, but at least + // the 10 tracks provided should be used and after that the new shuffle context + match TransferState::parse_from_bytes(&cluster.transfer_data) { + Ok(transfer_state) => self.handle_transfer(transfer_state)?, + Err(why) => error!("failed to take over control: {why}"), + } + + Ok(()) } fn handle_user_attributes_update(&mut self, update: UserAttributesUpdate) { @@ -849,6 +920,8 @@ impl SpircTask { if key == "autoplay" && old_value != new_value { self.player .emit_auto_play_changed_event(matches!(new_value, "1")); + + self.preload_autoplay_when_required() } } else { trace!( @@ -859,185 +932,243 @@ impl SpircTask { } } - fn handle_remote_update(&mut self, update: Frame) -> Result<(), Error> { - trace!("Received update frame: {:#?}", update); + async fn handle_cluster_update( + &mut self, + mut cluster_update: ClusterUpdate, + ) -> Result<(), Error> { + let reason = cluster_update.update_reason.enum_value(); - // First see if this update was intended for us. - let device_id = &self.ident; - let ident = update.ident(); - if ident == device_id - || (!update.recipient.is_empty() && !update.recipient.contains(device_id)) - { - return Err(SpircError::Ident(ident.to_string()).into()); + let device_ids = cluster_update.devices_that_changed.join(", "); + debug!( + "cluster update: {reason:?} from {device_ids}, active device: {}", + cluster_update.cluster.active_device_id + ); + + if let Some(cluster) = cluster_update.cluster.take() { + let became_inactive = self.connect_state.is_active() + && cluster.active_device_id != self.session.device_id(); + if became_inactive { + info!("device became inactive"); + self.connect_state.became_inactive(&self.session).await?; + self.handle_stop() + } else if self.connect_state.is_active() { + // fixme: workaround fix, because of missing information why it behaves like it does + // background: when another device sends a connect-state update, some player's position de-syncs + // tried: providing session_id, playback_id, track-metadata "track_player" + self.notify().await?; + } + } else if self.connect_state.is_active() { + self.connect_state.became_inactive(&self.session).await?; } - let old_client_id = self.session.client_id(); - - for entry in update.device_state.metadata.iter() { - match entry.type_() { - "client_id" => self.session.set_client_id(entry.metadata()), - "brand_display_name" => self.session.set_client_brand_name(entry.metadata()), - "model_display_name" => self.session.set_client_model_name(entry.metadata()), - _ => (), - } - } - - self.session.set_client_name(update.device_state.name()); - - let new_client_id = self.session.client_id(); - - if self.device.is_active() && new_client_id != old_client_id { - self.player.emit_session_client_changed_event( - new_client_id, - self.session.client_name(), - self.session.client_brand_name(), - self.session.client_model_name(), - ); - } - - match update.typ() { - MessageType::kMessageTypeHello => self.notify(Some(ident)), - - MessageType::kMessageTypeLoad => { - self.handle_load(update.state.get_or_default())?; - self.notify(None) - } - - MessageType::kMessageTypePlay => { - self.handle_play(); - self.notify(None) - } - - MessageType::kMessageTypePlayPause => { - self.handle_play_pause(); - self.notify(None) - } - - MessageType::kMessageTypePause => { - self.handle_pause(); - self.notify(None) - } - - MessageType::kMessageTypeNext => { - self.handle_next(); - self.notify(None) - } - - MessageType::kMessageTypePrev => { - self.handle_prev(); - self.notify(None) - } - - MessageType::kMessageTypeVolumeUp => { - self.handle_volume_up(); - self.notify(None) - } - - MessageType::kMessageTypeVolumeDown => { - self.handle_volume_down(); - self.notify(None) - } - - MessageType::kMessageTypeRepeat => { - let repeat = update.state.repeat(); - self.state.set_repeat(repeat); - - self.player.emit_repeat_changed_event(repeat); - - self.notify(None) - } - - MessageType::kMessageTypeShuffle => { - let shuffle = update.state.shuffle(); - self.state.set_shuffle(shuffle); - if shuffle { - let current_index = self.state.playing_track_index(); - let tracks = &mut self.state.track; - if !tracks.is_empty() { - tracks.swap(0, current_index as usize); - if let Some((_, rest)) = tracks.split_first_mut() { - let mut rng = rand::thread_rng(); - rest.shuffle(&mut rng); - } - self.state.set_playing_track_index(0); - } - } - self.player.emit_shuffle_changed_event(shuffle); - - self.notify(None) - } - - MessageType::kMessageTypeSeek => { - self.handle_seek(update.position()); - self.notify(None) - } - - MessageType::kMessageTypeReplace => { - let context_uri = update.state.context_uri().to_owned(); - - // completely ignore local playback. - if context_uri.starts_with("spotify:local-files") { - self.notify(None)?; - return Err(SpircError::UnsupportedLocalPlayBack.into()); - } - - self.update_tracks(update.state.get_or_default()); - - if let SpircPlayStatus::Playing { - preloading_of_next_track_triggered, - .. - } - | SpircPlayStatus::Paused { - preloading_of_next_track_triggered, - .. - } = self.play_status - { - if preloading_of_next_track_triggered { - // Get the next track_id in the playlist - if let Some(track_id) = self.preview_next_track() { - self.player.preload(track_id); - } - } - } - - self.notify(None) - } - - MessageType::kMessageTypeVolume => { - self.set_volume(update.volume() as u16); - self.notify(None) - } - - MessageType::kMessageTypeNotify => { - if self.device.is_active() - && update.device_state.is_active() - && self.device.became_active_at() <= update.device_state.became_active_at() - { - self.handle_disconnect(); - } - self.notify(None) - } - - _ => Ok(()), - } + Ok(()) } - fn handle_disconnect(&mut self) { - self.device.set_is_active(false); + async fn handle_connect_state_request( + &mut self, + (request, sender): RequestReply, + ) -> Result<(), Error> { + self.connect_state.set_last_command(request.clone()); + + debug!( + "handling: '{}' from {}", + request.command, request.sent_by_device_id + ); + + let response = match self.handle_request(request).await { + Ok(_) => Reply::Success, + Err(why) => { + error!("failed to handle request: {why}"); + Reply::Failure + } + }; + + sender.send(response).map_err(Into::into) + } + + async fn handle_request(&mut self, request: Request) -> Result<(), Error> { + use Command::*; + + match request.command { + // errors and unknown commands + Transfer(transfer) if transfer.data.is_none() => { + warn!("transfer endpoint didn't contain any data to transfer"); + Err(SpircError::NoData)? + } + Unknown(unknown) => Err(SpircError::UnknownEndpoint(unknown))?, + // implicit update of the connect_state + UpdateContext(update_context) => { + if &update_context.context.uri != self.connect_state.context_uri() { + debug!( + "ignoring context update for <{}>, because it isn't the current context <{}>", + update_context.context.uri, self.connect_state.context_uri() + ) + } else { + self.add_resolve_context(ResolveContext::from_context( + update_context.context, + false, + )) + } + return Ok(()); + } + // modification and update of the connect_state + Transfer(transfer) => { + self.handle_transfer(transfer.data.expect("by condition checked"))? + } + Play(play) => { + let shuffle = play + .options + .player_options_override + .as_ref() + .map(|o| o.shuffling_context) + .unwrap_or_else(|| self.connect_state.shuffling_context()); + let repeat = play + .options + .player_options_override + .as_ref() + .map(|o| o.repeating_context) + .unwrap_or_else(|| self.connect_state.repeat_context()); + let repeat_track = play + .options + .player_options_override + .as_ref() + .map(|o| o.repeating_track) + .unwrap_or_else(|| self.connect_state.repeat_track()); + + self.handle_load( + SpircLoadCommand { + context_uri: play.context.uri.clone(), + start_playing: true, + seek_to: play.options.seek_to.unwrap_or_default(), + playing_track: play.options.skip_to.into(), + shuffle, + repeat, + repeat_track, + }, + Some(play.context), + ) + .await?; + + self.connect_state.set_origin(play.play_origin) + } + Pause(_) => self.handle_pause(), + SeekTo(seek_to) => { + // for some reason the position is stored in value, not in position + trace!("seek to {seek_to:?}"); + self.handle_seek(seek_to.value) + } + SetShufflingContext(shuffle) => self.connect_state.handle_shuffle(shuffle.value)?, + SetRepeatingContext(repeat_context) => self + .connect_state + .handle_set_repeat(Some(repeat_context.value), None)?, + SetRepeatingTrack(repeat_track) => self + .connect_state + .handle_set_repeat(None, Some(repeat_track.value))?, + AddToQueue(add_to_queue) => self.connect_state.add_to_queue(add_to_queue.track, true), + SetQueue(set_queue) => self.connect_state.handle_set_queue(set_queue), + SetOptions(set_options) => { + let context = set_options.repeating_context; + let track = set_options.repeating_track; + self.connect_state.handle_set_repeat(context, track)?; + + let shuffle = set_options.shuffling_context; + if let Some(shuffle) = shuffle { + self.connect_state.handle_shuffle(shuffle)?; + } + } + SkipNext(skip_next) => self.handle_next(skip_next.track.map(|t| t.uri))?, + SkipPrev(_) => self.handle_prev()?, + Resume(_) if matches!(self.play_status, SpircPlayStatus::Stopped) => { + self.load_track(true, 0)? + } + Resume(_) => self.handle_play(), + } + + self.notify().await + } + + fn handle_transfer(&mut self, mut transfer: TransferState) -> Result<(), Error> { + self.connect_state + .reset_context(ResetContext::WhenDifferent( + &transfer.current_session.context.uri, + )); + + let mut ctx_uri = transfer.current_session.context.uri.clone(); + + match self.connect_state.current_track_from_transfer(&transfer) { + Err(why) => warn!("didn't find initial track: {why}"), + Ok(track) => { + debug!("found initial track <{}>", track.uri); + self.connect_state.set_track(track) + } + }; + + let autoplay = self.connect_state.current_track(|t| t.is_from_autoplay()); + if autoplay { + ctx_uri = ctx_uri.replace("station:", ""); + } + + let fallback = self.connect_state.current_track(|t| &t.uri).clone(); + + self.add_resolve_context(ResolveContext::from_uri(ctx_uri.clone(), &fallback, false)); + + let timestamp = self.now_ms(); + let state = &mut self.connect_state; + + state.set_active(true); + state.handle_initial_transfer(&mut transfer); + + // update position if the track continued playing + let position = if transfer.playback.is_paused { + transfer.playback.position_as_of_timestamp.into() + } else if transfer.playback.position_as_of_timestamp > 0 { + let time_since_position_update = timestamp - transfer.playback.timestamp; + i64::from(transfer.playback.position_as_of_timestamp) + time_since_position_update + } else { + 0 + }; + + let is_playing = !transfer.playback.is_paused; + + if self.connect_state.current_track(|t| t.is_autoplay()) || autoplay { + debug!("currently in autoplay context, async resolving autoplay for {ctx_uri}"); + + self.add_resolve_context(ResolveContext::from_uri(ctx_uri, fallback, true)) + } + + self.transfer_state = Some(transfer); + + self.load_track(is_playing, position.try_into()?) + } + + async fn handle_disconnect(&mut self) -> Result<(), Error> { self.handle_stop(); + self.play_status = SpircPlayStatus::Stopped {}; + self.connect_state + .update_position_in_relation(self.now_ms()); + self.notify().await?; + + self.connect_state.became_inactive(&self.session).await?; + self.player .emit_session_disconnected_event(self.session.connection_id(), self.session.username()); + + Ok(()) } fn handle_stop(&mut self) { self.player.stop(); + self.connect_state.update_position(0, self.now_ms()); + self.connect_state.clear_next_tracks(true); + + if let Err(why) = self.connect_state.fill_up_next_tracks() { + warn!("failed filling up next_track during stopping: {why}") + } } fn handle_activate(&mut self) { - let now = self.now_ms(); - self.device.set_is_active(true); - self.device.set_became_active_at(now); + self.connect_state.set_active(true); self.player .emit_session_connected_event(self.session.connection_id(), self.session.username()); self.player.emit_session_client_changed_event( @@ -1048,7 +1179,7 @@ impl SpircTask { ); self.player - .emit_volume_changed_event(self.device.volume() as u16); + .emit_volume_changed_event(self.connect_state.device_info().volume as u16); self.player .emit_auto_play_changed_event(self.session.autoplay()); @@ -1056,33 +1187,94 @@ impl SpircTask { self.player .emit_filter_explicit_content_changed_event(self.session.filter_explicit_content()); - self.player.emit_shuffle_changed_event(self.state.shuffle()); + self.player + .emit_shuffle_changed_event(self.connect_state.shuffling_context()); - self.player.emit_repeat_changed_event(self.state.repeat()); + self.player.emit_repeat_changed_event( + self.connect_state.repeat_context(), + self.connect_state.repeat_track(), + ); } - fn handle_load(&mut self, state: &State) -> Result<(), Error> { - if !self.device.is_active() { + async fn handle_load( + &mut self, + cmd: SpircLoadCommand, + context: Option, + ) -> Result<(), Error> { + self.connect_state + .reset_context(ResetContext::WhenDifferent(&cmd.context_uri)); + + if !self.connect_state.is_active() { self.handle_activate(); } - let context_uri = state.context_uri().to_owned(); - - // completely ignore local playback. - if context_uri.starts_with("spotify:local-files") { - self.notify(None)?; - return Err(SpircError::UnsupportedLocalPlayBack.into()); - } - - self.update_tracks(state); - - if !self.state.track.is_empty() { - let start_playing = state.status() == PlayStatus::kPlayStatusPlay; - self.load_track(start_playing, state.position_ms()); + let current_context_uri = self.connect_state.context_uri(); + let fallback = if let Some(ref ctx) = context { + match ConnectState::get_context_uri_from_context(ctx) { + Some(ctx_uri) => ctx_uri, + None => Err(SpircError::InvalidUri(cmd.context_uri.clone()))?, + } } else { - info!("No more tracks left in queue"); - self.handle_stop(); + &cmd.context_uri } + .clone(); + + if current_context_uri == &cmd.context_uri && fallback == cmd.context_uri { + debug!("context <{current_context_uri}> didn't change, no resolving required") + } else { + debug!("resolving context for load command"); + self.resolve_context(&fallback, &cmd.context_uri, false, true) + .await?; + } + + // for play commands with skip by uid, the context of the command contains + // tracks with uri and uid, so we merge the new context with the resolved/existing context + self.connect_state.merge_context(context); + self.connect_state.clear_next_tracks(false); + self.connect_state.clear_restrictions(); + + debug!("play track <{:?}>", cmd.playing_track); + + let index = match cmd.playing_track { + PlayingTrack::Index(i) => i as usize, + PlayingTrack::Uri(uri) => { + let ctx = self.connect_state.context.as_ref(); + ConnectState::find_index_in_context(ctx, |t| t.uri == uri)? + } + PlayingTrack::Uid(uid) => { + let ctx = self.connect_state.context.as_ref(); + ConnectState::find_index_in_context(ctx, |t| t.uid == uid)? + } + }; + + debug!( + "loading with shuffle: <{}>, repeat track: <{}> context: <{}>", + cmd.shuffle, cmd.repeat, cmd.repeat_track + ); + + self.connect_state.set_shuffle(cmd.shuffle); + self.connect_state.set_repeat_context(cmd.repeat); + + if cmd.shuffle { + self.connect_state.set_current_track(index)?; + self.connect_state.shuffle()?; + } else { + // manually overwrite a possible current queued track + self.connect_state.set_current_track(index)?; + self.connect_state.reset_playback_to_position(Some(index))?; + } + + self.connect_state.set_repeat_track(cmd.repeat_track); + + if self.connect_state.current_track(MessageField::is_some) { + self.load_track(cmd.start_playing, cmd.seek_to)?; + } else { + info!("No active track, stopping"); + self.handle_stop() + } + + self.preload_autoplay_when_required(); + Ok(()) } @@ -1093,8 +1285,8 @@ impl SpircTask { preloading_of_next_track_triggered, } => { self.player.play(); - self.state.set_status(PlayStatus::kPlayStatusPlay); - self.update_state_position(position_ms); + self.connect_state + .update_position(position_ms, self.now_ms()); self.play_status = SpircPlayStatus::Playing { nominal_start_time: self.now_ms() - position_ms as i64, preloading_of_next_track_triggered, @@ -1132,9 +1324,9 @@ impl SpircTask { preloading_of_next_track_triggered, } => { self.player.pause(); - self.state.set_status(PlayStatus::kPlayStatusPause); let position_ms = (self.now_ms() - nominal_start_time) as u32; - self.update_state_position(position_ms); + self.connect_state + .update_position(position_ms, self.now_ms()); self.play_status = SpircPlayStatus::Paused { position_ms, preloading_of_next_track_triggered, @@ -1149,7 +1341,8 @@ impl SpircTask { } fn handle_seek(&mut self, position_ms: u32) { - self.update_state_position(position_ms); + self.connect_state + .update_position(position_ms, self.now_ms()); self.player.seek(position_ms); let now = self.now_ms(); match self.play_status { @@ -1171,23 +1364,6 @@ impl SpircTask { }; } - fn consume_queued_track(&mut self) -> usize { - // Removes current track if it is queued - // Returns the index of the next track - let current_index = self.state.playing_track_index() as usize; - if (current_index < self.state.track.len()) && self.state.track[current_index].queued() { - self.state.track.remove(current_index); - current_index - } else { - current_index + 1 - } - } - - fn preview_next_track(&mut self) -> Option { - self.get_track_id_to_play_from_playlist(self.state.playing_track_index() + 1) - .map(|(track_id, _)| track_id) - } - fn handle_preload_next_track(&mut self) { // Requests the player thread to preload the next track match self.play_status { @@ -1204,138 +1380,195 @@ impl SpircTask { _ => (), } - if let Some(track_id) = self.preview_next_track() { + if let Some(track_id) = self.connect_state.preview_next_track() { self.player.preload(track_id); - } else { - self.handle_stop(); } } // Mark unavailable tracks so we can skip them later - fn handle_unavailable(&mut self, track_id: SpotifyId) { - let unavailables = self.get_track_index_for_spotify_id(&track_id, 0); - for &index in unavailables.iter() { - let mut unplayable_track_ref = TrackRef::new(); - unplayable_track_ref.set_gid(self.state.track[index].gid().to_vec()); - // Misuse context field to flag the track - unplayable_track_ref.set_context(String::from("NonPlayable")); - std::mem::swap(&mut self.state.track[index], &mut unplayable_track_ref); - debug!( - "Marked <{:?}> at {:?} as NonPlayable", - self.state.track[index], index, - ); - } + fn handle_unavailable(&mut self, track_id: SpotifyId) -> Result<(), Error> { + self.connect_state.mark_unavailable(track_id)?; self.handle_preload_next_track(); + + Ok(()) } - fn handle_next(&mut self) { - let context_uri = self.state.context_uri().to_owned(); - let mut tracks_len = self.state.track.len() as u32; - let mut new_index = self.consume_queued_track() as u32; - let mut continue_playing = self.state.status() == PlayStatus::kPlayStatusPlay; + fn preload_autoplay_when_required(&mut self) { + let require_load_new = !self + .connect_state + .has_next_tracks(Some(CONTEXT_FETCH_THRESHOLD)); - let update_tracks = - self.autoplay_context && tracks_len - new_index < CONTEXT_FETCH_THRESHOLD; - - debug!( - "At track {:?} of {:?} <{:?}> update [{}]", - new_index + 1, - tracks_len, - context_uri, - update_tracks, - ); - - // When in autoplay, keep topping up the playlist when it nears the end - if update_tracks { - if let Some(ref context) = self.context { - self.resolve_context = Some(context.next_page_url.to_owned()); - self.update_tracks_from_context(); - tracks_len = self.state.track.len() as u32; - } + if !require_load_new { + return; } - // When not in autoplay, either start autoplay or loop back to the start - if new_index >= tracks_len { - // for some contexts there is no autoplay, such as shows and episodes - // in such cases there is no context in librespot. - if self.context.is_some() && self.session.autoplay() { - // Extend the playlist - debug!("Starting autoplay for <{}>", context_uri); - // force reloading the current context with an autoplay context - self.autoplay_context = true; - self.resolve_context = Some(self.state.context_uri().to_owned()); - self.update_tracks_from_context(); - self.player.set_auto_normalise_as_album(false); - } else { - new_index = 0; - continue_playing &= self.state.repeat(); - debug!("Looping back to start, repeat is {}", continue_playing); + match self.connect_state.try_load_next_context() { + Err(why) => error!("failed loading next context: {why}"), + Ok(next) => { + match next { + LoadNext::Done => info!("loaded next context"), + LoadNext::PageUrl(page_url) => { + self.add_resolve_context(ResolveContext::from_page_url(page_url)) + } + LoadNext::Empty if self.session.autoplay() => { + let current_context = self.connect_state.context_uri(); + let fallback = self.connect_state.current_track(|t| &t.uri); + let resolve = ResolveContext::from_uri(current_context, fallback, true); + + self.add_resolve_context(resolve) + } + LoadNext::Empty => { + debug!("next context is empty and autoplay isn't enabled, no preloading required") + } + } } } + } - if tracks_len > 0 { - self.state.set_playing_track_index(new_index); - self.load_track(continue_playing, 0); + fn is_playing(&self) -> bool { + matches!( + self.play_status, + SpircPlayStatus::Playing { .. } | SpircPlayStatus::LoadingPlay { .. } + ) + } + + fn handle_next(&mut self, track_uri: Option) -> Result<(), Error> { + let continue_playing = self.is_playing(); + + let current_uri = self.connect_state.current_track(|t| &t.uri); + let mut has_next_track = + matches!(track_uri, Some(ref track_uri) if current_uri == track_uri); + + if !has_next_track { + has_next_track = loop { + let index = self.connect_state.next_track()?; + + let current_uri = self.connect_state.current_track(|t| &t.uri); + if matches!(track_uri, Some(ref track_uri) if current_uri != track_uri) { + continue; + } else { + break index.is_some(); + } + }; + }; + + self.preload_autoplay_when_required(); + + if has_next_track { + self.load_track(continue_playing, 0) } else { info!("Not playing next track because there are no more tracks left in queue."); - self.state.set_playing_track_index(0); + self.connect_state.reset_playback_to_position(None)?; self.handle_stop(); + Ok(()) } } - fn handle_prev(&mut self) { + fn handle_prev(&mut self) -> Result<(), Error> { // Previous behaves differently based on the position // Under 3s it goes to the previous song (starts playing) // Over 3s it seeks to zero (retains previous play status) if self.position() < 3000 { - // Queued tracks always follow the currently playing track. - // They should not be considered when calculating the previous - // track so extract them beforehand and reinsert them after it. - let mut queue_tracks = Vec::new(); - { - let queue_index = self.consume_queued_track(); - let tracks = &mut self.state.track; - while queue_index < tracks.len() && tracks[queue_index].queued() { - queue_tracks.push(tracks.remove(queue_index)); + let repeat_context = self.connect_state.repeat_context(); + match self.connect_state.prev_track()? { + None if repeat_context => self.connect_state.reset_playback_to_position(None)?, + None => { + self.connect_state.reset_playback_to_position(None)?; + self.handle_stop() } + Some(_) => self.load_track(self.is_playing(), 0)?, } - let current_index = self.state.playing_track_index(); - let new_index = if current_index > 0 { - current_index - 1 - } else if self.state.repeat() { - self.state.track.len() as u32 - 1 - } else { - 0 - }; - // Reinsert queued tracks after the new playing track. - let mut pos = (new_index + 1) as usize; - for track in queue_tracks { - self.state.track.insert(pos, track); - pos += 1; - } - - self.state.set_playing_track_index(new_index); - - let start_playing = self.state.status() == PlayStatus::kPlayStatusPlay; - self.load_track(start_playing, 0); } else { self.handle_seek(0); } + + Ok(()) } fn handle_volume_up(&mut self) { - let volume = (self.device.volume() as u16).saturating_add(VOLUME_STEP_SIZE); + let volume = + (self.connect_state.device_info().volume as u16).saturating_add(VOLUME_STEP_SIZE); self.set_volume(volume); } fn handle_volume_down(&mut self) { - let volume = (self.device.volume() as u16).saturating_sub(VOLUME_STEP_SIZE); + let volume = + (self.connect_state.device_info().volume as u16).saturating_sub(VOLUME_STEP_SIZE); self.set_volume(volume); } - fn handle_end_of_track(&mut self) -> Result<(), Error> { - self.handle_next(); - self.notify(None) + async fn handle_end_of_track(&mut self) -> Result<(), Error> { + let next_track = self + .connect_state + .repeat_track() + .then(|| self.connect_state.current_track(|t| t.uri.clone())); + + self.handle_next(next_track)?; + self.notify().await + } + + fn handle_playlist_modification( + &mut self, + playlist_modification_info: PlaylistModificationInfo, + ) -> Result<(), Error> { + let uri = playlist_modification_info.uri.ok_or(SpircError::NoData)?; + let uri = String::from_utf8(uri)?; + + if self.connect_state.context_uri() != &uri { + debug!("ignoring playlist modification update for playlist <{uri}>, because it isn't the current context"); + return Ok(()); + } + + debug!("playlist modification for current context: {uri}"); + self.add_resolve_context(ResolveContext::from_uri( + uri, + self.connect_state.current_track(|t| &t.uri), + false, + )); + + Ok(()) + } + + fn handle_session_update(&mut self, mut session_update: SessionUpdate) { + let reason = session_update.reason.enum_value(); + + let mut session = match session_update.session.take() { + None => return, + Some(session) => session, + }; + + let active_device = session._host_active_device_id.take().map(|id| match id { + _host_active_device_id::HostActiveDeviceId(id) => id, + other => { + warn!("unexpected active device id {other:?}"); + String::new() + } + }); + + if matches!(active_device, Some(ref device) if device == self.session.device_id()) { + info!( + "session update: <{:?}> for self, current session_id {}, new session_id {}", + reason, + self.session.session_id(), + session.session_id + ); + + if self.session.session_id() != session.session_id { + self.session.set_session_id(session.session_id.clone()); + self.connect_state.set_session_id(session.session_id); + } + } else { + debug!("session update: <{reason:?}> from active session host: <{active_device:?}>"); + } + + // this seems to be used for jams or handling the current session_id + // + // handling this event was intended to keep the playback when other clients (primarily + // mobile) connects, otherwise they would steel the current playback when there was no + // session_id provided on the initial PutStateReason::NEW_DEVICE state update + // + // by generating an initial session_id from the get-go we prevent that behavior and + // currently don't need to handle this event, might still be useful for later "jam" support } fn position(&mut self) -> u32 { @@ -1350,190 +1583,57 @@ impl SpircTask { } } - fn update_tracks_from_context(&mut self) { - if let Some(ref context) = self.context { - let new_tracks = &context.tracks; - - debug!("Adding {:?} tracks from context to frame", new_tracks.len()); - - let mut track_vec = self.state.track.clone(); - if let Some(head) = track_vec.len().checked_sub(CONTEXT_TRACKS_HISTORY) { - track_vec.drain(0..head); - } - track_vec.extend_from_slice(new_tracks); - self.state.track = track_vec; - - // Update playing index - if let Some(new_index) = self - .state - .playing_track_index() - .checked_sub(CONTEXT_TRACKS_HISTORY as u32) - { - self.state.set_playing_track_index(new_index); - } - } else { - warn!("No context to update from!"); - } - } - - fn update_tracks(&mut self, state: &State) { - trace!("State: {:#?}", state); - - let index = state.playing_track_index(); - let context_uri = state.context_uri(); - let tracks = &state.track; - - trace!("Frame has {:?} tracks", tracks.len()); - - // First the tracks from the requested context, without autoplay. - // We will transition into autoplay after the latest track of this context. - self.autoplay_context = false; - self.resolve_context = Some(context_uri.to_owned()); - - self.player - .set_auto_normalise_as_album(context_uri.starts_with("spotify:album:")); - - self.state.set_playing_track_index(index); - self.state.track = tracks.to_vec(); - self.state.set_context_uri(context_uri.to_owned()); - // has_shuffle/repeat seem to always be true in these replace msgs, - // but to replicate the behaviour of the Android client we have to - // ignore false values. - if state.repeat() { - self.state.set_repeat(true); - } - if state.shuffle() { - self.state.set_shuffle(true); - } - } - - // Helper to find corresponding index(s) for track_id - fn get_track_index_for_spotify_id( - &self, - track_id: &SpotifyId, - start_index: usize, - ) -> Vec { - let index: Vec = self.state.track[start_index..] - .iter() - .enumerate() - .filter(|&(_, track_ref)| track_ref.gid() == track_id.to_raw()) - .map(|(idx, _)| start_index + idx) - .collect(); - index - } - - // Broken out here so we can refactor this later when we move to SpotifyObjectID or similar - fn track_ref_is_unavailable(&self, track_ref: &TrackRef) -> bool { - track_ref.context() == "NonPlayable" - } - - fn get_track_id_to_play_from_playlist(&self, index: u32) -> Option<(SpotifyId, u32)> { - let tracks_len = self.state.track.len(); - - // Guard against tracks_len being zero to prevent - // 'index out of bounds: the len is 0 but the index is 0' - // https://github.com/librespot-org/librespot/issues/226#issuecomment-971642037 - if tracks_len == 0 { - warn!("No playable track found in state: {:?}", self.state); - return None; - } - - let mut new_playlist_index = index as usize; - - if new_playlist_index >= tracks_len { - new_playlist_index = 0; - } - - let start_index = new_playlist_index; - - // Cycle through all tracks, break if we don't find any playable tracks - // tracks in each frame either have a gid or uri (that may or may not be a valid track) - // E.g - context based frames sometimes contain tracks with - - let mut track_ref = self.state.track[new_playlist_index].clone(); - let mut track_id = SpotifyId::try_from(&track_ref); - while self.track_ref_is_unavailable(&track_ref) || track_id.is_err() { - warn!( - "Skipping track <{:?}> at position [{}] of {}", - track_ref, new_playlist_index, tracks_len - ); - - new_playlist_index += 1; - if new_playlist_index >= tracks_len { - new_playlist_index = 0; - } - - if new_playlist_index == start_index { - warn!("No playable track found in state: {:?}", self.state); - return None; - } - track_ref = self.state.track[new_playlist_index].clone(); - track_id = SpotifyId::try_from(&track_ref); - } - - match track_id { - Ok(track_id) => Some((track_id, new_playlist_index as u32)), - Err(_) => None, - } - } - - fn load_track(&mut self, start_playing: bool, position_ms: u32) { - let index = self.state.playing_track_index(); - - match self.get_track_id_to_play_from_playlist(index) { - Some((track, index)) => { - self.state.set_playing_track_index(index); - - self.player.load(track, start_playing, position_ms); - - self.update_state_position(position_ms); - if start_playing { - self.state.set_status(PlayStatus::kPlayStatusPlay); - self.play_status = SpircPlayStatus::LoadingPlay { position_ms }; - } else { - self.state.set_status(PlayStatus::kPlayStatusPause); - self.play_status = SpircPlayStatus::LoadingPause { position_ms }; - } - } - None => { - self.handle_stop(); - } - } - } - - fn hello(&mut self) -> Result<(), Error> { - CommandSender::new(self, MessageType::kMessageTypeHello).send() - } - - fn notify(&mut self, recipient: Option<&str>) -> Result<(), Error> { - let status = self.state.status(); - - // When in loading state, the Spotify UI is disabled for interaction. - // On desktop this isn't so bad but on mobile it means that the bottom - // control disappears entirely. This is very confusing, so don't notify - // in this case. - if status == PlayStatus::kPlayStatusLoading { + fn load_track(&mut self, start_playing: bool, position_ms: u32) -> Result<(), Error> { + if self.connect_state.current_track(MessageField::is_none) { + debug!("current track is none, stopping playback"); + self.handle_stop(); return Ok(()); } - trace!("Sending status to server: [{:?}]", status); - let mut cs = CommandSender::new(self, MessageType::kMessageTypeNotify); - if let Some(s) = recipient { - cs = cs.recipient(s); + let current_uri = self.connect_state.current_track(|t| &t.uri); + let id = SpotifyId::from_uri(current_uri)?; + self.player.load(id, start_playing, position_ms); + + self.connect_state + .update_position(position_ms, self.now_ms()); + if start_playing { + self.play_status = SpircPlayStatus::LoadingPlay { position_ms }; + } else { + self.play_status = SpircPlayStatus::LoadingPause { position_ms }; } - cs.send() + self.connect_state.set_status(&self.play_status); + + Ok(()) + } + + async fn notify(&mut self) -> Result<(), Error> { + self.connect_state.set_status(&self.play_status); + + if self.is_playing() { + self.connect_state + .update_position_in_relation(self.now_ms()); + } + + self.connect_state.set_now(self.now_ms() as u64); + + self.connect_state + .send_state(&self.session) + .await + .map(|_| ()) } fn set_volume(&mut self, volume: u16) { - let old_volume = self.device.volume(); + let old_volume = self.connect_state.device_info().volume; let new_volume = volume as u32; if old_volume != new_volume || self.mixer.volume() != volume { - self.device.set_volume(new_volume); + self.update_volume = true; + + self.connect_state.set_volume(new_volume); self.mixer.set_volume(volume); if let Some(cache) = self.session.cache() { cache.save_volume(volume) } - if self.device.is_active() { + if self.connect_state.is_active() { self.player.emit_volume_changed_event(volume); } } @@ -1545,44 +1645,3 @@ impl Drop for SpircTask { debug!("drop Spirc[{}]", self.spirc_id); } } - -struct CommandSender<'a> { - spirc: &'a mut SpircTask, - frame: protocol::spirc::Frame, -} - -impl<'a> CommandSender<'a> { - fn new(spirc: &'a mut SpircTask, cmd: MessageType) -> Self { - let mut frame = protocol::spirc::Frame::new(); - // frame version - frame.set_version(1); - // Latest known Spirc version is 3.2.6, but we need another interface to announce support for Spirc V3. - // Setting anything higher than 2.0.0 here just seems to limit it to 2.0.0. - frame.set_protocol_version("2.0.0".to_string()); - frame.set_ident(spirc.ident.clone()); - frame.set_seq_nr(spirc.sequence.get()); - frame.set_typ(cmd); - *frame.device_state.mut_or_insert_default() = spirc.device.clone(); - frame.set_state_update_id(spirc.now_ms()); - CommandSender { spirc, frame } - } - - fn recipient(mut self, recipient: &'a str) -> Self { - self.frame.recipient.push(recipient.to_owned()); - self - } - - #[allow(dead_code)] - fn state(mut self, state: protocol::spirc::State) -> Self { - *self.frame.state.mut_or_insert_default() = state; - self - } - - fn send(mut self) -> Result<(), Error> { - if self.frame.state.is_none() && self.spirc.device.is_active() { - *self.frame.state.mut_or_insert_default() = self.spirc.state.clone(); - } - - self.spirc.sender.send(self.frame.write_to_bytes()?) - } -} diff --git a/connect/src/state.rs b/connect/src/state.rs new file mode 100644 index 00000000..28e57dad --- /dev/null +++ b/connect/src/state.rs @@ -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), + #[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 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, + + pub active_since: Option, + 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, + /// upcoming contexts, directly provided by the context-resolver + next_contexts: Vec, + + /// a context to keep track of our shuffled context, + /// should be only available when `player.option.shuffling_context` is true + shuffle_context: Option, + /// a context to keep track of the autoplay context + autoplay_context: Option, +} + +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) -> 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 + } +} diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs new file mode 100644 index 00000000..3e9d720e --- /dev/null +++ b/connect/src/state/context.rs @@ -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, + pub metadata: HashMap, + pub restrictions: Option, + /// 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 bool>( + context: Option<&StateContext>, + f: F, + ) -> Result { + 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, + new_context_uri: Option<&str>, + provider: Option, + ) -> 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::>(); + + StateContext { + tracks, + restrictions, + metadata: page.metadata, + index: ContextIndex::new(), + } + } + + pub fn merge_context(&mut self, context: Option) -> 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, + ) -> Result { + 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 { + 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) + } +} diff --git a/connect/src/state/handle.rs b/connect/src/state/handle.rs new file mode 100644 index 00000000..a69e1ebe --- /dev/null +++ b/connect/src/state/handle.rs @@ -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, + track: Option, + ) -> 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(()) + } + } +} diff --git a/connect/src/state/metadata.rs b/connect/src/state/metadata.rs new file mode 100644 index 00000000..d3788b22 --- /dev/null +++ b/connect/src/state/metadata.rs @@ -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; + fn metadata_mut(&mut self) -> &mut HashMap; + + 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 { + &self.metadata + } + + fn metadata_mut(&mut self) -> &mut HashMap { + &mut self.metadata + } +} + +impl Metadata for ProvidedTrack { + fn metadata(&self) -> &HashMap { + &self.metadata + } + + fn metadata_mut(&mut self) -> &mut HashMap { + &mut self.metadata + } +} diff --git a/connect/src/state/options.rs b/connect/src/state/options.rs new file mode 100644 index 00000000..b6bc331c --- /dev/null +++ b/connect/src/state/options.rs @@ -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 + } +} diff --git a/connect/src/state/provider.rs b/connect/src/state/provider.rs new file mode 100644 index 00000000..97eb7aa4 --- /dev/null +++ b/connect/src/state/provider.rs @@ -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() + } +} diff --git a/connect/src/state/restrictions.rs b/connect/src/state/restrictions.rs new file mode 100644 index 00000000..a0f26933 --- /dev/null +++ b/connect/src/state/restrictions.rs @@ -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(); + } + } + } +} diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs new file mode 100644 index 00000000..2dc1b9af --- /dev/null +++ b/connect/src/state/tracks.rs @@ -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 { + 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 { + &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 { + &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 { + &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 { + &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, 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>, 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) -> 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) { + // 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) { + 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 { + 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) -> 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 { + let mut prev = self + .prev_tracks() + .iter() + .flat_map(|t| t.is_autoplay().then_some(t.uri.clone())) + .collect::>(); + + 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(); + } +} diff --git a/connect/src/state/transfer.rs b/connect/src/state/transfer.rs new file mode 100644 index 00000000..c310e0b9 --- /dev/null +++ b/connect/src/state/transfer.rs @@ -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 { + 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(()) + } +} diff --git a/core/Cargo.toml b/core/Cargo.toml index b76e6b8a..66d54a8b 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -60,6 +60,8 @@ tokio-util = { version = "0.7", features = ["codec"] } url = "2" uuid = { version = "1", default-features = false, features = ["fast-rng", "v4"] } 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 # but currently, hyper-proxy2 and tokio-tungstenite do not support it. diff --git a/core/src/config.rs b/core/src/config.rs index 1160c0f5..0b17690e 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -1,5 +1,6 @@ use std::{fmt, path::PathBuf, str::FromStr}; +use librespot_protocol::devices::DeviceType as ProtoDeviceType; use url::Url; pub(crate) const KEYMASTER_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; @@ -146,3 +147,29 @@ impl fmt::Display for DeviceType { f.write_str(str) } } + +impl From 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, + } + } +} diff --git a/core/src/connection/handshake.rs b/core/src/connection/handshake.rs index c94b1fb7..0d1da46c 100644 --- a/core/src/connection/handshake.rs +++ b/core/src/connection/handshake.rs @@ -233,8 +233,8 @@ where Ok(message) } -async fn read_into_accumulator<'a, 'b, T: AsyncRead + Unpin>( - connection: &'a mut T, +async fn read_into_accumulator<'b, T: AsyncRead + Unpin>( + connection: &mut T, size: usize, acc: &'b mut Vec, ) -> io::Result<&'b mut [u8]> { diff --git a/core/src/dealer/manager.rs b/core/src/dealer/manager.rs new file mode 100644 index 00000000..792deca3 --- /dev/null +++ b/core/src/dealer/manager.rs @@ -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 = OnceCell::from(Builder::new()), + dealer: OnceCell = OnceCell::new(), + } +} + +pub type BoxedStream = Pin + Send>>; +pub type BoxedStreamResult = BoxedStream>; + +#[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 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); +type RequestReceiver = mpsc::UnboundedReceiver; +type RequestSender = mpsc::UnboundedSender; + +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) -> Result { + 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( + &self, + uri: impl Into, + t: impl Fn(Message) -> Result + Send + 'static, + ) -> Result, Error> { + Ok(Box::pin(self.add_listen_for(uri)?.map(t))) + } + + pub fn add_handle_for(&self, url: impl Into) -> Result { + 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) -> Result, 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 + } + } +} diff --git a/core/src/dealer/maps.rs b/core/src/dealer/maps.rs index 4f719de7..23d21a11 100644 --- a/core/src/dealer/maps.rs +++ b/core/src/dealer/maps.rs @@ -1,8 +1,7 @@ use std::collections::HashMap; -use thiserror::Error; - use crate::Error; +use thiserror::Error; #[derive(Debug, Error)] pub enum HandlerMapError { @@ -28,6 +27,10 @@ impl Default for HandlerMap { } impl HandlerMap { + pub fn contains(&self, path: &str) -> bool { + matches!(self, HandlerMap::Branch(map) if map.contains_key(path)) + } + pub fn insert<'a>( &mut self, mut path: impl Iterator, @@ -107,6 +110,22 @@ impl SubscriberMap { } } + pub fn contains<'a>(&self, mut path: impl Iterator) -> 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 { self.children.is_empty() && self.subscribed.is_empty() } @@ -115,16 +134,22 @@ impl SubscriberMap { &mut self, mut path: impl Iterator, fun: &mut impl FnMut(&T) -> bool, - ) { - self.subscribed.retain(|x| fun(x)); + ) -> bool { + 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(y) = self.children.get_mut(next) { - y.retain(path, fun); + handled_by_any = handled_by_any || y.retain(path, fun); if y.is_empty() { self.children.remove(next); } } } + + handled_by_any } } diff --git a/core/src/dealer/mod.rs b/core/src/dealer/mod.rs index 8969f317..d5bff5b1 100644 --- a/core/src/dealer/mod.rs +++ b/core/src/dealer/mod.rs @@ -1,3 +1,4 @@ +pub mod manager; mod maps; pub mod protocol; @@ -28,8 +29,10 @@ use tokio_tungstenite::tungstenite; use tungstenite::error::UrlError; use url::Url; -use self::maps::*; -use self::protocol::*; +use self::{ + maps::*, + protocol::{Message, MessageOrRequest, Request, WebsocketMessage, WebsocketRequest}, +}; use crate::{ socket, @@ -39,7 +42,14 @@ use crate::{ type WsMessage = tungstenite::Message; type WsError = tungstenite::Error; -type WsResult = Result; +type WsResult = Result; +type GetUrlResult = Result; + +impl From for Error { + fn from(err: WsError) -> Self { + Error::failed_precondition(err) + } +} 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); -pub struct Response { +struct Response { pub success: bool, } -pub struct Responder { +struct Responder { key: String, tx: mpsc::UnboundedSender, sent: bool, @@ -101,7 +111,7 @@ impl Drop for Responder { } } -pub trait IntoResponse { +trait IntoResponse { 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); } @@ -156,8 +166,10 @@ impl Stream for Subscription { fn split_uri(s: &str) -> Option> { let (scheme, sep, rest) = if let Some(rest) = s.strip_prefix("hm://") { ("hm", '/', rest) - } else if let Some(rest) = s.strip_suffix("spotify:") { + } else if let Some(rest) = s.strip_prefix("spotify:") { ("spotify", ':', rest) + } else if s.contains('/') { + ("", '/', s) } else { return None; }; @@ -169,7 +181,7 @@ fn split_uri(s: &str) -> Option> { } #[derive(Debug, Clone, Error)] -pub enum AddHandlerError { +enum AddHandlerError { #[error("There is already a handler for the given uri")] AlreadyHandled, #[error("The specified uri {0} is invalid")] @@ -186,7 +198,7 @@ impl From for Error { } #[derive(Debug, Clone, Error)] -pub enum SubscriptionError { +enum SubscriptionError { #[error("The specified uri is invalid")] InvalidUri(String), } @@ -224,8 +236,23 @@ fn subscribe( Ok(Subscription(rx)) } +fn handles( + req_map: &HandlerMap>, + msg_map: &SubscriberMap, + 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)] -pub struct Builder { +struct Builder { message_handlers: SubscriberMap, request_handlers: HandlerMap>, } @@ -267,22 +294,26 @@ impl Builder { 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(self, get_url: F, proxy: Option) -> Dealer where - Fut: Future + Send + 'static, - F: (FnMut() -> Fut) + Send + 'static, + Fut: Future + Send + 'static, + F: (Fn() -> Fut) + Send + 'static, { create_dealer!(self, shared -> run(shared, None, get_url, proxy)) } - pub async fn launch(self, mut get_url: F, proxy: Option) -> WsResult + pub async fn launch(self, get_url: F, proxy: Option) -> WsResult where - Fut: Future + Send + 'static, - F: (FnMut() -> Fut) + Send + 'static, + Fut: Future + Send + 'static, + F: (Fn() -> Fut) + Send + 'static, { let dealer = create_dealer!(self, shared -> { // Try to connect. - let url = get_url().await; + let url = get_url().await?; let tasks = connect(&url, proxy.as_ref(), &shared).await?; // If a connection is established, continue in a background task. @@ -303,15 +334,47 @@ struct 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) { - self.message_handlers + if self + .message_handlers .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) { + fn dispatch_request( + &self, + request: WebsocketRequest, + send_tx: &mpsc::UnboundedSender, + ) { + 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. let responder = Responder::new(request.key.clone(), send_tx.clone()); @@ -325,13 +388,11 @@ impl DealerShared { return; }; - { - let handler_map = self.request_handlers.lock(); + let handler_map = self.request_handlers.lock(); - if let Some(handler) = handler_map.get(split) { - handler.handle_request(request, responder); - return; - } + if let Some(handler) = handler_map.get(split) { + handler.handle_request(payload_request, responder); + return; } warn!("No handler for message_ident: {}", &request.message_ident); @@ -355,9 +416,9 @@ impl DealerShared { } } -pub struct Dealer { +struct Dealer { shared: Arc, - handle: TimeoutOnDrop<()>, + handle: TimeoutOnDrop>, } impl Dealer { @@ -376,6 +437,14 @@ impl Dealer { 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) { debug!("closing dealer"); @@ -402,7 +471,7 @@ async fn connect( let default_port = match address.scheme() { "ws" => 80, "wss" => 443, - _ => return Err(WsError::Url(UrlError::UnsupportedUrlScheme)), + _ => return Err(WsError::Url(UrlError::UnsupportedUrlScheme).into()), }; let port = address.port().unwrap_or(default_port); @@ -484,13 +553,13 @@ async fn connect( Some(Ok(msg)) => match msg { WsMessage::Text(t) => match serde_json::from_str(&t) { 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(_) => { info!("Received invalid binary message"); } WsMessage::Pong(_) => { - debug!("Received pong"); + trace!("Received pong"); pong_received.store(true, atomic::Ordering::Relaxed); } _ => (), // tungstenite handles Close and Ping automatically @@ -522,7 +591,7 @@ async fn connect( break; } - debug!("Sent ping"); + trace!("Sent ping"); sleep(PING_TIMEOUT).await; @@ -556,8 +625,9 @@ async fn run( initial_tasks: Option<(JoinHandle<()>, JoinHandle<()>)>, mut get_url: F, proxy: Option, -) where - Fut: Future + Send + 'static, +) -> Result<(), Error> +where + Fut: Future + Send + 'static, F: (FnMut() -> Fut) + Send + 'static, { let init_task = |t| Some(TimeoutOnDrop::new(t, WEBSOCKET_CLOSE_TIMEOUT)); @@ -593,7 +663,7 @@ async fn run( break }, e = get_url() => e - }; + }?; match connect(&url, proxy.as_ref(), &shared).await { Ok((s, r)) => tasks = (init_task(s), init_task(r)), @@ -609,4 +679,6 @@ async fn run( let tasks = tasks.0.into_iter().chain(tasks.1); let _ = join_all(tasks).await; + + Ok(()) } diff --git a/core/src/dealer/protocol.rs b/core/src/dealer/protocol.rs index 9e62a2e5..e6b7f2dc 100644 --- a/core/src/dealer/protocol.rs +++ b/core/src/dealer/protocol.rs @@ -1,19 +1,58 @@ +pub mod request; + +pub use request::*; + 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_json::Error as SerdeError; +use thiserror::Error; -pub type JsonValue = serde_json::Value; -pub type JsonObject = serde_json::Map; +const IGNORE_UNKNOWN: protobuf_json_mapping::ParseOptions = protobuf_json_mapping::ParseOptions { + ignore_unknown_fields: true, + _future_options: (), +}; -#[derive(Clone, Debug, Deserialize)] -pub struct Payload { - pub message_id: i32, - pub sent_by_device_id: String, - pub command: JsonObject, +type JsonValue = serde_json::Value; + +#[derive(Debug, Error)] +enum ProtocolError { + #[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 for Error { + fn from(err: ProtocolError) -> Self { + match err { + ProtocolError::UnexpectedData(_) => Error::unavailable(err), + _ => Error::failed_precondition(err), + } + } } #[derive(Clone, Debug, Deserialize)] -pub struct Request { +pub(super) struct Payload { + pub compressed: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct WebsocketRequest { #[serde(default)] pub headers: HashMap, pub message_ident: String, @@ -22,18 +61,133 @@ pub struct Request { } #[derive(Clone, Debug, Deserialize)] -pub struct Message { +pub(super) struct WebsocketMessage { #[serde(default)] pub headers: HashMap, pub method: Option, #[serde(default)] - pub payloads: Vec, + pub payloads: Vec, pub uri: String, } +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub enum MessagePayloadValue { + String(String), + Bytes(Vec), + Json(JsonValue), +} + #[derive(Clone, Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub(super) enum MessageOrRequest { - Message(Message), - Request(Request), + Message(WebsocketMessage), + Request(WebsocketRequest), +} + +#[derive(Clone, Debug)] +pub enum PayloadValue { + Empty, + Raw(Vec), + Json(String), +} + +#[derive(Clone, Debug)] +pub struct Message { + pub headers: HashMap, + pub payload: PayloadValue, + pub uri: String, +} + +impl Message { + pub fn from_json(value: Self) -> Result { + use protobuf_json_mapping::*; + match value.payload { + PayloadValue::Json(json) => match parse_from_str::(&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(value: Self) -> Result { + 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 { + 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 { + 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::(&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, + data: Vec, +) -> Result, 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()), + } } diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs new file mode 100644 index 00000000..4d796469 --- /dev/null +++ b/core/src/dealer/protocol/request.rs @@ -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), + 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, + 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, + 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, + #[serde(deserialize_with = "vec_json_proto")] + pub prev_tracks: Vec, + // 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, + pub repeating_context: Option, + pub repeating_track: Option, + pub options: Option, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct UpdateContextCommand { + #[serde(deserialize_with = "json_proto")] + pub context: Context, + pub session_id: Option, +} + +#[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, + pub license: Option, + // possible to send wie web-api + pub seek_to: Option, + // mobile + pub always_play_something: Option, + pub audio_stream: Option, + pub initially_paused: Option, + pub prefetch_level: Option, + pub system_initiated: Option, +} + +#[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, + pub track_uri: Option, + pub track_index: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct LoggingParams { + pub interaction_ids: Option>, + pub device_identifier: Option, + pub command_initiated_time: Option, + pub page_instance_ids: Option>, + pub command_id: Option, +} diff --git a/core/src/deserialize_with.rs b/core/src/deserialize_with.rs new file mode 100644 index 00000000..11687f9b --- /dev/null +++ b/core/src/deserialize_with.rs @@ -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( + value: &Value, +) -> Result { + protobuf_json_mapping::parse_from_str_with_options::(&value.to_string(), &IGNORE_UNKNOWN) +} + +pub fn base64_proto<'de, T, D>(de: D) -> Result, 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 +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, 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, 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::>(); + + Ok(res) +} + +pub fn boxed<'de, T, D>(de: D) -> Result, 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 +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", + )), + } +} diff --git a/core/src/error.rs b/core/src/error.rs index 6b0178c9..0ae2e74f 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -499,3 +499,9 @@ impl From for Error { Self::new(ErrorKind::FailedPrecondition, err) } } + +impl From for Error { + fn from(err: protobuf_json_mapping::ParseError) -> Self { + Self::failed_precondition(err) + } +} diff --git a/core/src/http_client.rs b/core/src/http_client.rs index 88d41c92..0b932ad9 100644 --- a/core/src/http_client.rs +++ b/core/src/http_client.rs @@ -208,7 +208,7 @@ impl HttpClient { } } - if code != StatusCode::OK { + if !code.is_success() { return Err(HttpClientError::StatusCode(code).into()); } } diff --git a/core/src/lib.rs b/core/src/lib.rs index 9894bb70..f2d6587e 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -16,7 +16,8 @@ pub mod config; mod connection; pub mod date; #[allow(dead_code)] -mod dealer; +pub mod dealer; +pub mod deserialize_with; #[doc(hidden)] pub mod diffie_hellman; pub mod error; diff --git a/core/src/mercury/mod.rs b/core/src/mercury/mod.rs index 7fde9b7f..76b060a3 100644 --- a/core/src/mercury/mod.rs +++ b/core/src/mercury/mod.rs @@ -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); trace!("response pushed over Mercury: {:?}", response); Err(MercuryError::Response(response).into()) - } else { - Ok(()) } } else if let Some(cb) = pending.callback { cb.send(Ok(response)).map_err(|_| MercuryError::Channel)?; diff --git a/core/src/session.rs b/core/src/session.rs index defdf61b..45d54bc6 100644 --- a/core/src/session.rs +++ b/core/src/session.rs @@ -9,23 +9,7 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; -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 crate::dealer::manager::DealerManager; use crate::{ apresolve::{ApResolver, SocketAddress}, audio_key::AudioKeyManager, @@ -43,6 +27,23 @@ use crate::{ token::TokenProvider, 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)] pub enum SessionError { @@ -78,6 +79,7 @@ pub struct UserData { #[derive(Debug, Clone, Default)] struct SessionData { + session_id: String, client_id: String, client_name: String, client_brand_name: String, @@ -100,6 +102,7 @@ struct SessionInternal { audio_key: OnceCell, channel: OnceCell, mercury: OnceCell, + dealer: OnceCell, spclient: OnceCell, token_provider: OnceCell, login5: OnceCell, @@ -128,6 +131,8 @@ impl Session { let session_data = SessionData { 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() }; @@ -141,6 +146,7 @@ impl Session { audio_key: OnceCell::new(), channel: OnceCell::new(), mercury: OnceCell::new(), + dealer: OnceCell::new(), spclient: OnceCell::new(), token_provider: OnceCell::new(), login5: OnceCell::new(), @@ -303,6 +309,12 @@ impl Session { .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 { self.0.spclient.get_or_init(|| SpClient::new(self.weak())) } @@ -373,6 +385,14 @@ impl Session { 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 { &self.config().device_id } diff --git a/core/src/spclient.rs b/core/src/spclient.rs index a23b52f0..c818570a 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -3,20 +3,6 @@ use std::{ 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::{ apresolve::SocketAddress, @@ -37,6 +23,20 @@ use crate::{ version::spotify_semantic_version, 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! { SpClient : SpClientInner { @@ -50,11 +50,20 @@ pub type SpClientResult = Result; #[allow(clippy::declare_interior_mutable_const)] 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)] pub enum SpClientError { #[error("missing attribute {0}")] Attribute(String), + #[error("expected data but received none")] + NoData, } impl From 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 { pub fn set_strategy(&self, strategy: RequestStrategy) { self.lock(|inner| inner.strategy = strategy) @@ -354,7 +377,25 @@ impl SpClient { headers: Option, message: &M, ) -> 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( + &self, + method: &Method, + endpoint: &str, + headers: Option, + message: &M, + options: &RequestOptions, + ) -> SpClientResult { + let body = message.write_to_bytes()?; let mut headers = headers.unwrap_or_default(); headers.insert( @@ -362,7 +403,7 @@ impl SpClient { 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 } @@ -376,7 +417,8 @@ impl SpClient { let mut headers = headers.unwrap_or_default(); 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( @@ -384,7 +426,19 @@ impl SpClient { method: &Method, endpoint: &str, headers: Option, - 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, + body: Option<&[u8]>, + options: &RequestOptions, ) -> SpClientResult { let mut tries: usize = 0; let mut last_response; @@ -399,31 +453,33 @@ impl SpClient { let mut url = self.base_url().await?; url.push_str(endpoint); - let separator = match url.find('?') { - Some(_) => "&", - None => "?", - }; - // 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. // For the sake of documentation you could also do "product=free" but // we only support premium anyway. - let _ = write!( - url, - "{}product=0&country={}", - separator, - self.session().country() - ); + if options.metrics && !url.contains("product=0") { + let _ = write!( + url, + "{}product=0&country={}", + util::get_next_query_separator(&url), + self.session().country() + ); + } // Defeat caches. Spotify-generated URLs already contain this. - if !url.contains("salt=") { - let _ = write!(url, "&salt={}", rand::thread_rng().next_u32()); + if options.salt && !url.contains("salt=") { + let _ = write!( + url, + "{}salt={}", + util::get_next_query_separator(&url), + rand::thread_rng().next_u32() + ); } let mut request = Request::builder() .method(method) .uri(url) - .body(body.to_owned().into())?; + .body(Bytes::copy_from_slice(body))?; // Reconnection logic: keep getting (cached) tokens because they might have expired. let token = self.session().login5().auth_token().await?; @@ -481,20 +537,34 @@ impl SpClient { last_response } - pub async fn put_connect_state( - &self, - connection_id: &str, - state: &PutStateRequest, - ) -> SpClientResult { + pub async fn put_connect_state_request(&self, state: &PutStateRequest) -> SpClientResult { let endpoint = format!("/connect-state/v1/devices/{}", self.session().device_id()); 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) .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 { let endpoint = format!("/metadata/4/{}/{}", scope, id.to_base16()?); self.request(&Method::GET, &endpoint, None, None).await @@ -738,4 +808,76 @@ impl SpClient { 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 { + 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::(&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 { + 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::(&ctx_json); + + if ctx.is_err() { + trace!("failed parsing context: {ctx_json}") + } + + Ok(ctx?) + } + + pub async fn get_rootlist(&self, from: usize, length: Option) -> 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 + } } diff --git a/core/src/spotify_id.rs b/core/src/spotify_id.rs index 959b84ee..f7478f54 100644 --- a/core/src/spotify_id.rs +++ b/core/src/spotify_id.rs @@ -423,19 +423,6 @@ impl TryFrom<&Vec> for SpotifyId { } } -impl TryFrom<&protocol::spirc::TrackRef> for SpotifyId { - type Error = crate::Error; - fn try_from(track: &protocol::spirc::TrackRef) -> Result { - 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 { type Error = crate::Error; fn try_from(album: &protocol::metadata::Album) -> Result { diff --git a/core/src/util.rs b/core/src/util.rs index 31cdd962..c48378af 100644 --- a/core/src/util.rs +++ b/core/src/util.rs @@ -165,3 +165,10 @@ pub fn solve_hash_cash( Ok(now.elapsed()) } + +pub fn get_next_query_separator(url: &str) -> &'static str { + match url.find('?') { + Some(_) => "&", + None => "?", + } +} diff --git a/core/src/version.rs b/core/src/version.rs index 3439662c..4fce65ad 100644 --- a/core/src/version.rs +++ b/core/src/version.rs @@ -25,6 +25,9 @@ pub const SPOTIFY_SEMANTIC_VERSION: &str = "1.2.31.1205.g4d59ad7c"; /// The protocol version of the Spotify mobile app. 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. pub const FALLBACK_USER_AGENT: &str = "Spotify/117300517 Linux/0 (librespot)"; diff --git a/docs/dealer.md b/docs/dealer.md new file mode 100644 index 00000000..24704214 --- /dev/null +++ b/docs/dealer.md @@ -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_(_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. diff --git a/examples/play_connect.rs b/examples/play_connect.rs index c46464fb..9a033da2 100644 --- a/examples/play_connect.rs +++ b/examples/play_connect.rs @@ -9,13 +9,13 @@ use librespot::{ player::Player, }, }; +use librespot_connect::spirc::PlayingTrack; use librespot_connect::{ - config::ConnectConfig, spirc::{Spirc, SpircLoadCommand}, + state::ConnectStateConfig, }; use librespot_metadata::{Album, Metadata}; use librespot_playback::mixer::{softmixer::SoftMixer, Mixer, MixerConfig}; -use librespot_protocol::spirc::TrackRef; use std::env; use std::sync::Arc; use tokio::join; @@ -25,7 +25,7 @@ async fn main() { let session_config = SessionConfig::default(); let player_config = PlayerConfig::default(); let audio_format = AudioFormat::default(); - let connect_config = ConnectConfig::default(); + let connect_config = ConnectStateConfig::default(); let mut args: Vec<_> = env::args().collect(); 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()) .await .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!( "Playing album: {} by {}", @@ -87,10 +79,12 @@ async fn main() { .load(SpircLoadCommand { context_uri, start_playing: true, + seek_to: 0, shuffle: 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 - tracks, + repeat_track: false, + // the index specifies which track in the context starts playing, in this case the first in the album + playing_track: PlayingTrack::Index(0), }) .unwrap(); }); diff --git a/playback/src/audio_backend/portaudio.rs b/playback/src/audio_backend/portaudio.rs index c44245cf..29fba7d9 100644 --- a/playback/src/audio_backend/portaudio.rs +++ b/playback/src/audio_backend/portaudio.rs @@ -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<()> { macro_rules! start_sink { (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) { portaudio_rs::terminate().unwrap(); } } -impl<'a> PortAudioSink<'a> { +impl PortAudioSink<'_> { pub const NAME: &'static str = "portaudio"; } diff --git a/playback/src/audio_backend/rodio.rs b/playback/src/audio_backend/rodio.rs index 2632f54a..f63fdef0 100644 --- a/playback/src/audio_backend/rodio.rs +++ b/playback/src/audio_backend/rodio.rs @@ -145,7 +145,7 @@ fn create_sink( }, Some(device_name) => { 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()))? } None => host diff --git a/playback/src/player.rs b/playback/src/player.rs index 43f63610..6a4170f0 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -123,7 +123,10 @@ enum PlayerCommand { }, EmitFilterExplicitContentChangedEvent(bool), EmitShuffleChangedEvent(bool), - EmitRepeatChangedEvent(bool), + EmitRepeatChangedEvent { + context: bool, + track: bool, + }, EmitAutoPlayChangedEvent(bool), } @@ -218,7 +221,8 @@ pub enum PlayerEvent { shuffle: bool, }, RepeatChanged { - repeat: bool, + context: bool, + track: bool, }, AutoPlayChanged { auto_play: bool, @@ -607,8 +611,8 @@ impl Player { self.command(PlayerCommand::EmitShuffleChangedEvent(shuffle)); } - pub fn emit_repeat_changed_event(&self, repeat: bool) { - self.command(PlayerCommand::EmitRepeatChangedEvent(repeat)); + pub fn emit_repeat_changed_event(&self, context: bool, track: bool) { + self.command(PlayerCommand::EmitRepeatChangedEvent { context, track }); } pub fn emit_auto_play_changed_event(&self, auto_play: bool) { @@ -2104,8 +2108,8 @@ impl PlayerInternal { self.send_event(PlayerEvent::VolumeChanged { volume }) } - PlayerCommand::EmitRepeatChangedEvent(repeat) => { - self.send_event(PlayerEvent::RepeatChanged { repeat }) + PlayerCommand::EmitRepeatChangedEvent { context, track } => { + self.send_event(PlayerEvent::RepeatChanged { context, track }) } PlayerCommand::EmitShuffleChangedEvent(shuffle) => { @@ -2336,9 +2340,10 @@ impl fmt::Debug for PlayerCommand { .debug_tuple("EmitShuffleChangedEvent") .field(&shuffle) .finish(), - PlayerCommand::EmitRepeatChangedEvent(repeat) => f + PlayerCommand::EmitRepeatChangedEvent { context, track } => f .debug_tuple("EmitRepeatChangedEvent") - .field(&repeat) + .field(&context) + .field(&track) .finish(), PlayerCommand::EmitAutoPlayChangedEvent(auto_play) => f .debug_tuple("EmitAutoPlayChangedEvent") diff --git a/protocol/build.rs b/protocol/build.rs index 8a0a8138..43971bc8 100644 --- a/protocol/build.rs +++ b/protocol/build.rs @@ -37,6 +37,8 @@ fn compile() { proto_dir.join("spotify/login5/v3/user_info.proto"), proto_dir.join("storage-resolve.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 proto_dir.join("authentication.proto"), proto_dir.join("canvaz.proto"), @@ -45,7 +47,6 @@ fn compile() { proto_dir.join("keyexchange.proto"), proto_dir.join("mercury.proto"), proto_dir.join("pubsub.proto"), - proto_dir.join("spirc.proto"), ]; let slices = files.iter().map(Deref::deref).collect::>(); diff --git a/src/main.rs b/src/main.rs index 2da9323a..6aaa72ce 100644 --- a/src/main.rs +++ b/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::{ env, fs::create_dir_all, @@ -12,12 +8,13 @@ use std::{ str::FromStr, 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::{ - connect::{config::ConnectConfig, spirc::Spirc}, + connect::{spirc::Spirc, state::ConnectStateConfig}, core::{ authentication::Credentials, cache::Cache, config::DeviceType, version, Session, SessionConfig, @@ -33,9 +30,11 @@ use librespot::{ player::{coefficient_to_duration, duration_to_coefficient, Player}, }, }; - -#[cfg(feature = "alsa-backend")] -use librespot::playback::mixer::alsamixer::AlsaMixer; +use log::{debug, error, info, trace, warn}; +use sha1::{Digest, Sha1}; +use sysinfo::{ProcessesToUpdate, System}; +use thiserror::Error; +use url::Url; mod player_event_handler; use player_event_handler::{run_program_on_sink_events, EventHandler}; @@ -208,7 +207,7 @@ struct Setup { cache: Option, player_config: PlayerConfig, session_config: SessionConfig, - connect_config: ConnectConfig, + connect_config: ConnectStateConfig, mixer_config: MixerConfig, credentials: Option, enable_oauth: bool, @@ -1371,7 +1370,7 @@ fn get_setup() -> Setup { }); 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()); @@ -1431,14 +1430,11 @@ fn get_setup() -> Setup { #[cfg(feature = "alsa-backend")] let default_value = &format!( "{}, 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"))] - let default_value = &connect_default_config - .initial_volume - .unwrap_or_default() - .to_string(); + let default_value = &connect_default_config.initial_volume.to_string(); invalid_error_msg( INITIAL_VOLUME, @@ -1485,14 +1481,21 @@ fn get_setup() -> Setup { let is_group = opt_present(DEVICE_IS_GROUP); - let has_volume_ctrl = !matches!(mixer_config.volume_ctrl, VolumeCtrl::Fixed); - - ConnectConfig { - name, - device_type, - is_group, - initial_volume, - has_volume_ctrl, + if let Some(initial_volume) = initial_volume { + ConnectStateConfig { + name, + device_type, + is_group, + initial_volume: initial_volume.into(), + ..Default::default() + } + } else { + ConnectStateConfig { + name, + device_type, + is_group, + ..Default::default() + } } }; diff --git a/src/player_event_handler.rs b/src/player_event_handler.rs index 3d0a47df..21cfe01c 100644 --- a/src/player_event_handler.rs +++ b/src/player_event_handler.rs @@ -226,9 +226,10 @@ impl EventHandler { env_vars.insert("PLAYER_EVENT", "shuffle_changed".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("REPEAT", repeat.to_string()); + env_vars.insert("REPEAT", context.to_string()); + env_vars.insert("REPEAT_TRACK", track.to_string()); } PlayerEvent::AutoPlayChanged { auto_play } => { env_vars.insert("PLAYER_EVENT", "auto_play_changed".to_string());