From 736faafb4bb135458985428aba175acd643c9078 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sat, 14 Sep 2024 14:27:07 +0200 Subject: [PATCH 001/138] dealer wrapper for eas of use --- core/src/dealer/manager.rs | 87 ++++++++++++++++++++++++++++++++++++++ core/src/session.rs | 9 ++++ 2 files changed, 96 insertions(+) create mode 100644 core/src/dealer/manager.rs diff --git a/core/src/dealer/manager.rs b/core/src/dealer/manager.rs new file mode 100644 index 000000000..279c4cb93 --- /dev/null +++ b/core/src/dealer/manager.rs @@ -0,0 +1,87 @@ +use crate::dealer::{Builder, Dealer, Subscription, WsError}; +use crate::Error; +use std::cell::OnceCell; +use std::str::FromStr; +use thiserror::Error; +use url::Url; + +component! { + DealerManager: DealerManagerInner { + builder: OnceCell = OnceCell::from(Builder::new()), + dealer: OnceCell = OnceCell::new(), + } +} + +#[derive(Error, Debug)] +enum DealerError { + #[error("Builder wasn't available")] + BuilderNotAvailable, + #[error("Websocket couldn't be started because: {0}")] + LaunchFailure(WsError), + #[error("Failed to set dealer")] + CouldNotSetDealer, +} + +impl From for Error { + fn from(err: DealerError) -> Self { + Error::failed_precondition(err) + } +} + +impl DealerManager { + async fn get_url(&self) -> Result { + let session = self.session(); + + let (host, port) = session.apresolver().resolve("dealer").await?; + let token = session + .token_provider() + .get_token("streaming") + .await? + .access_token; + let url = format!("wss://{host}:{port}/?access_token={token}"); + + let url = Url::from_str(&url)?; + Ok(url) + } + + pub fn 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 async fn start(&self) -> Result<(), Error> { + let url = self.get_url().await?; + debug!("Launching dealer at {url}"); + + let get_url = move || { + let url = url.clone(); + async move { url } + }; + + 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/session.rs b/core/src/session.rs index 3944ce83e..24322fecd 100644 --- a/core/src/session.rs +++ b/core/src/session.rs @@ -22,6 +22,7 @@ use thiserror::Error; use tokio::{sync::mpsc, time::Instant}; use tokio_stream::wrappers::UnboundedReceiverStream; +use crate::dealer::manager::DealerManager; use crate::{ apresolve::{ApResolver, SocketAddress}, audio_key::AudioKeyManager, @@ -96,6 +97,7 @@ struct SessionInternal { audio_key: OnceCell, channel: OnceCell, mercury: OnceCell, + dealer: OnceCell, spclient: OnceCell, token_provider: OnceCell, cache: Option>, @@ -136,6 +138,7 @@ impl Session { audio_key: OnceCell::new(), channel: OnceCell::new(), mercury: OnceCell::new(), + dealer: OnceCell::new(), spclient: OnceCell::new(), token_provider: OnceCell::new(), handle: tokio::runtime::Handle::current(), @@ -276,6 +279,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())) } From 10730816c4caf9cd8561089fa7920e57ca9c4365 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sat, 14 Sep 2024 21:09:57 +0200 Subject: [PATCH 002/138] improve sending protobuf requests --- core/src/spclient.rs | 63 ++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/core/src/spclient.rs b/core/src/spclient.rs index 156cf9c82..35bf56e36 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -1,25 +1,3 @@ -use std::{ - env::consts::OS, - fmt::Write, - time::{Duration, Instant}, -}; - -use byteorder::{BigEndian, ByteOrder}; -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 sha1::{Digest, Sha1}; -use sysinfo::System; -use thiserror::Error; - use crate::{ apresolve::SocketAddress, cdn_url::CdnUrl, @@ -38,6 +16,26 @@ use crate::{ version::spotify_semantic_version, Error, FileId, SpotifyId, }; +use byteorder::{BigEndian, ByteOrder}; +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 sha1::{Digest, Sha1}; +use std::{ + env::consts::OS, + fmt::Write, + time::{Duration, Instant}, +}; +use sysinfo::System; +use thiserror::Error; component! { SpClient : SpClientInner { @@ -49,8 +47,8 @@ component! { pub type SpClientResult = Result; -#[allow(clippy::declare_interior_mutable_const)] const CLIENT_TOKEN: HeaderName = HeaderName::from_static("client-token"); +const CONNECTION_ID: HeaderName = HeaderName::from_static("x-spotify-connection-id"); #[derive(Debug, Error)] pub enum SpClientError { @@ -396,7 +394,7 @@ impl SpClient { headers: Option, message: &M, ) -> SpClientResult { - let body = protobuf::text_format::print_to_string(message); + let body = message.write_to_bytes()?; let mut headers = headers.unwrap_or_default(); headers.insert( @@ -418,7 +416,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( @@ -426,7 +425,7 @@ impl SpClient { method: &Method, endpoint: &str, headers: Option, - body: Option<&str>, + body: Option<&[u8]>, ) -> SpClientResult { let mut tries: usize = 0; let mut last_response; @@ -465,7 +464,7 @@ impl SpClient { 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 @@ -527,17 +526,13 @@ 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) + self.request_with_protobuf(&Method::PUT, &endpoint, Some(headers), &state) .await } From dfdfbd57e27ef7af0422e65006a20c73d6cad16a Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sat, 14 Sep 2024 21:53:06 +0200 Subject: [PATCH 003/138] replace connect config with connect_state config --- connect/src/config.rs | 22 ------- connect/src/lib.rs | 2 +- connect/src/spirc.rs | 61 +++++++++----------- connect/src/state.rs | 112 ++++++++++++++++++++++++++++++++++++ core/src/config.rs | 27 +++++++++ core/src/dealer/protocol.rs | 2 +- src/main.rs | 39 +++++++------ 7 files changed, 190 insertions(+), 75 deletions(-) delete mode 100644 connect/src/config.rs create mode 100644 connect/src/state.rs diff --git a/connect/src/config.rs b/connect/src/config.rs deleted file mode 100644 index 278ecf179..000000000 --- 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/lib.rs b/connect/src/lib.rs index 193e5db5e..4a80f37bd 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; pub mod spirc; +pub mod state; diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 200c3f830..86f466e00 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,21 +1,5 @@ -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; - +use crate::state::{ConnectState, ConnectStateConfig}; use crate::{ - config::ConnectConfig, context::PageContext, core::{ authentication::Credentials, mercury::MercurySender, session::UserAttributes, @@ -32,6 +16,20 @@ use crate::{ user_attributes::UserAttributesMutation, }, }; +use futures_util::stream::FusedStream; +use futures_util::{FutureExt, StreamExt}; +use protobuf::Message; +use rand::prelude::SliceRandom; +use std::{ + future::Future, + pin::Pin, + sync::atomic::{AtomicUsize, Ordering}, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; +use thiserror::Error; +use tokio::sync::mpsc; +use tokio_stream::wrappers::UnboundedReceiverStream; #[derive(Debug, Error)] pub enum SpircError { @@ -82,6 +80,8 @@ struct SpircTask { sequence: SeqGenerator, + connect_state: ConnectState, + ident: String, device: DeviceState, state: State, @@ -157,7 +157,6 @@ impl From for 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 pub struct Spirc { @@ -181,7 +180,7 @@ fn int_capability(typ: protocol::spirc::CapabilityType, val: i64) -> protocol::s cap } -fn initial_device_state(config: ConnectConfig) -> DeviceState { +fn initial_device_state(config: ConnectStateConfig) -> DeviceState { let mut msg = DeviceState::new(); msg.set_sw_version(version::SEMVER.to_string()); msg.set_is_active(false); @@ -211,11 +210,7 @@ fn initial_device_state(config: ConnectConfig) -> DeviceState { )); msg.capabilities.push(int_capability( protocol::spirc::CapabilityType::kVolumeSteps, - if config.has_volume_ctrl { - VOLUME_STEPS - } else { - 0 - }, + config.volume_steps.into(), )); msg.capabilities.push(int_capability( protocol::spirc::CapabilityType::kSupportsPlaylistV2, @@ -269,7 +264,7 @@ fn url_encode(bytes: impl AsRef<[u8]>) -> String { impl Spirc { pub async fn new( - config: ConnectConfig, + config: ConnectStateConfig, session: Session, credentials: Credentials, player: Arc, @@ -280,6 +275,8 @@ impl Spirc { let ident = session.device_id().to_owned(); + let connect_state = ConnectState::new(config, &session); + let remote_update = Box::pin( session .mercury() @@ -339,6 +336,7 @@ impl Spirc { // Connect *after* all message listeners are registered session.connect(credentials, true).await?; + session.dealer().start().await?; let canonical_username = &session.username(); debug!("canonical_username: {}", canonical_username); @@ -348,9 +346,7 @@ impl Spirc { let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); - let initial_volume = config.initial_volume; - - let device = initial_device_state(config); + let device = initial_device_state(ConnectStateConfig::default()); let player_events = player.get_player_event_channel(); @@ -360,6 +356,8 @@ impl Spirc { sequence: SeqGenerator::new(1), + connect_state, + ident, device, @@ -385,13 +383,6 @@ impl Spirc { 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()?; diff --git a/connect/src/state.rs b/connect/src/state.rs new file mode 100644 index 000000000..6581df5f1 --- /dev/null +++ b/connect/src/state.rs @@ -0,0 +1,112 @@ +use librespot_core::config::DeviceType; +use librespot_core::{version, Session}; +use librespot_protocol::connect::{Capabilities, DeviceInfo}; +use librespot_protocol::player::{ContextPlayerOptions, PlayOrigin, PlayerState, Suppressions}; +use protobuf::{EnumOrUnknown, MessageField}; +use std::time::{Instant, SystemTime}; + +#[derive(Debug, Clone)] +pub struct ConnectStateConfig { + pub initial_volume: u32, + pub name: String, + pub device_type: DeviceType, + pub zeroconf_enabled: bool, + pub volume_steps: i32, + pub hidden: bool, + pub is_group: bool, +} + +impl Default for ConnectStateConfig { + fn default() -> Self { + Self { + initial_volume: u32::from(u16::MAX) / 2, + name: "librespot".to_string(), + device_type: DeviceType::Speaker, + zeroconf_enabled: false, + volume_steps: 64, + hidden: false, + is_group: false, + } + } +} + +#[derive(Default, Debug, Clone)] +pub struct ConnectState { + pub active: bool, + pub active_since: Option, + + pub has_been_playing_for: Option, + + pub device: DeviceInfo, + pub player: PlayerState, + + pub tracks: Vec<()>, + + pub last_command: Option<(u32, String)>, +} + +impl ConnectState { + pub fn new(cfg: ConnectStateConfig, session: &Session) -> Self { + let mut state = Self { + device: 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(), + client_id: session.client_id(), + spirc_version: "3.2.6".to_string(), + is_group: cfg.is_group, + capabilities: MessageField::some(Capabilities { + volume_steps: cfg.volume_steps, + hidden: cfg.hidden, + gaia_eq_connect_id: true, + can_be_player: true, + + needs_full_player_state: true, + + is_observable: true, + is_controllable: true, + + supports_logout: cfg.zeroconf_enabled, + supported_types: vec!["audio/episode".to_string(), "audio/track".to_string()], + supports_playlist_v2: true, + supports_transfer_command: true, + supports_command_request: true, + supports_gzip_pushes: 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, // TODO: impl + supports_hifi: MessageField::none(), + + command_acks: true, + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }; + state.reset(); + state + } + + fn reset(&mut self) { + self.active = false; + self.active_since = None; + self.player = PlayerState { + is_system_initiated: true, + playback_speed: 1., + play_origin: MessageField::some(PlayOrigin::new()), + suppressions: MessageField::some(Suppressions::new()), + options: MessageField::some(ContextPlayerOptions::new()), + ..Default::default() + } + } +} diff --git a/core/src/config.rs b/core/src/config.rs index 674c5020f..54c8b3fcc 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -1,4 +1,5 @@ use std::{fmt, path::PathBuf, str::FromStr}; +use librespot_protocol::devices::DeviceType as ProtoDeviceType; use url::Url; @@ -132,3 +133,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/dealer/protocol.rs b/core/src/dealer/protocol.rs index 9e62a2e56..f749340b9 100644 --- a/core/src/dealer/protocol.rs +++ b/core/src/dealer/protocol.rs @@ -7,7 +7,7 @@ pub type JsonObject = serde_json::Map; #[derive(Clone, Debug, Deserialize)] pub struct Payload { - pub message_id: i32, + pub message_id: u32, pub sent_by_device_id: String, pub command: JsonObject, } diff --git a/src/main.rs b/src/main.rs index 2735ddf71..2e3412489 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ use thiserror::Error; use url::Url; use librespot::{ - connect::{config::ConnectConfig, spirc::Spirc}, + connect::{spirc::Spirc, state::ConnectStateConfig}, core::{ authentication::Credentials, cache::Cache, config::DeviceType, version, Session, SessionConfig, @@ -207,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, @@ -1288,7 +1288,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()); @@ -1348,14 +1348,12 @@ 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, @@ -1402,14 +1400,23 @@ 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(), + zeroconf_enabled: enable_discovery, + ..Default::default() + } + } else { + ConnectStateConfig { + name, + device_type, + is_group, + zeroconf_enabled: enable_discovery, + ..Default::default() + } } }; From 2ecb8f06a781457710ae678bf4988cf4856047de Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sat, 14 Sep 2024 22:31:22 +0200 Subject: [PATCH 004/138] start integrating dealer into spirc --- connect/Cargo.toml | 1 + connect/src/spirc.rs | 104 +++++++++++++++++++++++++++++++++-------- connect/src/state.rs | 64 ++++++++++++++++++++++++- core/src/dealer/mod.rs | 1 + 4 files changed, 149 insertions(+), 21 deletions(-) diff --git a/connect/Cargo.toml b/connect/Cargo.toml index 9035002e7..83140baf7 100644 --- a/connect/Cargo.toml +++ b/connect/Cargo.toml @@ -19,6 +19,7 @@ serde_json = "1.0" thiserror = "1.0" tokio = { version = "1", features = ["macros", "parking_lot", "sync"] } tokio-stream = "0.1" +base64 = "0.22.1" [dependencies.librespot-core] path = "../core" diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 86f466e00..6917dafc3 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -16,8 +16,7 @@ use crate::{ user_attributes::UserAttributesMutation, }, }; -use futures_util::stream::FusedStream; -use futures_util::{FutureExt, StreamExt}; +use futures_util::{FutureExt, Stream, StreamExt}; use protobuf::Message; use rand::prelude::SliceRandom; use std::{ @@ -27,9 +26,12 @@ use std::{ sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; +use base64::Engine; +use base64::prelude::BASE64_STANDARD; use thiserror::Error; use tokio::sync::mpsc; use tokio_stream::wrappers::UnboundedReceiverStream; +use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason}; #[derive(Debug, Error)] pub enum SpircError { @@ -72,7 +74,7 @@ enum SpircPlayStatus { }, } -type BoxedStream = Pin + Send>>; +type BoxedStream = Pin + Send>>; struct SpircTask { player: Arc, @@ -90,6 +92,7 @@ struct SpircTask { remote_update: BoxedStream>, connection_id_update: BoxedStream>, + connect_state_update: BoxedStream>, user_attributes_update: BoxedStream>, user_attributes_mutation: BoxedStream>, sender: MercurySender, @@ -164,7 +167,7 @@ pub struct Spirc { } fn initial_state() -> State { - let mut frame = protocol::spirc::State::new(); + let mut frame = State::new(); frame.set_repeat(false); frame.set_shuffle(false); frame.set_status(PlayStatus::kPlayStatusStop); @@ -297,19 +300,35 @@ impl Spirc { let connection_id_update = Box::pin( session - .mercury() - .listen_for("hm://pusher/v1/connections/") - .map(UnboundedReceiverStream::new) - .flatten_stream() + .dealer() + .listen_for("hm://pusher/v1/connections/")? .map(|response| -> Result { let connection_id = response - .uri - .strip_prefix("hm://pusher/v1/connections/") + .headers + .get("Spotify-Connection-Id") .ok_or_else(|| SpircError::InvalidUri(response.uri.clone()))?; Ok(connection_id.to_owned()) }), ); + let connect_state_update = Box::pin( + session + .dealer() + .listen_for("hm://connect-state/v1/cluster")? + .map(|response| -> Result { + let json_string = response + .payloads + .first() + .ok_or(SpircError::NoData)? + .to_string(); + // a json string has a leading and trailing quotation mark, we need to remove them + let base64_data = &json_string[1..json_string.len() - 1]; + let data = BASE64_STANDARD.decode(base64_data)?; + + ClusterUpdate::parse_from_bytes(&data).map_err(Error::failed_precondition) + }), + ); + let user_attributes_update = Box::pin( session .mercury() @@ -367,6 +386,7 @@ impl Spirc { remote_update, connection_id_update, + connect_state_update, user_attributes_update, user_attributes_mutation, sender, @@ -443,6 +463,20 @@ impl SpircTask { let commands = self.commands.as_mut(); let player_events = self.player_events.as_mut(); tokio::select! { + cluster_update = self.connect_state_update.next() => match cluster_update { + Some(result) => match result { + Ok(cluster_update) => { + if let Err(e) = self.handle_cluster_update(cluster_update) { + error!("could not dispatch connect state update: {}", e); + } + }, + Err(e) => error!("could not parse connect state update: {}", e), + } + None => { + error!("connect state update selected, but none received"); + break; + } + }, remote_update = self.remote_update.next() => match remote_update { Some(result) => match result { Ok((username, frame)) => { @@ -481,7 +515,7 @@ impl SpircTask { }, 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), + Ok(connection_id) => self.handle_connection_id_update(connection_id).await, Err(e) => error!("could not parse connection ID update: {}", e), } None => { @@ -551,16 +585,17 @@ impl SpircTask { } } + self.session.dealer().close().await; + if self.sender.flush().await.is_err() { warn!("Cannot flush spirc event sender when done."); } } fn now_ms(&mut self) -> i64 { - let dur = match SystemTime::now().duration_since(UNIX_EPOCH) { - Ok(dur) => dur, - Err(err) => err.duration(), - }; + let dur = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|err| err.duration()); dur.as_millis() as i64 + 1000 * self.session.time_delta() } @@ -775,9 +810,25 @@ impl SpircTask { } } - fn handle_connection_id_update(&mut self, connection_id: String) { + async fn handle_connection_id_update(&mut self, connection_id: String) { trace!("Received connection ID update: {:?}", connection_id); self.session.set_connection_id(&connection_id); + + let response = match self.connect_state.update_remote(&self.session, PutStateReason::NEW_DEVICE).await { + Ok(res) => Cluster::parse_from_bytes(&res).ok(), + Err(why) => { + error!("{why:?}"); + None + } + }; + + if let Some(cluster) = response { + debug!( + "successfully put connect state for {} with connection-id {connection_id}", + self.session.device_id() + ); + info!("active device is {:?}", cluster.active_device_id); + } } fn handle_user_attributes_update(&mut self, update: UserAttributesUpdate) { @@ -995,6 +1046,21 @@ impl SpircTask { } } + fn handle_cluster_update(&mut self, cluster_update: ClusterUpdate) -> Result<(), Error> { + let reason = cluster_update.update_reason.enum_value_or_default(); + + let device_ids = cluster_update.devices_that_changed.join(", "); + // the websocket version sends devices not device + let devices = cluster_update.cluster.device.len(); + + let prev_tracks = cluster_update.cluster.player_state.prev_tracks.len(); + let next_tracks = cluster_update.cluster.player_state.next_tracks.len(); + + info!("cluster update! {reason:?} for {device_ids} from {devices}"); + info!("has {prev_tracks:?} previous tracks and {next_tracks} next tracks"); + Ok(()) + } + fn handle_disconnect(&mut self) { self.device.set_is_active(false); self.handle_stop(); @@ -1521,12 +1587,12 @@ impl Drop for SpircTask { struct CommandSender<'a> { spirc: &'a mut SpircTask, - frame: protocol::spirc::Frame, + frame: Frame, } impl<'a> CommandSender<'a> { fn new(spirc: &'a mut SpircTask, cmd: MessageType) -> CommandSender<'_> { - let mut frame = protocol::spirc::Frame::new(); + let mut frame = 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. @@ -1546,7 +1612,7 @@ impl<'a> CommandSender<'a> { } #[allow(dead_code)] - fn state(mut self, state: protocol::spirc::State) -> CommandSender<'a> { + fn state(mut self, state: State) -> CommandSender<'a> { *self.frame.state.mut_or_insert_default() = state; self } diff --git a/connect/src/state.rs b/connect/src/state.rs index 6581df5f1..dfae01366 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -1,9 +1,12 @@ use librespot_core::config::DeviceType; +use librespot_core::spclient::SpClientResult; use librespot_core::{version, Session}; -use librespot_protocol::connect::{Capabilities, DeviceInfo}; +use librespot_protocol::connect::{ + Capabilities, Device, DeviceInfo, MemberType, PutStateReason, PutStateRequest, +}; use librespot_protocol::player::{ContextPlayerOptions, PlayOrigin, PlayerState, Suppressions}; use protobuf::{EnumOrUnknown, MessageField}; -use std::time::{Instant, SystemTime}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; #[derive(Debug, Clone)] pub struct ConnectStateConfig { @@ -109,4 +112,61 @@ impl ConnectState { ..Default::default() } } + + pub async fn update_remote(&self, session: &Session, reason: PutStateReason) -> SpClientResult { + if matches!(reason, PutStateReason::BECAME_INACTIVE) { + todo!("handle became inactive") + } + + let now = SystemTime::now(); + let since_the_epoch = now.duration_since(UNIX_EPOCH).expect("Time went backwards"); + let client_side_timestamp = u64::try_from(since_the_epoch.as_millis())?; + + let member_type = EnumOrUnknown::new(MemberType::CONNECT_STATE); + let put_state_reason = EnumOrUnknown::new(reason); + + let state = self.clone(); + + let is_active = state.active; + let device = MessageField::some(Device { + device_info: MessageField::some(state.device), + player_state: MessageField::some(state.player), + ..Default::default() + }); + + let mut put_state = PutStateRequest { + client_side_timestamp, + member_type, + put_state_reason, + is_active, + device, + ..Default::default() + }; + + if let Some(has_been_playing_for) = state.has_been_playing_for { + match has_been_playing_for.elapsed().as_millis().try_into() { + Ok(ms) => put_state.has_been_playing_for_ms = ms, + Err(why) => warn!("couldn't update has been playing for because {why}"), + } + } + + if let Some(active_since) = state.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) => put_state.started_playing_at = active_since_ms, + Err(why) => warn!("couldn't update active since because {why}"), + } + } + } + + if let Some((message_id, device_id)) = state.last_command { + put_state.last_command_message_id = message_id; + put_state.last_command_sent_by_device_id = device_id; + } + + session + .spclient() + .put_connect_state_request(put_state) + .await + } } diff --git a/core/src/dealer/mod.rs b/core/src/dealer/mod.rs index 8969f317d..fb1d84ce9 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; From c066c298d6165225ace0c184d11a284438e7b3cf Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sun, 15 Sep 2024 14:55:13 +0200 Subject: [PATCH 005/138] payload handling, gzip support --- connect/Cargo.toml | 1 - connect/src/spirc.rs | 16 ++------- core/Cargo.toml | 1 + core/src/dealer/maps.rs | 12 +++++-- core/src/dealer/mod.rs | 35 +++++++++++++----- core/src/dealer/protocol.rs | 72 ++++++++++++++++++++++++++++++++++--- 6 files changed, 106 insertions(+), 31 deletions(-) diff --git a/connect/Cargo.toml b/connect/Cargo.toml index 83140baf7..9035002e7 100644 --- a/connect/Cargo.toml +++ b/connect/Cargo.toml @@ -19,7 +19,6 @@ serde_json = "1.0" thiserror = "1.0" tokio = { version = "1", features = ["macros", "parking_lot", "sync"] } tokio-stream = "0.1" -base64 = "0.22.1" [dependencies.librespot-core] path = "../core" diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 6917dafc3..9bffd79bb 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -17,6 +17,7 @@ use crate::{ }, }; use futures_util::{FutureExt, Stream, StreamExt}; +use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason}; use protobuf::Message; use rand::prelude::SliceRandom; use std::{ @@ -26,12 +27,9 @@ use std::{ sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; -use base64::Engine; -use base64::prelude::BASE64_STANDARD; use thiserror::Error; use tokio::sync::mpsc; use tokio_stream::wrappers::UnboundedReceiverStream; -use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason}; #[derive(Debug, Error)] pub enum SpircError { @@ -316,16 +314,8 @@ impl Spirc { .dealer() .listen_for("hm://connect-state/v1/cluster")? .map(|response| -> Result { - let json_string = response - .payloads - .first() - .ok_or(SpircError::NoData)? - .to_string(); - // a json string has a leading and trailing quotation mark, we need to remove them - let base64_data = &json_string[1..json_string.len() - 1]; - let data = BASE64_STANDARD.decode(base64_data)?; - - ClusterUpdate::parse_from_bytes(&data).map_err(Error::failed_precondition) + ClusterUpdate::parse_from_bytes(&response.payload) + .map_err(Error::failed_precondition) }), ); diff --git a/core/Cargo.toml b/core/Cargo.toml index 2cf7dde80..ee444feae 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -63,6 +63,7 @@ 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" [build-dependencies] rand = "0.8" diff --git a/core/src/dealer/maps.rs b/core/src/dealer/maps.rs index 4f719de76..a11d520d0 100644 --- a/core/src/dealer/maps.rs +++ b/core/src/dealer/maps.rs @@ -115,16 +115,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 fb1d84ce9..4025b7aa7 100644 --- a/core/src/dealer/mod.rs +++ b/core/src/dealer/mod.rs @@ -2,6 +2,9 @@ pub mod manager; mod maps; pub mod protocol; +use futures_core::{Future, Stream}; +use futures_util::{future::join_all, SinkExt, StreamExt}; +use parking_lot::Mutex; use std::{ iter, pin::Pin, @@ -12,10 +15,6 @@ use std::{ task::Poll, time::Duration, }; - -use futures_core::{Future, Stream}; -use futures_util::{future::join_all, SinkExt, StreamExt}; -use parking_lot::Mutex; use thiserror::Error; use tokio::{ select, @@ -304,12 +303,30 @@ struct DealerShared { } impl DealerShared { - fn dispatch_message(&self, msg: Message) { + fn dispatch_message(&self, msg: WebsocketMessage) { + let msg = match msg.handle_payload() { + Ok(data) => Message { + headers: msg.headers, + payload: data, + 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) { @@ -491,7 +508,7 @@ async fn connect( 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 @@ -523,7 +540,7 @@ async fn connect( break; } - debug!("Sent ping"); + trace!("Sent ping"); sleep(PING_TIMEOUT).await; diff --git a/core/src/dealer/protocol.rs b/core/src/dealer/protocol.rs index f749340b9..96dc7cdb3 100644 --- a/core/src/dealer/protocol.rs +++ b/core/src/dealer/protocol.rs @@ -1,6 +1,10 @@ -use std::collections::HashMap; - +use crate::Error; +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use flate2::read::GzDecoder; use serde::Deserialize; +use std::collections::HashMap; +use std::io::Read; pub type JsonValue = serde_json::Value; pub type JsonObject = serde_json::Map; @@ -22,18 +26,76 @@ pub struct Request { } #[derive(Clone, Debug, Deserialize)] -pub struct Message { +pub struct WebsocketMessage { #[serde(default)] pub headers: HashMap, pub method: Option, #[serde(default)] - pub payloads: Vec, + pub payloads: Vec, pub uri: String, } +pub const PAYLOAD_DEFAULT: PayloadValue = PayloadValue::Bytes(Vec::new()); +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub enum PayloadValue { + String(String), + Bytes(Vec), + Others(JsonValue), +} + #[derive(Clone, Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub(super) enum MessageOrRequest { - Message(Message), + Message(WebsocketMessage), Request(Request), } + +#[derive(Clone, Debug)] +pub struct Message { + pub headers: HashMap, + pub payload: Vec, + pub uri: String, +} + +impl WebsocketMessage { + pub fn handle_payload(&self) -> Result, Error> { + let payload = match self.payloads.first() { + None => return Ok(Vec::new()), + Some(p) => p, + }; + + let encoding = self.headers.get("Transfer-Encoding").map(String::as_str); + if let Some(encoding) = encoding { + trace!("message was send with {encoding} encoding "); + } + + match payload { + PayloadValue::String(string) => { + trace!("payload: {string}"); + + let bytes = BASE64_STANDARD + .decode(string) + .map_err(Error::failed_precondition)?; + + if !matches!(encoding, Some("gzip")) { + return Ok(bytes); + } + + let mut gz = GzDecoder::new(&bytes[..]); + 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(Error::failed_precondition(why)), + } + } + PayloadValue::Bytes(bytes) => Ok(bytes.clone()), + PayloadValue::Others(others) => Err(Error::unimplemented(format!( + "Received unknown data from websocket message: {others:?}" + ))), + } + } +} From f8f350c23f8472e35b97d3ccefc8cacac596ba31 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sun, 15 Sep 2024 21:13:02 +0200 Subject: [PATCH 006/138] put connect state consistent --- Cargo.lock | 37 ++++++++++++++++++++++++++++++++++++- connect/src/spirc.rs | 8 +------- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ead3df352..107f4f6a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + [[package]] name = "aes" version = "0.8.4" @@ -200,7 +206,7 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.7.3", "object", "rustc-demangle", "windows-targets 0.52.6", @@ -494,6 +500,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 = "crypto-common" version = "0.1.6" @@ -705,6 +720,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 0.8.0", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1774,6 +1799,7 @@ dependencies = [ "data-encoding", "dns-sd", "env_logger", + "flate2", "form_urlencoded", "futures-core", "futures-util", @@ -1974,6 +2000,15 @@ dependencies = [ "adler2", ] +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + [[package]] name = "mio" version = "1.0.2" diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 9bffd79bb..30d27e975 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -359,7 +359,7 @@ impl Spirc { let player_events = player.get_player_event_channel(); - let mut task = SpircTask { + let task = SpircTask { player, mixer, @@ -395,8 +395,6 @@ impl Spirc { let spirc = Spirc { commands: cmd_tx }; - task.hello()?; - Ok((spirc, task.run())) } @@ -1530,10 +1528,6 @@ impl SpircTask { } } - 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(); From 23107e7fe0193690c86739b1cf2e0b828eb2694f Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sun, 15 Sep 2024 22:54:51 +0200 Subject: [PATCH 007/138] formatting --- connect/src/spirc.rs | 36 ++++++++++++++++++------------- connect/src/state.rs | 3 ++- core/src/config.rs | 2 +- core/src/dealer/manager.rs | 6 ++++-- core/src/dealer/maps.rs | 2 +- core/src/dealer/mod.rs | 8 +++---- core/src/dealer/protocol.rs | 9 ++++---- core/src/spclient.rs | 42 +++++++++++++++++++------------------ rustfmt.toml | 1 + src/main.rs | 22 +++++++++---------- 10 files changed, 71 insertions(+), 60 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 30d27e975..c606fca6b 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,3 +1,19 @@ +use std::{ + future::Future, + pin::Pin, + sync::atomic::{AtomicUsize, Ordering}, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use futures_util::{FutureExt, Stream, StreamExt}; +use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason}; +use protobuf::Message; +use rand::prelude::SliceRandom; +use thiserror::Error; +use tokio::sync::mpsc; +use tokio_stream::wrappers::UnboundedReceiverStream; + use crate::state::{ConnectState, ConnectStateConfig}; use crate::{ context::PageContext, @@ -16,20 +32,6 @@ use crate::{ user_attributes::UserAttributesMutation, }, }; -use futures_util::{FutureExt, Stream, StreamExt}; -use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason}; -use protobuf::Message; -use rand::prelude::SliceRandom; -use std::{ - future::Future, - pin::Pin, - sync::atomic::{AtomicUsize, Ordering}, - sync::Arc, - time::{SystemTime, UNIX_EPOCH}, -}; -use thiserror::Error; -use tokio::sync::mpsc; -use tokio_stream::wrappers::UnboundedReceiverStream; #[derive(Debug, Error)] pub enum SpircError { @@ -802,7 +804,11 @@ impl SpircTask { trace!("Received connection ID update: {:?}", connection_id); self.session.set_connection_id(&connection_id); - let response = match self.connect_state.update_remote(&self.session, PutStateReason::NEW_DEVICE).await { + let response = match self + .connect_state + .update_remote(&self.session, PutStateReason::NEW_DEVICE) + .await + { Ok(res) => Cluster::parse_from_bytes(&res).ok(), Err(why) => { error!("{why:?}"); diff --git a/connect/src/state.rs b/connect/src/state.rs index dfae01366..e2ca6d9ce 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -1,3 +1,5 @@ +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + use librespot_core::config::DeviceType; use librespot_core::spclient::SpClientResult; use librespot_core::{version, Session}; @@ -6,7 +8,6 @@ use librespot_protocol::connect::{ }; use librespot_protocol::player::{ContextPlayerOptions, PlayOrigin, PlayerState, Suppressions}; use protobuf::{EnumOrUnknown, MessageField}; -use std::time::{Instant, SystemTime, UNIX_EPOCH}; #[derive(Debug, Clone)] pub struct ConnectStateConfig { diff --git a/core/src/config.rs b/core/src/config.rs index 54c8b3fcc..31d6a5a6d 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -1,6 +1,6 @@ use std::{fmt, path::PathBuf, str::FromStr}; -use librespot_protocol::devices::DeviceType as ProtoDeviceType; +use librespot_protocol::devices::DeviceType as ProtoDeviceType; use url::Url; pub(crate) const KEYMASTER_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; diff --git a/core/src/dealer/manager.rs b/core/src/dealer/manager.rs index 279c4cb93..8ca9a1494 100644 --- a/core/src/dealer/manager.rs +++ b/core/src/dealer/manager.rs @@ -1,10 +1,12 @@ -use crate::dealer::{Builder, Dealer, Subscription, WsError}; -use crate::Error; use std::cell::OnceCell; use std::str::FromStr; + use thiserror::Error; use url::Url; +use crate::dealer::{Builder, Dealer, Subscription, WsError}; +use crate::Error; + component! { DealerManager: DealerManagerInner { builder: OnceCell = OnceCell::from(Builder::new()), diff --git a/core/src/dealer/maps.rs b/core/src/dealer/maps.rs index a11d520d0..91388a59e 100644 --- a/core/src/dealer/maps.rs +++ b/core/src/dealer/maps.rs @@ -119,7 +119,7 @@ impl SubscriberMap { let mut handled_by_any = false; self.subscribed.retain(|x| { handled_by_any = true; - fun(x) + fun(x) }); if let Some(next) = path.next() { diff --git a/core/src/dealer/mod.rs b/core/src/dealer/mod.rs index 4025b7aa7..60b3e523b 100644 --- a/core/src/dealer/mod.rs +++ b/core/src/dealer/mod.rs @@ -2,9 +2,6 @@ pub mod manager; mod maps; pub mod protocol; -use futures_core::{Future, Stream}; -use futures_util::{future::join_all, SinkExt, StreamExt}; -use parking_lot::Mutex; use std::{ iter, pin::Pin, @@ -15,6 +12,10 @@ use std::{ task::Poll, time::Duration, }; + +use futures_core::{Future, Stream}; +use futures_util::{future::join_all, SinkExt, StreamExt}; +use parking_lot::Mutex; use thiserror::Error; use tokio::{ select, @@ -30,7 +31,6 @@ use url::Url; use self::maps::*; use self::protocol::*; - use crate::{ socket, util::{keep_flushing, CancelOnDrop, TimeoutOnDrop}, diff --git a/core/src/dealer/protocol.rs b/core/src/dealer/protocol.rs index 96dc7cdb3..58b362dae 100644 --- a/core/src/dealer/protocol.rs +++ b/core/src/dealer/protocol.rs @@ -1,10 +1,12 @@ -use crate::Error; +use std::collections::HashMap; +use std::io::Read; + use base64::prelude::BASE64_STANDARD; use base64::Engine; use flate2::read::GzDecoder; use serde::Deserialize; -use std::collections::HashMap; -use std::io::Read; + +use crate::Error; pub type JsonValue = serde_json::Value; pub type JsonObject = serde_json::Map; @@ -35,7 +37,6 @@ pub struct WebsocketMessage { pub uri: String, } -pub const PAYLOAD_DEFAULT: PayloadValue = PayloadValue::Bytes(Vec::new()); #[derive(Clone, Debug, Deserialize)] #[serde(untagged)] pub enum PayloadValue { diff --git a/core/src/spclient.rs b/core/src/spclient.rs index 35bf56e36..5209ad3ae 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -1,3 +1,25 @@ +use std::{ + env::consts::OS, + fmt::Write, + time::{Duration, Instant}, +}; + +use byteorder::{BigEndian, ByteOrder}; +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 sha1::{Digest, Sha1}; +use sysinfo::System; +use thiserror::Error; + use crate::{ apresolve::SocketAddress, cdn_url::CdnUrl, @@ -16,26 +38,6 @@ use crate::{ version::spotify_semantic_version, Error, FileId, SpotifyId, }; -use byteorder::{BigEndian, ByteOrder}; -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 sha1::{Digest, Sha1}; -use std::{ - env::consts::OS, - fmt::Write, - time::{Duration, Instant}, -}; -use sysinfo::System; -use thiserror::Error; component! { SpClient : SpClientInner { diff --git a/rustfmt.toml b/rustfmt.toml index 3a26366d4..dd3bd0d72 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1 +1,2 @@ edition = "2021" +group_imports = "StdExternalCrate" diff --git a/src/main.rs b/src/main.rs index 2e3412489..2dda7fdb6 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,10 +8,11 @@ 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::{spirc::Spirc, state::ConnectStateConfig}, core::{ @@ -32,9 +29,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}; @@ -1352,8 +1351,7 @@ fn get_setup() -> Setup { ); #[cfg(not(feature = "alsa-backend"))] - let default_value = - &connect_default_config.initial_volume.to_string(); + let default_value = &connect_default_config.initial_volume.to_string(); invalid_error_msg( INITIAL_VOLUME, From 68728fe9fc38cb7b31fd4cd4f26e523d8b3ed319 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Fri, 20 Sep 2024 15:08:32 +0200 Subject: [PATCH 008/138] request payload handling, gzip support --- Cargo.lock | 12 +++ core/Cargo.toml | 1 + core/src/dealer/mod.rs | 29 ++++-- core/src/dealer/protocol.rs | 177 +++++++++++++++++++++++++++--------- 4 files changed, 169 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 107f4f6a5..6ed53fc07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1825,6 +1825,7 @@ dependencies = [ "pbkdf2", "priority-queue", "protobuf", + "protobuf-json-mapping", "quick-xml", "rand", "rsa", @@ -2565,6 +2566,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "protobuf-json-mapping" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b07993d14c66dfb74c639dc1b90381773b85cff66ef47bff35bad0778150a3aa" +dependencies = [ + "protobuf", + "protobuf-support", + "thiserror", +] + [[package]] name = "protobuf-parse" version = "3.5.1" diff --git a/core/Cargo.toml b/core/Cargo.toml index ee444feae..4a72f95f2 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -64,6 +64,7 @@ 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.1" [build-dependencies] rand = "0.8" diff --git a/core/src/dealer/mod.rs b/core/src/dealer/mod.rs index 60b3e523b..7f4cdaa55 100644 --- a/core/src/dealer/mod.rs +++ b/core/src/dealer/mod.rs @@ -329,7 +329,22 @@ impl DealerShared { 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, + ) { + debug!("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; + } + }; + debug!("request command: {payload_request:?}"); + // ResponseSender will automatically send "success: false" if it is dropped without an answer. let responder = Responder::new(request.key.clone(), send_tx.clone()); @@ -343,13 +358,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); @@ -502,7 +515,7 @@ 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"); diff --git a/core/src/dealer/protocol.rs b/core/src/dealer/protocol.rs index 58b362dae..0879c2992 100644 --- a/core/src/dealer/protocol.rs +++ b/core/src/dealer/protocol.rs @@ -1,25 +1,41 @@ use std::collections::HashMap; -use std::io::Read; +use std::io::{Error as IoError, Read}; +use crate::Error; use base64::prelude::BASE64_STANDARD; -use base64::Engine; +use base64::{DecodeError, Engine}; use flate2::read::GzDecoder; -use serde::Deserialize; - -use crate::Error; +use librespot_protocol::player::TransferState; +use protobuf::MessageFull; +use serde::{Deserialize, Deserializer}; +use serde_json::Error as SerdeError; +use thiserror::Error; pub type JsonValue = serde_json::Value; -pub type JsonObject = serde_json::Map; + +#[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), +} + +impl From for Error { + fn from(err: ProtocolError) -> Self { + Error::failed_precondition(err) + } +} #[derive(Clone, Debug, Deserialize)] pub struct Payload { - pub message_id: u32, - pub sent_by_device_id: String, - pub command: JsonObject, + pub compressed: String, } #[derive(Clone, Debug, Deserialize)] -pub struct Request { +pub struct WebsocketRequest { #[serde(default)] pub headers: HashMap, pub message_ident: String, @@ -49,7 +65,7 @@ pub enum PayloadValue { #[serde(tag = "type", rename_all = "snake_case")] pub(super) enum MessageOrRequest { Message(WebsocketMessage), - Request(Request), + Request(WebsocketRequest), } #[derive(Clone, Debug)] @@ -59,6 +75,48 @@ pub struct Message { pub uri: String, } +#[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: RequestCommand, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct RequestCommand { + pub endpoint: RequestEndpoint, + #[serde(default, deserialize_with = "deserialize_base64_proto")] + pub data: Option, + pub options: Option, + pub from_device_identifier: String, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RequestEndpoint { + Transfer, + Unknown(String), +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Options { + pub restore_paused: String, + pub restore_position: String, + pub restore_track: String, + pub retain_session: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct LoggingParams { + interaction_ids: Vec, + device_identifier: Option, + command_initiated_time: Option, + page_instance_ids: Option>, +} + impl WebsocketMessage { pub fn handle_payload(&self) -> Result, Error> { let payload = match self.payloads.first() { @@ -66,37 +124,72 @@ impl WebsocketMessage { Some(p) => p, }; - let encoding = self.headers.get("Transfer-Encoding").map(String::as_str); - if let Some(encoding) = encoding { - trace!("message was send with {encoding} encoding "); - } - - match payload { - PayloadValue::String(string) => { - trace!("payload: {string}"); - - let bytes = BASE64_STANDARD - .decode(string) - .map_err(Error::failed_precondition)?; - - if !matches!(encoding, Some("gzip")) { - return Ok(bytes); - } - - let mut gz = GzDecoder::new(&bytes[..]); - 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(Error::failed_precondition(why)), - } + let bytes = match payload { + PayloadValue::String(string) => BASE64_STANDARD + .decode(string) + .map_err(ProtocolError::Base64)?, + PayloadValue::Bytes(bytes) => bytes.clone(), + PayloadValue::Others(others) => { + return Err(Error::unimplemented(format!( + "Received unknown data from websocket message: {others:?}" + ))) } - PayloadValue::Bytes(bytes) => Ok(bytes.clone()), - PayloadValue::Others(others) => Err(Error::unimplemented(format!( - "Received unknown data from websocket message: {others:?}" - ))), - } + }; + + handle_transfer_encoding(&self.headers, bytes) + } +} + +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)?; + debug!("request payload: {payload}"); + + let request = serde_json::from_str(&payload).map_err(ProtocolError::Deserialization)?; + Ok(request) } } + +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()), + } +} + +pub fn deserialize_base64_proto<'de, T, D>(de: D) -> Result, D::Error> +where + T: MessageFull, + D: Deserializer<'de>, +{ + use serde::de::Error; + + let v: String = serde::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) +} From e450428f3f0f0ca1802e7e7853c98e514c63ffd1 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Fri, 20 Sep 2024 18:41:36 +0200 Subject: [PATCH 009/138] expose dealer::protocol, move request in own file --- core/src/dealer/mod.rs | 22 +++++---- core/src/dealer/protocol.rs | 73 ++++------------------------- core/src/dealer/protocol/request.rs | 62 ++++++++++++++++++++++++ core/src/lib.rs | 2 +- 4 files changed, 84 insertions(+), 75 deletions(-) create mode 100644 core/src/dealer/protocol/request.rs diff --git a/core/src/dealer/mod.rs b/core/src/dealer/mod.rs index 7f4cdaa55..b8d809adf 100644 --- a/core/src/dealer/mod.rs +++ b/core/src/dealer/mod.rs @@ -1,4 +1,4 @@ -pub mod manager; +pub(super) mod manager; mod maps; pub mod protocol; @@ -30,7 +30,9 @@ use tungstenite::error::UrlError; use url::Url; use self::maps::*; -use self::protocol::*; +use crate::dealer::protocol::{ + Message, MessageOrRequest, Request, WebsocketMessage, WebsocketRequest, +}; use crate::{ socket, util::{keep_flushing, CancelOnDrop, TimeoutOnDrop}, @@ -48,11 +50,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 +103,7 @@ impl Drop for Responder { } } -pub trait IntoResponse { +trait IntoResponse { fn respond(self, responder: Responder); } @@ -132,7 +134,7 @@ where } } -pub trait RequestHandler: Send + 'static { +trait RequestHandler: Send + 'static { fn handle_request(&self, request: Request, responder: Responder); } @@ -169,7 +171,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 +188,7 @@ impl From for Error { } #[derive(Debug, Clone, Error)] -pub enum SubscriptionError { +enum SubscriptionError { #[error("The specified uri is invalid")] InvalidUri(String), } @@ -225,7 +227,7 @@ fn subscribe( } #[derive(Default)] -pub struct Builder { +struct Builder { message_handlers: SubscriberMap, request_handlers: HandlerMap>, } @@ -386,7 +388,7 @@ impl DealerShared { } } -pub struct Dealer { +struct Dealer { shared: Arc, handle: TimeoutOnDrop<()>, } diff --git a/core/src/dealer/protocol.rs b/core/src/dealer/protocol.rs index 0879c2992..0723a9ca4 100644 --- a/core/src/dealer/protocol.rs +++ b/core/src/dealer/protocol.rs @@ -1,3 +1,7 @@ +pub mod request; + +pub use request::*; + use std::collections::HashMap; use std::io::{Error as IoError, Read}; @@ -5,13 +9,11 @@ use crate::Error; use base64::prelude::BASE64_STANDARD; use base64::{DecodeError, Engine}; use flate2::read::GzDecoder; -use librespot_protocol::player::TransferState; -use protobuf::MessageFull; -use serde::{Deserialize, Deserializer}; +use serde::Deserialize; use serde_json::Error as SerdeError; use thiserror::Error; -pub type JsonValue = serde_json::Value; +type JsonValue = serde_json::Value; #[derive(Debug, Error)] enum ProtocolError { @@ -30,12 +32,12 @@ impl From for Error { } #[derive(Clone, Debug, Deserialize)] -pub struct Payload { +pub(super) struct Payload { pub compressed: String, } #[derive(Clone, Debug, Deserialize)] -pub struct WebsocketRequest { +pub(super) struct WebsocketRequest { #[serde(default)] pub headers: HashMap, pub message_ident: String, @@ -44,7 +46,7 @@ pub struct WebsocketRequest { } #[derive(Clone, Debug, Deserialize)] -pub struct WebsocketMessage { +pub(super) struct WebsocketMessage { #[serde(default)] pub headers: HashMap, pub method: Option, @@ -75,48 +77,6 @@ pub struct Message { pub uri: String, } -#[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: RequestCommand, -} - -#[derive(Clone, Debug, Deserialize)] -pub struct RequestCommand { - pub endpoint: RequestEndpoint, - #[serde(default, deserialize_with = "deserialize_base64_proto")] - pub data: Option, - pub options: Option, - pub from_device_identifier: String, - pub logging_params: LoggingParams, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RequestEndpoint { - Transfer, - Unknown(String), -} - -#[derive(Clone, Debug, Deserialize)] -pub struct Options { - pub restore_paused: String, - pub restore_position: String, - pub restore_track: String, - pub retain_session: String, -} - -#[derive(Clone, Debug, Deserialize)] -pub struct LoggingParams { - interaction_ids: Vec, - device_identifier: Option, - command_initiated_time: Option, - page_instance_ids: Option>, -} - impl WebsocketMessage { pub fn handle_payload(&self) -> Result, Error> { let payload = match self.payloads.first() { @@ -178,18 +138,3 @@ fn handle_transfer_encoding( Err(why) => Err(ProtocolError::GZip(why).into()), } } - -pub fn deserialize_base64_proto<'de, T, D>(de: D) -> Result, D::Error> -where - T: MessageFull, - D: Deserializer<'de>, -{ - use serde::de::Error; - - let v: String = serde::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) -} diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs new file mode 100644 index 000000000..f44ec04ac --- /dev/null +++ b/core/src/dealer/protocol/request.rs @@ -0,0 +1,62 @@ +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use librespot_protocol::player::TransferState; +use protobuf::MessageFull; +use serde::{Deserialize, Deserializer}; + +#[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: RequestCommand, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct RequestCommand { + pub endpoint: RequestEndpoint, + #[serde(default, deserialize_with = "deserialize_base64_proto")] + pub data: Option, + pub options: Option, + pub from_device_identifier: String, + pub logging_params: LoggingParams, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RequestEndpoint { + Transfer, + Unknown(String), +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Options { + pub restore_paused: String, + pub restore_position: String, + pub restore_track: String, + pub retain_session: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct LoggingParams { + interaction_ids: Vec, + device_identifier: Option, + command_initiated_time: Option, + page_instance_ids: Option>, +} + +fn deserialize_base64_proto<'de, T, D>(de: D) -> Result, D::Error> +where + T: MessageFull, + D: Deserializer<'de>, +{ + use serde::de::Error; + + let v: String = serde::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) +} diff --git a/core/src/lib.rs b/core/src/lib.rs index f0ee345cf..4cf10affa 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -16,7 +16,7 @@ pub mod config; mod connection; pub mod date; #[allow(dead_code)] -mod dealer; +pub mod dealer; #[doc(hidden)] pub mod diffie_hellman; pub mod error; From 618473b6e2ae2ff113f5bfb0664eb7019f681d33 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Fri, 20 Sep 2024 19:10:25 +0200 Subject: [PATCH 010/138] integrate handle of connect-state commands --- Cargo.lock | 19 ++---------- connect/src/spirc.rs | 42 +++++++++++++++++++++----- core/src/dealer/manager.rs | 61 +++++++++++++++++++++++++++++++++++++- core/src/dealer/mod.rs | 2 +- 4 files changed, 97 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ed53fc07..74d3ee093 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,12 +17,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" -[[package]] -name = "adler2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" - [[package]] name = "aes" version = "0.8.4" @@ -206,7 +200,7 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide 0.7.3", + "miniz_oxide", "object", "rustc-demangle", "windows-targets 0.52.6", @@ -727,7 +721,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" dependencies = [ "crc32fast", - "miniz_oxide 0.8.0", + "miniz_oxide", ] [[package]] @@ -2001,15 +1995,6 @@ dependencies = [ "adler2", ] -[[package]] -name = "miniz_oxide" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" -dependencies = [ - "adler2", -] - [[package]] name = "mio" version = "1.0.2" diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index c606fca6b..150d1664b 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -6,14 +6,6 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; -use futures_util::{FutureExt, Stream, StreamExt}; -use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason}; -use protobuf::Message; -use rand::prelude::SliceRandom; -use thiserror::Error; -use tokio::sync::mpsc; -use tokio_stream::wrappers::UnboundedReceiverStream; - use crate::state::{ConnectState, ConnectStateConfig}; use crate::{ context::PageContext, @@ -32,6 +24,14 @@ use crate::{ user_attributes::UserAttributesMutation, }, }; +use futures_util::{FutureExt, Stream, StreamExt}; +use librespot_core::dealer::manager::{Reply, RequestReply}; +use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason}; +use protobuf::Message; +use rand::prelude::SliceRandom; +use thiserror::Error; +use tokio::sync::mpsc; +use tokio_stream::wrappers::UnboundedReceiverStream; #[derive(Debug, Error)] pub enum SpircError { @@ -93,6 +93,7 @@ struct SpircTask { remote_update: BoxedStream>, connection_id_update: BoxedStream>, connect_state_update: BoxedStream>, + connect_state_command: BoxedStream, user_attributes_update: BoxedStream>, user_attributes_mutation: BoxedStream>, sender: MercurySender, @@ -321,6 +322,13 @@ impl Spirc { }), ); + let connect_state_command = Box::pin( + session + .dealer() + .handle_for("hm://connect-state/v1/player/command") + .map(UnboundedReceiverStream::new)?, + ); + let user_attributes_update = Box::pin( session .mercury() @@ -379,6 +387,7 @@ impl Spirc { remote_update, connection_id_update, connect_state_update, + connect_state_command, user_attributes_update, user_attributes_mutation, sender, @@ -467,6 +476,15 @@ impl SpircTask { break; } }, + connect_state_command = self.connect_state_command.next() => match connect_state_command { + Some(request) => if let Err(e) = self.handle_connect_state_command(request){ + error!("could handle connect state command: {}", e); + }, + None => { + error!("connect state command selected, but none received"); + break; + } + }, remote_update = self.remote_update.next() => match remote_update { Some(result) => match result { Ok((username, frame)) => { @@ -1055,6 +1073,14 @@ impl SpircTask { Ok(()) } + fn handle_connect_state_command( + &mut self, + (request, sender): RequestReply, + ) -> Result<(), Error> { + debug!("connect state command: {:?}", request.command.endpoint); + sender.send(Reply::Unanswered).map_err(Into::into) + } + fn handle_disconnect(&mut self) { self.device.set_is_active(false); self.handle_stop(); diff --git a/core/src/dealer/manager.rs b/core/src/dealer/manager.rs index 8ca9a1494..d6cf2082b 100644 --- a/core/src/dealer/manager.rs +++ b/core/src/dealer/manager.rs @@ -2,9 +2,12 @@ use std::cell::OnceCell; use std::str::FromStr; use thiserror::Error; +use tokio::sync::mpsc; use url::Url; -use crate::dealer::{Builder, Dealer, Subscription, WsError}; +use crate::dealer::{ + Builder, Dealer, Request, RequestHandler, Responder, Response, Subscription, WsError, +}; use crate::Error; component! { @@ -30,6 +33,47 @@ impl From for Error { } } +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); + 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(&self) -> Result { let session = self.session(); @@ -59,6 +103,21 @@ impl DealerManager { }) } + pub fn 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 async fn start(&self) -> Result<(), Error> { let url = self.get_url().await?; debug!("Launching dealer at {url}"); diff --git a/core/src/dealer/mod.rs b/core/src/dealer/mod.rs index b8d809adf..726144b14 100644 --- a/core/src/dealer/mod.rs +++ b/core/src/dealer/mod.rs @@ -1,4 +1,4 @@ -pub(super) mod manager; +pub mod manager; mod maps; pub mod protocol; From db8f6ee3b5bd82bd3f0e399bd38fb4f1e12e6e52 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sun, 22 Sep 2024 17:48:43 +0200 Subject: [PATCH 011/138] spirc: remove ident field --- connect/src/spirc.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 150d1664b..b07bfcb1f 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -84,7 +84,6 @@ struct SpircTask { connect_state: ConnectState, - ident: String, device: DeviceState, state: State, play_request_id: Option, @@ -277,8 +276,6 @@ impl Spirc { 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( @@ -377,8 +374,6 @@ impl Spirc { connect_state, - ident, - device, state: initial_state(), play_request_id: None, @@ -899,7 +894,7 @@ impl SpircTask { trace!("Received update frame: {:#?}", update); // First see if this update was intended for us. - let device_id = &self.ident; + let device_id = &self.connect_state.device.device_id; let ident = update.ident(); if ident == device_id || (!update.recipient.is_empty() && !update.recipient.contains(device_id)) @@ -1614,7 +1609,7 @@ impl<'a> CommandSender<'a> { // 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_ident(spirc.connect_state.device.device_id.clone()); frame.set_seq_nr(spirc.sequence.get()); frame.set_typ(cmd); *frame.device_state.mut_or_insert_default() = spirc.device.clone(); From c74606de5eb81bf51abaf7efb75639ea655f6ecf Mon Sep 17 00:00:00 2001 From: photovoltex Date: Tue, 24 Sep 2024 20:47:22 +0200 Subject: [PATCH 012/138] transfer playing state better --- connect/src/context.rs | 20 +++ connect/src/spirc.rs | 255 ++++++++++++++++++++++------ connect/src/state.rs | 56 +++++- core/src/dealer/manager.rs | 2 + core/src/dealer/protocol/request.rs | 1 + core/src/error.rs | 6 + core/src/spclient.rs | 13 +- core/src/spotify_id.rs | 8 + 8 files changed, 301 insertions(+), 60 deletions(-) diff --git a/connect/src/context.rs b/connect/src/context.rs index 9428faac3..6c75b0403 100644 --- a/connect/src/context.rs +++ b/connect/src/context.rs @@ -7,6 +7,7 @@ use serde::{ de::{Error, Unexpected}, Deserialize, }; +use librespot_protocol::player::{ContextPage, ContextTrack}; #[derive(Deserialize, Debug, Default, Clone)] pub struct StationContext { @@ -79,6 +80,25 @@ pub struct SubtitleContext { uri: String, } +fn track_ref_to_context_track(track_ref: TrackRef) -> ContextTrack { + ContextTrack { + uri: track_ref.uri.unwrap_or_default(), + gid: track_ref.gid.unwrap_or_default(), + ..Default::default() + } +} + +impl From for ContextPage { + fn from(value: PageContext) -> Self { + Self { + next_page_url: value.next_page_url, + tracks: value.tracks.into_iter().map(track_ref_to_context_track).collect(), + loading: false, + ..Default::default() + } + } +} + fn bool_from_string<'de, D>(de: D) -> Result where D: serde::Deserializer<'de>, diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index b07bfcb1f..65dba64bd 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -26,7 +26,9 @@ use crate::{ }; use futures_util::{FutureExt, Stream, StreamExt}; use librespot_core::dealer::manager::{Reply, RequestReply}; +use librespot_core::dealer::protocol::RequestEndpoint; use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason}; +use librespot_protocol::player::{Context, ContextPage, ProvidedTrack, TransferState}; use protobuf::Message; use rand::prelude::SliceRandom; use thiserror::Error; @@ -56,7 +58,7 @@ impl From for Error { } #[derive(Debug)] -enum SpircPlayStatus { +pub(crate) enum SpircPlayStatus { Stopped, LoadingPlay { position_ms: u32, @@ -103,7 +105,7 @@ struct SpircTask { session: Session, resolve_context: Option, autoplay_context: bool, - context: Option, + context: Option, spirc_id: usize, } @@ -472,7 +474,7 @@ impl SpircTask { } }, connect_state_command = self.connect_state_command.next() => match connect_state_command { - Some(request) => if let Err(e) = self.handle_connect_state_command(request){ + Some(request) => if let Err(e) = self.handle_connect_state_command(request).await { error!("could handle connect state command: {}", e); }, None => { @@ -571,7 +573,7 @@ impl SpircTask { context.tracks.len(), self.state.context_uri(), ); - Some(context) + Some(context.into()) } Err(e) => { error!("Unable to parse JSONContext {:?}", e); @@ -595,7 +597,7 @@ impl SpircTask { } } - fn now_ms(&mut self) -> i64 { + fn now_ms(&self) -> i64 { let dur = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_else(|err| err.duration()); @@ -604,11 +606,13 @@ impl SpircTask { } 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); + self.connect_state.player.position_as_of_timestamp = position_ms.into(); + self.connect_state.player.timestamp = self.now_ms(); } + // 1727262048196 + // 1727262048000 + fn handle_command(&mut self, cmd: SpircCommand) -> Result<(), Error> { if matches!(cmd, SpircCommand::Shutdown) { trace!("Received SpircCommand::Shutdown"); @@ -635,11 +639,11 @@ impl SpircTask { self.notify(None) } SpircCommand::Prev => { - self.handle_prev(); + self.handle_prev()?; self.notify(None) } SpircCommand::Next => { - self.handle_next(); + self.handle_next()?; self.notify(None) } SpircCommand::VolumeUp => { @@ -819,7 +823,7 @@ impl SpircTask { let response = match self .connect_state - .update_remote(&self.session, PutStateReason::NEW_DEVICE) + .update_state(&self.session, PutStateReason::NEW_DEVICE) .await { Ok(res) => Cluster::parse_from_bytes(&res).ok(), @@ -829,12 +833,18 @@ impl SpircTask { } }; - if let Some(cluster) = response { + if let Some(mut cluster) = response { debug!( "successfully put connect state for {} with connection-id {connection_id}", self.session.device_id() ); info!("active device is {:?}", cluster.active_device_id); + + if let Some(player_state) = cluster.player_state.take() { + self.connect_state.player = player_state; + } else { + warn!("couldn't take player state from cluster") + } } } @@ -950,12 +960,12 @@ impl SpircTask { } MessageType::kMessageTypeNext => { - self.handle_next(); + self.handle_next()?; self.notify(None) } MessageType::kMessageTypePrev => { - self.handle_prev(); + self.handle_prev()?; self.notify(None) } @@ -1053,27 +1063,149 @@ impl SpircTask { } } - fn handle_cluster_update(&mut self, cluster_update: ClusterUpdate) -> Result<(), Error> { + fn handle_cluster_update(&mut self, mut cluster_update: ClusterUpdate) -> Result<(), Error> { let reason = cluster_update.update_reason.enum_value_or_default(); let device_ids = cluster_update.devices_that_changed.join(", "); - // the websocket version sends devices not device let devices = cluster_update.cluster.device.len(); let prev_tracks = cluster_update.cluster.player_state.prev_tracks.len(); let next_tracks = cluster_update.cluster.player_state.next_tracks.len(); - info!("cluster update! {reason:?} for {device_ids} from {devices}"); - info!("has {prev_tracks:?} previous tracks and {next_tracks} next tracks"); + info!("cluster update! {reason:?} for {device_ids} from {devices} has {prev_tracks:?} previous tracks and {next_tracks} next tracks"); + + let state = &mut self.connect_state; + + if let Some(cluster) = cluster_update.cluster.as_mut() { + if let Some(player_state) = cluster.player_state.take() { + state.player = player_state; + } + } + Ok(()) } - fn handle_connect_state_command( + async fn handle_connect_state_command( &mut self, (request, sender): RequestReply, ) -> Result<(), Error> { - debug!("connect state command: {:?}", request.command.endpoint); - sender.send(Reply::Unanswered).map_err(Into::into) + self.connect_state.last_command = Some(request.clone()); + + let response = match request.command.endpoint { + RequestEndpoint::Transfer if request.command.data.is_some() => { + self.handle_transfer(request.command.data.expect("by condition checked")) + .await?; + self.connect_state + .update_state(&self.session, PutStateReason::PLAYER_STATE_CHANGED) + .await?; + + Reply::Success + } + RequestEndpoint::Transfer => { + warn!("transfer endpoint didn't contain any data to transfer"); + Reply::Failure + } + RequestEndpoint::Unknown(endpoint) => { + warn!("unhandled command! endpoint '{endpoint}'"); + Reply::Unanswered + } + }; + + sender.send(response).map_err(Into::into) + } + + async fn handle_transfer(&mut self, mut transfer: TransferState) -> Result<(), Error> { + // todo: load ctx tracks + + if let Some(session) = transfer.current_session.as_mut() { + if let Some(ctx) = session.context.as_mut() { + self.resolve_context(ctx).await?; + self.context = ctx.pages.pop(); + } + } + + let timestamp = self.now_ms(); + let state = &mut self.connect_state; + + state.set_active(true); + state.player.is_buffering = false; + + state.player.options = transfer.options; + state.player.is_paused = transfer.playback.is_paused; + state.player.is_playing = !transfer.playback.is_paused; + + if transfer.playback.playback_speed != 0. { + state.player.playback_speed = transfer.playback.playback_speed + } else { + state.player.playback_speed = 1.; + } + + state.player.play_origin = transfer.current_session.play_origin.clone(); + state.player.context_uri = transfer.current_session.context.uri.clone(); + state.player.context_url = transfer.current_session.context.url.clone(); + state.player.context_restrictions = transfer.current_session.context.restrictions.clone(); + state.player.suppressions = transfer.current_session.suppressions.clone(); + + for (key, value) in &transfer.current_session.context.metadata { + state + .player + .context_metadata + .insert(key.clone(), value.clone()); + } + + for (key, value) in &transfer.current_session.context.metadata { + state + .player + .context_metadata + .insert(key.clone(), value.clone()); + } + + if state.player.track.is_none() { + // todo: now we need to resolve this stuff, we can ignore this to some degree for now if we come from an already running context + todo!("resolving player_state required") + } + + // update position if the track continued playing + let position = if transfer.playback.is_paused { + state.player.position_as_of_timestamp + } else { + let time_since_position_update = timestamp - state.player.timestamp; + state.player.position_as_of_timestamp + time_since_position_update + }; + + self.load_track(self.connect_state.player.is_playing, position.try_into()?) + } + + async fn resolve_context(&mut self, ctx: &mut Context) -> Result<(), Error> { + if ctx.uri.starts_with("spotify:local-files") { + return Err(SpircError::UnsupportedLocalPlayBack.into()); + } + + if !ctx.pages.is_empty() { + debug!("context already contains pages to use"); + return Ok(()); + } + + debug!("context didn't had any tracks, resolving tracks..."); + let resolved_ctx = self.session.spclient().get_context(&ctx.uri).await?; + + debug!( + "context was resolved {} pages and {} tracks", + resolved_ctx.pages.len(), + resolved_ctx + .pages + .iter() + .map(|p| p.tracks.len()) + .sum::() + ); + + ctx.pages = resolved_ctx.pages; + ctx.metadata = resolved_ctx.metadata; + ctx.restrictions = resolved_ctx.restrictions; + ctx.loading = resolved_ctx.loading; + ctx.special_fields = resolved_ctx.special_fields; + + Ok(()) } fn handle_disconnect(&mut self) { @@ -1132,7 +1264,7 @@ impl SpircTask { if !self.state.track.is_empty() { let start_playing = state.status() == PlayStatus::kPlayStatusPlay; - self.load_track(start_playing, state.position_ms()); + self.load_track(start_playing, state.position_ms())?; } else { info!("No more tracks left in queue"); self.handle_stop(); @@ -1282,7 +1414,7 @@ impl SpircTask { self.handle_preload_next_track(); } - fn handle_next(&mut self) { + fn handle_next(&mut self) -> Result<(), Error> { 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; @@ -1329,15 +1461,16 @@ impl SpircTask { if tracks_len > 0 { self.state.set_playing_track_index(new_index); - self.load_track(continue_playing, 0); + 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.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) @@ -1371,9 +1504,10 @@ impl SpircTask { self.state.set_playing_track_index(new_index); let start_playing = self.state.status() == PlayStatus::kPlayStatusPlay; - self.load_track(start_playing, 0); + self.load_track(start_playing, 0) } else { self.handle_seek(0); + Ok(()) } } @@ -1388,7 +1522,7 @@ impl SpircTask { } fn handle_end_of_track(&mut self) -> Result<(), Error> { - self.handle_next(); + self.handle_next()?; self.notify(None) } @@ -1408,22 +1542,40 @@ impl SpircTask { if let Some(ref context) = self.context { let new_tracks = &context.tracks; - debug!("Adding {:?} tracks from context to frame", new_tracks.len()); + debug!( + "Adding {:?} tracks from context to next_tracks", + new_tracks.len() + ); - let mut track_vec = self.state.track.clone(); + let mut track_vec = self.connect_state.player.next_tracks.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; + + let new_tracks = new_tracks + .iter() + .map(|track| ProvidedTrack { + uri: track.uri.clone(), + uid: track.uid.clone(), + metadata: track.metadata.clone(), + // todo: correct provider + provider: "autoplay".to_string(), + ..Default::default() + }) + .collect::>(); + + track_vec.extend_from_slice(&new_tracks); + self.connect_state.player.next_tracks = track_vec; // Update playing index if let Some(new_index) = self - .state - .playing_track_index() + .connect_state + .player + .index + .track .checked_sub(CONTEXT_TRACKS_HISTORY as u32) { - self.state.set_playing_track_index(new_index); + self.connect_state.set_playing_track_index(new_index); } } else { warn!("No context to update from!"); @@ -1531,28 +1683,27 @@ impl SpircTask { } } - 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 }; - } - } + fn load_track(&mut self, start_playing: bool, position_ms: u32) -> Result<(), Error> { + let track_to_load = match self.connect_state.player.track.as_ref() { None => { self.handle_stop(); + return Ok(()); } + Some(track) => track, + }; + + let id = SpotifyId::try_from(track_to_load)?; + self.player.load(id, start_playing, position_ms); + + self.update_state_position(position_ms); + if start_playing { + self.play_status = SpircPlayStatus::LoadingPlay { position_ms }; + } else { + self.play_status = SpircPlayStatus::LoadingPause { position_ms }; } + self.connect_state.set_status(&self.play_status); + + Ok(()) } fn notify(&mut self, recipient: Option<&str>) -> Result<(), Error> { diff --git a/connect/src/state.rs b/connect/src/state.rs index e2ca6d9ce..38b13e08d 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -1,5 +1,6 @@ use std::time::{Instant, SystemTime, UNIX_EPOCH}; +use crate::spirc::SpircPlayStatus; use librespot_core::config::DeviceType; use librespot_core::spclient::SpClientResult; use librespot_core::{version, Session}; @@ -8,6 +9,7 @@ use librespot_protocol::connect::{ }; use librespot_protocol::player::{ContextPlayerOptions, PlayOrigin, PlayerState, Suppressions}; use protobuf::{EnumOrUnknown, MessageField}; +use librespot_core::dealer::protocol::Request; #[derive(Debug, Clone)] pub struct ConnectStateConfig { @@ -44,9 +46,7 @@ pub struct ConnectState { pub device: DeviceInfo, pub player: PlayerState, - pub tracks: Vec<()>, - - pub last_command: Option<(u32, String)>, + pub last_command: Option, } impl ConnectState { @@ -114,7 +114,44 @@ impl ConnectState { } } - pub async fn update_remote(&self, session: &Session, reason: PutStateReason) -> SpClientResult { + pub fn set_active(&mut self, value: bool) { + if value { + if self.active { + return; + } + + self.active = true; + self.active_since = Some(SystemTime::now()) + } else { + self.active = false; + self.active_since = None + } + } + + pub fn set_playing_track_index(&mut self, new_index: u32) { + if let Some(index) = self.player.index.as_mut() { + index.track = new_index; + } + } + + pub(crate) fn set_status(&mut self, status: &SpircPlayStatus) { + self.player.is_paused = matches!( + status, + SpircPlayStatus::LoadingPause { .. } + | SpircPlayStatus::Paused { .. } + | SpircPlayStatus::Stopped + ); + self.player.is_buffering = matches!( + status, + SpircPlayStatus::LoadingPause { .. } | SpircPlayStatus::LoadingPlay { .. } + ); + self.player.is_playing = matches!( + status, + SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::Playing { .. } + ); + } + + pub async fn update_state(&self, session: &Session, reason: PutStateReason) -> SpClientResult { if matches!(reason, PutStateReason::BECAME_INACTIVE) { todo!("handle became inactive") } @@ -128,6 +165,11 @@ impl ConnectState { let state = self.clone(); + if state.active && state.player.is_playing { + state.player.position_as_of_timestamp; + state.player.timestamp; + } + let is_active = state.active; let device = MessageField::some(Device { device_info: MessageField::some(state.device), @@ -160,9 +202,9 @@ impl ConnectState { } } - if let Some((message_id, device_id)) = state.last_command { - put_state.last_command_message_id = message_id; - put_state.last_command_sent_by_device_id = device_id; + if let Some(request) = state.last_command { + put_state.last_command_message_id = request.message_id; + put_state.last_command_sent_by_device_id = request.sent_by_device_id; } session diff --git a/core/src/dealer/manager.rs b/core/src/dealer/manager.rs index d6cf2082b..b4a4a6638 100644 --- a/core/src/dealer/manager.rs +++ b/core/src/dealer/manager.rs @@ -33,6 +33,7 @@ impl From for Error { } } +#[derive(Debug)] pub enum Reply { Success, Failure, @@ -64,6 +65,7 @@ impl RequestHandler for DealerRequestHandler { 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 { diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index f44ec04ac..83c9b032c 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -27,6 +27,7 @@ pub struct RequestCommand { #[serde(rename_all = "snake_case")] pub enum RequestEndpoint { Transfer, + #[serde(untagged)] Unknown(String), } diff --git a/core/src/error.rs b/core/src/error.rs index b18ce91a5..6c525342e 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -509,3 +509,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/spclient.rs b/core/src/spclient.rs index 5209ad3ae..a07b8997e 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -19,7 +19,7 @@ use rand::RngCore; use sha1::{Digest, Sha1}; use sysinfo::System; use thiserror::Error; - +use librespot_protocol::player::Context; use crate::{ apresolve::SocketAddress, cdn_url::CdnUrl, @@ -781,4 +781,15 @@ impl SpClient { self.request_url(&url).await } + + pub async fn get_context(&self, uri: &str) -> Result { + // requesting this endpoint with metrics results in a somewhat consistent 502 errors + let uri = format!("/context-resolve/v1/{uri}"); + + let res = self.request(&Method::GET, &uri, None, None).await?; + let ctx_json = String::from_utf8(res.to_vec())?; + let ctx = protobuf_json_mapping::parse_from_str::(&ctx_json)?; + + Ok(ctx) + } } diff --git a/core/src/spotify_id.rs b/core/src/spotify_id.rs index 959b84eeb..2e17e68ed 100644 --- a/core/src/spotify_id.rs +++ b/core/src/spotify_id.rs @@ -436,6 +436,14 @@ impl TryFrom<&protocol::spirc::TrackRef> for SpotifyId { } } +impl TryFrom<&protocol::player::ProvidedTrack> for SpotifyId { + type Error = crate::Error; + + fn try_from(track: &protocol::player::ProvidedTrack) -> Result { + SpotifyId::from_uri(&track.uri) + } +} + impl TryFrom<&protocol::metadata::Album> for SpotifyId { type Error = crate::Error; fn try_from(album: &protocol::metadata::Album) -> Result { From f51d2f9d238fa890fffee9e1b553ea0a572658e7 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Wed, 25 Sep 2024 20:26:06 +0200 Subject: [PATCH 013/138] spirc: remove remote_update stream --- connect/src/spirc.rs | 198 ------------------------------------------- 1 file changed, 198 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 65dba64bd..b4f083ea4 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -91,7 +91,6 @@ struct SpircTask { play_request_id: Option, play_status: SpircPlayStatus, - remote_update: BoxedStream>, connection_id_update: BoxedStream>, connect_state_update: BoxedStream>, connect_state_command: BoxedStream, @@ -280,24 +279,6 @@ impl Spirc { 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 data = response.payload.first().ok_or(SpircError::NoData)?; - Ok((username, Frame::parse_from_bytes(data)?)) - }), - ); - let connection_id_update = Box::pin( session .dealer() @@ -482,22 +463,6 @@ impl SpircTask { break; } }, - 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), @@ -900,169 +865,6 @@ impl SpircTask { } } - fn handle_remote_update(&mut self, update: Frame) -> Result<(), Error> { - trace!("Received update frame: {:#?}", update); - - // First see if this update was intended for us. - let device_id = &self.connect_state.device.device_id; - 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 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(()), - } - } - fn handle_cluster_update(&mut self, mut cluster_update: ClusterUpdate) -> Result<(), Error> { let reason = cluster_update.update_reason.enum_value_or_default(); From b0f8fc49458b78e7bc712ffad9c8ab0e8400d60d Mon Sep 17 00:00:00 2001 From: photovoltex Date: Wed, 25 Sep 2024 20:54:45 +0200 Subject: [PATCH 014/138] spirc: replace command sender with connect state update --- connect/src/spirc.rs | 125 ++++++++++++++----------------------------- 1 file changed, 41 insertions(+), 84 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index b4f083ea4..a502144c2 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -494,12 +494,12 @@ impl SpircTask { } }, 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); } }, @@ -575,13 +575,10 @@ impl SpircTask { self.connect_state.player.timestamp = self.now_ms(); } - // 1727262048196 - // 1727262048000 - - 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()?; + todo!("signal shutdown to spotify"); self.handle_disconnect(); self.shutdown = true; if let Some(rx) = self.commands.as_mut() { @@ -593,7 +590,7 @@ impl SpircTask { match cmd { SpircCommand::Play => { self.handle_play(); - self.notify(None) + self.notify().await } SpircCommand::PlayPause => { self.handle_play_pause(); @@ -601,47 +598,47 @@ impl SpircTask { } SpircCommand::Pause => { self.handle_pause(); - self.notify(None) + self.notify().await } SpircCommand::Prev => { self.handle_prev()?; - self.notify(None) + self.notify().await } SpircCommand::Next => { self.handle_next()?; - self.notify(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.notify().await } SpircCommand::Shuffle(shuffle) => { self.state.set_shuffle(shuffle); - self.notify(None) + self.notify().await } SpircCommand::Repeat(repeat) => { self.state.set_repeat(repeat); - self.notify(None) + 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.into()).await?; + self.notify().await } _ => Ok(()), } @@ -650,7 +647,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); @@ -660,7 +657,7 @@ impl SpircTask { } } - fn handle_player_event(&mut self, event: PlayerEvent) -> Result<(), Error> { + async fn handle_player_event(&mut self, event: PlayerEvent) -> Result<(), Error> { // update play_request_id if let PlayerEvent::PlayRequestIdChanged { play_request_id } = event { self.play_request_id = Some(play_request_id); @@ -673,7 +670,7 @@ 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 } => { @@ -692,7 +689,7 @@ impl SpircTask { trace!("==> kPlayStatusLoading"); } } - self.notify(None) + self.notify().await } PlayerEvent::Playing { position_ms, .. } | PlayerEvent::PositionCorrection { position_ms, .. } @@ -707,7 +704,7 @@ 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.notify().await } else { Ok(()) } @@ -720,7 +717,7 @@ impl SpircTask { nominal_start_time: new_nominal_start_time, preloading_of_next_track_triggered: false, }; - self.notify(None) + self.notify().await } _ => Ok(()), } @@ -738,7 +735,7 @@ impl SpircTask { position_ms: new_position_ms, preloading_of_next_track_triggered: false, }; - self.notify(None) + self.notify().await } SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::LoadingPause { .. } => { @@ -748,7 +745,7 @@ impl SpircTask { position_ms: new_position_ms, preloading_of_next_track_triggered: false, }; - self.notify(None) + self.notify().await } _ => Ok(()), } @@ -760,7 +757,7 @@ impl SpircTask { _ => { self.state.set_status(PlayStatus::kPlayStatusStop); self.play_status = SpircPlayStatus::Stopped; - self.notify(None) + self.notify().await } } } @@ -893,13 +890,16 @@ impl SpircTask { ) -> Result<(), Error> { self.connect_state.last_command = Some(request.clone()); + debug!( + "handling {:?} player command from {}", + request.command.endpoint, request.sent_by_device_id + ); + let response = match request.command.endpoint { RequestEndpoint::Transfer if request.command.data.is_some() => { self.handle_transfer(request.command.data.expect("by condition checked")) .await?; - self.connect_state - .update_state(&self.session, PutStateReason::PLAYER_STATE_CHANGED) - .await?; + self.notify().await?; Reply::Success } @@ -1049,7 +1049,7 @@ impl SpircTask { self.player.emit_repeat_changed_event(self.state.repeat()); } - fn handle_load(&mut self, state: &State) -> Result<(), Error> { + async fn handle_load(&mut self, state: &State) -> Result<(), Error> { if !self.device.is_active() { self.handle_activate(); } @@ -1058,7 +1058,7 @@ impl SpircTask { // completely ignore local playback. if context_uri.starts_with("spotify:local-files") { - self.notify(None)?; + self.notify().await?; return Err(SpircError::UnsupportedLocalPlayBack.into()); } @@ -1323,9 +1323,9 @@ impl SpircTask { self.set_volume(volume); } - fn handle_end_of_track(&mut self) -> Result<(), Error> { + async fn handle_end_of_track(&mut self) -> Result<(), Error> { self.handle_next()?; - self.notify(None) + self.notify().await } fn position(&mut self) -> u32 { @@ -1508,7 +1508,7 @@ impl SpircTask { Ok(()) } - fn notify(&mut self, recipient: Option<&str>) -> Result<(), Error> { + async fn notify(&mut self) -> Result<(), Error> { let status = self.state.status(); // When in loading state, the Spotify UI is disabled for interaction. @@ -1519,12 +1519,10 @@ impl SpircTask { 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); - } - cs.send() + self.connect_state + .update_state(&self.session, PutStateReason::PLAYER_STATE_CHANGED) + .await + .map(|_| ()) } fn set_volume(&mut self, volume: u16) { @@ -1548,44 +1546,3 @@ impl Drop for SpircTask { debug!("drop Spirc[{}]", self.spirc_id); } } - -struct CommandSender<'a> { - spirc: &'a mut SpircTask, - frame: Frame, -} - -impl<'a> CommandSender<'a> { - fn new(spirc: &'a mut SpircTask, cmd: MessageType) -> CommandSender<'_> { - let mut frame = 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.connect_state.device.device_id.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) -> CommandSender<'_> { - self.frame.recipient.push(recipient.to_owned()); - self - } - - #[allow(dead_code)] - fn state(mut self, state: State) -> CommandSender<'a> { - *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()?) - } -} From 2b879f282b7b771a0d10afa6ac613a9e4c4e6794 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Wed, 25 Sep 2024 21:00:51 +0200 Subject: [PATCH 015/138] spirc: remove device state and remaining unused methods --- connect/src/spirc.rs | 199 +++++-------------------------------------- 1 file changed, 23 insertions(+), 176 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index a502144c2..8f6cf0ff8 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -10,17 +10,16 @@ use crate::state::{ConnectState, ConnectStateConfig}; use crate::{ context::PageContext, core::{ - authentication::Credentials, mercury::MercurySender, session::UserAttributes, - util::SeqGenerator, version, Error, Session, SpotifyId, + authentication::Credentials, mercury::MercurySender, session::UserAttributes, Error, + Session, SpotifyId, }, playback::{ mixer::Mixer, player::{Player, PlayerEvent, PlayerEventChannel}, }, protocol::{ - self, explicit_content_pubsub::UserAttributesUpdate, - spirc::{DeviceState, Frame, MessageType, PlayStatus, State, TrackRef}, + spirc::{PlayStatus, State, TrackRef}, user_attributes::UserAttributesMutation, }, }; @@ -30,7 +29,6 @@ use librespot_core::dealer::protocol::RequestEndpoint; use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason}; use librespot_protocol::player::{Context, ContextPage, ProvidedTrack, TransferState}; use protobuf::Message; -use rand::prelude::SliceRandom; use thiserror::Error; use tokio::sync::mpsc; use tokio_stream::wrappers::UnboundedReceiverStream; @@ -82,11 +80,8 @@ struct SpircTask { player: Arc, mixer: Arc, - sequence: SeqGenerator, - connect_state: ConnectState, - device: DeviceState, state: State, play_request_id: Option, play_status: SpircPlayStatus, @@ -177,91 +172,6 @@ fn initial_state() -> State { 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: ConnectStateConfig) -> 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, - config.volume_steps.into(), - )); - 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() } @@ -345,24 +255,18 @@ impl Spirc { let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); - let device = initial_device_state(ConnectStateConfig::default()); - let player_events = player.get_player_event_channel(); let task = SpircTask { player, mixer, - sequence: SeqGenerator::new(1), - connect_state, - device, state: initial_state(), play_request_id: None, play_status: SpircPlayStatus::Stopped, - remote_update, connection_id_update, connect_state_update, connect_state_command, @@ -585,7 +489,7 @@ impl SpircTask { rx.close() } Ok(()) - } else if self.device.is_active() { + } else if self.connect_state.active { trace!("Received SpircCommand::{:?}", cmd); match cmd { SpircCommand::Play => { @@ -594,7 +498,7 @@ impl SpircTask { } SpircCommand::PlayPause => { self.handle_play_pause(); - self.notify(None) + self.notify().await } SpircCommand::Pause => { self.handle_pause(); @@ -917,8 +821,6 @@ impl SpircTask { } async fn handle_transfer(&mut self, mut transfer: TransferState) -> Result<(), Error> { - // todo: load ctx tracks - if let Some(session) = transfer.current_session.as_mut() { if let Some(ctx) = session.context.as_mut() { self.resolve_context(ctx).await?; @@ -955,11 +857,13 @@ impl SpircTask { .insert(key.clone(), value.clone()); } - for (key, value) in &transfer.current_session.context.metadata { - state - .player - .context_metadata - .insert(key.clone(), value.clone()); + if let Some(context) = &self.context { + for (key, value) in &context.metadata { + state + .player + .context_metadata + .insert(key.clone(), value.clone()); + } } if state.player.track.is_none() { @@ -1011,7 +915,7 @@ impl SpircTask { } fn handle_disconnect(&mut self) { - self.device.set_is_active(false); + self.connect_state.set_active(false); self.handle_stop(); self.player @@ -1023,9 +927,7 @@ impl SpircTask { } 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( @@ -1036,7 +938,7 @@ impl SpircTask { ); self.player - .emit_volume_changed_event(self.device.volume() as u16); + .emit_volume_changed_event(self.connect_state.device.volume as u16); self.player .emit_auto_play_changed_event(self.session.autoplay()); @@ -1050,7 +952,7 @@ impl SpircTask { } async fn handle_load(&mut self, state: &State) -> Result<(), Error> { - if !self.device.is_active() { + if !self.connect_state.active { self.handle_activate(); } @@ -1172,8 +1074,8 @@ impl SpircTask { } 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) + let next = self.connect_state.player.next_tracks.iter().next()?; + SpotifyId::try_from(next).ok() } fn handle_preload_next_track(&mut self) { @@ -1314,12 +1216,12 @@ impl SpircTask { } fn handle_volume_up(&mut self) { - let volume = (self.device.volume() as u16).saturating_add(VOLUME_STEP_SIZE); + let volume = (self.connect_state.device.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.volume as u16).saturating_sub(VOLUME_STEP_SIZE); self.set_volume(volume); } @@ -1430,61 +1332,6 @@ impl SpircTask { 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) -> Result<(), Error> { let track_to_load = match self.connect_state.player.track.as_ref() { None => { @@ -1526,15 +1373,15 @@ impl SpircTask { } fn set_volume(&mut self, volume: u16) { - let old_volume = self.device.volume(); + let old_volume = self.connect_state.device.volume; let new_volume = volume as u32; if old_volume != new_volume { - self.device.set_volume(new_volume); + self.connect_state.device.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.active { self.player.emit_volume_changed_event(volume); } } From d487a1e167e4423f3c02ae1477ac861440baf795 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Wed, 25 Sep 2024 21:03:36 +0200 Subject: [PATCH 016/138] spirc: remove mercury sender --- connect/src/spirc.rs | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 8f6cf0ff8..1cacaae69 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -10,7 +10,7 @@ use crate::state::{ConnectState, ConnectStateConfig}; use crate::{ context::PageContext, core::{ - authentication::Credentials, mercury::MercurySender, session::UserAttributes, Error, + authentication::Credentials, session::UserAttributes, Error, Session, SpotifyId, }, playback::{ @@ -91,7 +91,7 @@ struct SpircTask { connect_state_command: BoxedStream, user_attributes_update: BoxedStream>, user_attributes_mutation: BoxedStream>, - sender: MercurySender, + commands: Option>, player_events: Option, @@ -247,12 +247,6 @@ impl Spirc { session.connect(credentials, true).await?; session.dealer().start().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); - let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); let player_events = player.get_player_event_channel(); @@ -272,7 +266,6 @@ impl Spirc { connect_state_command, user_attributes_update, user_attributes_mutation, - sender, commands: Some(cmd_rx), player_events: Some(player_events), @@ -407,10 +400,6 @@ impl SpircTask { 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; - }, 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:") { @@ -460,10 +449,6 @@ impl SpircTask { } self.session.dealer().close().await; - - if self.sender.flush().await.is_err() { - warn!("Cannot flush spirc event sender when done."); - } } fn now_ms(&self) -> i64 { From 4ca0e0ccaed790fdecbff8dda3e1ffbe46aba9ab Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sun, 29 Sep 2024 15:37:52 +0200 Subject: [PATCH 017/138] add repeat track state --- connect/src/spirc.rs | 20 ++++++++++++++++---- connect/src/state.rs | 18 ++++++++++++++++++ playback/src/player.rs | 21 +++++++++++++-------- src/player_event_handler.rs | 5 +++-- 4 files changed, 50 insertions(+), 14 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 1cacaae69..99079e7ee 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -118,6 +118,7 @@ pub enum SpircCommand { Shutdown, Shuffle(bool), Repeat(bool), + RepeatTrack(bool), Disconnect, SetPosition(u32), SetVolume(u16), @@ -132,6 +133,7 @@ pub struct SpircLoadCommand { pub start_playing: bool, pub shuffle: bool, pub repeat: bool, + pub repeat_track: bool, pub playing_track_index: u32, pub tracks: Vec, } @@ -314,6 +316,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))?) } @@ -510,11 +515,15 @@ impl SpircTask { self.notify().await } SpircCommand::Shuffle(shuffle) => { - self.state.set_shuffle(shuffle); + self.connect_state.set_shuffle(shuffle); self.notify().await } SpircCommand::Repeat(repeat) => { - self.state.set_repeat(repeat); + 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) => { @@ -931,9 +940,12 @@ impl SpircTask { self.player .emit_filter_explicit_content_changed_event(self.session.filter_explicit_content()); - self.player.emit_shuffle_changed_event(self.state.shuffle()); + let options = &self.connect_state.player.options; + self.player + .emit_shuffle_changed_event(options.shuffling_context); - self.player.emit_repeat_changed_event(self.state.repeat()); + self.player + .emit_repeat_changed_event(options.repeating_context, options.repeating_track); } async fn handle_load(&mut self, state: &State) -> Result<(), Error> { diff --git a/connect/src/state.rs b/connect/src/state.rs index 38b13e08d..59b04a63b 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -128,6 +128,24 @@ impl ConnectState { } } + pub fn set_repeat_context(&mut self, repeat: bool) { + if let Some(options) = self.player.options.as_mut() { + options.repeating_context = repeat; + } + } + + pub fn set_repeat_track(&mut self, repeat: bool) { + if let Some(options) = self.player.options.as_mut() { + options.repeating_track = repeat; + } + } + + pub fn set_shuffle(&mut self, shuffle: bool) { + if let Some(options) = self.player.options.as_mut() { + options.shuffling_context = shuffle; + } + } + pub fn set_playing_track_index(&mut self, new_index: u32) { if let Some(index) = self.player.index.as_mut() { index.track = new_index; diff --git a/playback/src/player.rs b/playback/src/player.rs index 43f636101..6a4170f00 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/src/player_event_handler.rs b/src/player_event_handler.rs index 3d0a47df5..21cfe01cf 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()); From 5959fb881f5b753e274458dc4adaffc793892e54 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sun, 29 Sep 2024 16:02:29 +0200 Subject: [PATCH 018/138] ConnectState: add methods to replace state in spirc --- connect/src/state.rs | 313 +++++++++++++++++++++++++++++++++++++++++-- core/src/version.rs | 3 + 2 files changed, 303 insertions(+), 13 deletions(-) diff --git a/connect/src/state.rs b/connect/src/state.rs index 59b04a63b..0fbbfed34 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -1,15 +1,51 @@ +use std::hash::{DefaultHasher, Hasher}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use crate::spirc::SpircPlayStatus; use librespot_core::config::DeviceType; +use librespot_core::dealer::protocol::Request; use librespot_core::spclient::SpClientResult; -use librespot_core::{version, Session}; +use librespot_core::{version, Error, Session, SpotifyId}; use librespot_protocol::connect::{ Capabilities, Device, DeviceInfo, MemberType, PutStateReason, PutStateRequest, }; -use librespot_protocol::player::{ContextPlayerOptions, PlayOrigin, PlayerState, Suppressions}; -use protobuf::{EnumOrUnknown, MessageField}; -use librespot_core::dealer::protocol::Request; +use librespot_protocol::player::{ + ContextIndex, ContextPage, ContextPlayerOptions, ContextTrack, PlayOrigin, PlayerState, + ProvidedTrack, Suppressions, +}; +use protobuf::{EnumOrUnknown, Message, MessageField}; +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; + +// provider used by spotify +const CONTEXT_PROVIDER: &str = "context"; +const QUEUE_PROVIDER: &str = "queue"; +// our own provider to flag tracks as a specific states +// todo: we might just need to remove tracks that are unavailable to play, will have to see how the official clients handle this provider +const UNAVAILABLE_PROVIDER: &str = "unavailable"; + +#[derive(Debug, Error)] +pub enum ConnectStateError { + #[error("no next track available")] + NoNextTrack, + #[error("no prev track available")] + NoPrevTrack, + #[error("message field {0} was not available")] + MessageFieldNone(String), + #[error("context is not available")] + NoContext, + #[error("not the first context page")] + NotFirstContextPage, +} + +impl From for Error { + fn from(err: ConnectStateError) -> Self { + Error::failed_precondition(err) + } +} #[derive(Debug, Clone)] pub struct ConnectStateConfig { @@ -18,7 +54,6 @@ pub struct ConnectStateConfig { pub device_type: DeviceType, pub zeroconf_enabled: bool, pub volume_steps: i32, - pub hidden: bool, pub is_group: bool, } @@ -30,7 +65,6 @@ impl Default for ConnectStateConfig { device_type: DeviceType::Speaker, zeroconf_enabled: false, volume_steps: 64, - hidden: false, is_group: false, } } @@ -44,8 +78,20 @@ pub struct ConnectState { pub has_been_playing_for: Option, pub device: DeviceInfo, + + unavailable_uri: Vec, + // is only some when we're playing a queued item and have to preserve the index + player_index: Option, + // index: 0 based, so the first track is index 0 + // prev_track: bottom => top, aka the last track is the prev track + // next_track: top => bottom, aka the first track is the next track pub player: PlayerState, + // todo: still a bit jank, have to overhaul the resolving, especially when transferring playback + // the context from which we play, is used to top up prev and next tracks + // the index is used to keep track which tracks are already loaded into next tracks + pub context: Option<(ContextPage, ContextIndex)>, + pub last_command: Option, } @@ -59,12 +105,12 @@ impl ConnectState { 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(), - spirc_version: "3.2.6".to_string(), is_group: cfg.is_group, capabilities: MessageField::some(Capabilities { volume_steps: cfg.volume_steps, - hidden: cfg.hidden, + hidden: false, gaia_eq_connect_id: true, can_be_player: true, @@ -79,6 +125,8 @@ impl ConnectState { supports_transfer_command: true, supports_command_request: true, supports_gzip_pushes: true, + + // todo: not handled yet supports_set_options_command: true, is_voice_enabled: false, @@ -114,6 +162,18 @@ impl ConnectState { } } + // todo: is there maybe a better way to calculate the hash? + fn new_queue_revision(&self) -> String { + let mut hasher = DefaultHasher::new(); + for track in &self.player.next_tracks { + if let Ok(bytes) = track.write_to_bytes() { + hasher.write(&bytes) + } + } + + hasher.finish().to_string() + } + pub fn set_active(&mut self, value: bool) { if value { if self.active { @@ -150,6 +210,7 @@ impl ConnectState { if let Some(index) = self.player.index.as_mut() { index.track = new_index; } + todo!("remove later") } pub(crate) fn set_status(&mut self, status: &SpircPlayStatus) { @@ -167,6 +228,185 @@ impl ConnectState { status, SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::Playing { .. } ); + + debug!( + "updated connect play status playing: {}, paused: {}, buffering: {}", + self.player.is_playing, self.player.is_paused, self.player.is_buffering + ); + + if let Some(restrictions) = self.player.restrictions.as_mut() { + if self.player.is_playing && !self.player.is_paused { + restrictions.disallow_pausing_reasons.clear(); + restrictions.disallow_resuming_reasons = vec!["not_paused".to_string()] + } + + if self.player.is_paused && !self.player.is_playing { + restrictions.disallow_resuming_reasons.clear(); + restrictions.disallow_pausing_reasons = vec!["not_playing".to_string()] + } + } + } + + pub fn move_to_next_track(&mut self) -> Result { + let old_track = self + .player + .track + .take(); + + if let Some(old_track) = old_track { + // only add songs not from the queue to our previous tracks + if old_track.provider != QUEUE_PROVIDER { + // add old current track to prev tracks, while preserving a length of 10 + if self.player.prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { + self.player.prev_tracks.remove(0); + } + self.player.prev_tracks.push(old_track); + } + } + + if self.player.next_tracks.is_empty() { + return Err(ConnectStateError::NoNextTrack); + } + + let new_track = self.player.next_tracks.remove(0); + + let (ctx, ctx_index) = match self.context.as_mut() { + None => todo!("handle no context available"), + Some(ctx) => ctx, + }; + + ctx_index.track = Self::top_up_list( + &mut self.player.next_tracks, + (&ctx.tracks, &ctx_index.track), + SPOTIFY_MAX_NEXT_TRACKS_SIZE, + false, + ) as u32; + + let is_queued_track = new_track.provider == QUEUE_PROVIDER; + self.player.track = MessageField::some(new_track); + + if is_queued_track { + // the index isn't send when we are a queued track, but we have to preserve it for later + self.player_index = self.player.index.take(); + self.player.index = MessageField::none() + } else if let Some(index) = self.player.index.as_mut() { + index.track += 1; + }; + + // the web-player needs a revision update + self.player.queue_revision = self.new_queue_revision(); + + Ok(self.player.index.track) + } + + pub fn move_to_prev_track( + &mut self, + ) -> Result<&MessageField, ConnectStateError> { + let old_track = self + .player + .track + .take(); + + if let Some(old_track) = old_track { + if old_track.provider != QUEUE_PROVIDER { + self.player.next_tracks.insert(0, old_track); + } + } + + while self.player.next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE { + let _ = self.player.next_tracks.pop(); + } + + let new_track = self + .player + .prev_tracks + .pop() + .ok_or(ConnectStateError::NoPrevTrack)?; + + let (ctx, index) = match self.context.as_mut() { + None => todo!("handle no context available"), + Some(ctx) => ctx, + }; + + index.track = Self::top_up_list( + &mut self.player.next_tracks, + (&ctx.tracks, &index.track), + SPOTIFY_MAX_NEXT_TRACKS_SIZE, + false, + ) as u32; + + self.player.track = MessageField::some(new_track); + let index = self + .player + .index + .as_mut() + .ok_or(ConnectStateError::MessageFieldNone("player.index".to_string()))?; + + index.track -= 1; + + // the web-player needs a revision update + self.player.queue_revision = self.new_queue_revision(); + + Ok(&self.player.track) + } + + pub fn reset_playback_context(&mut self) -> Result<(), Error> { + let (context, context_index) = self.context.as_mut().ok_or(ConnectStateError::NoContext)?; + if context_index.page != 0 { + // todo: hmm, probably needs to resolve the correct context_page + return Err(ConnectStateError::NotFirstContextPage.into()); + } + + if let Some(player_index) = self.player.index.as_mut() { + player_index.track = 0; + } + + let new_track = context.tracks.first().ok_or(ConnectStateError::NoContext)?; + let is_unavailable = self.unavailable_uri.contains(&new_track.uri); + let new_track = Self::context_to_provided_track(new_track, is_unavailable); + + self.player.track = MessageField::some(new_track); + context_index.track = 1; + self.player.prev_tracks.clear(); + self.player.next_tracks.clear(); + + while self.player.next_tracks.len() < SPOTIFY_MAX_NEXT_TRACKS_SIZE { + if let Some(track) = context.tracks.get(context_index.track as usize) { + let is_unavailable = self.unavailable_uri.contains(&track.uri); + self.player + .next_tracks + .push(Self::context_to_provided_track(track, is_unavailable)); + context_index.track += 1; + } else { + break; + } + } + + // the web-player needs a revision update + self.player.queue_revision = self.new_queue_revision(); + + Ok(()) + } + + pub fn update_context(&mut self, context: Option) { + self.context = context.map(|ctx| (ctx, ContextIndex::default())) + } + + pub fn mark_all_as_unavailable(&mut self, id: SpotifyId) { + let id = match id.to_uri() { + Ok(uri) => uri, + Err(_) => return, + }; + + for next_track in &mut self.player.next_tracks { + Self::mark_as_unavailable_for_match(next_track, &id) + } + + for prev_track in &mut self.player.prev_tracks { + Self::mark_as_unavailable_for_match(prev_track, &id) + } + + self.unavailable_uri.push(id); } pub async fn update_state(&self, session: &Session, reason: PutStateReason) -> SpClientResult { @@ -183,11 +423,6 @@ impl ConnectState { let state = self.clone(); - if state.active && state.player.is_playing { - state.player.position_as_of_timestamp; - state.player.timestamp; - } - let is_active = state.active; let device = MessageField::some(Device { device_info: MessageField::some(state.device), @@ -230,4 +465,56 @@ impl ConnectState { .put_connect_state_request(put_state) .await } + + fn mark_as_unavailable_for_match(track: &mut ProvidedTrack, id: &str) { + debug!("Marked <{}:{}> as unavailable", track.provider, track.uri); + if track.uri == id { + track.provider = UNAVAILABLE_PROVIDER.to_string(); + } + } + + fn top_up_list( + list: &mut Vec, + (context, index): (&Vec, &u32), + limit: usize, + add_to_top: bool, + ) -> usize { + let mut new_index = *index as usize; + + while list.len() < limit { + new_index += 1; + + let track = match context.get(new_index) { + None => return new_index - 1, + Some(ct) => Self::context_to_provided_track(ct, false), + }; + + if add_to_top { + list.insert(0, track) + } else { + list.push(track); + } + } + + new_index + } + + pub fn context_to_provided_track( + ctx_track: &ContextTrack, + is_unavailable: bool, + ) -> ProvidedTrack { + let provider = if is_unavailable { + UNAVAILABLE_PROVIDER + } else { + CONTEXT_PROVIDER + }; + + ProvidedTrack { + uri: ctx_track.uri.to_string(), + uid: ctx_track.uid.to_string(), + metadata: ctx_track.metadata.clone(), + provider: provider.to_string(), + ..Default::default() + } + } } diff --git a/core/src/version.rs b/core/src/version.rs index d3870473d..ba5d3f6d4 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)"; From ae738ddba7bc575bcea0b02e6c9ed1f3f2328743 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sun, 29 Sep 2024 16:10:23 +0200 Subject: [PATCH 019/138] spirc: move context into connect_state, update load and next --- connect/src/spirc.rs | 205 ++++++++++++------------------------------- 1 file changed, 57 insertions(+), 148 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 99079e7ee..e701d55a2 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -6,13 +6,10 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; -use crate::state::{ConnectState, ConnectStateConfig}; +use crate::state::{ConnectState, ConnectStateConfig, ConnectStateError}; use crate::{ context::PageContext, - core::{ - authentication::Credentials, session::UserAttributes, Error, - Session, SpotifyId, - }, + core::{authentication::Credentials, session::UserAttributes, Error, Session, SpotifyId}, playback::{ mixer::Mixer, player::{Player, PlayerEvent, PlayerEventChannel}, @@ -27,7 +24,7 @@ use futures_util::{FutureExt, Stream, StreamExt}; use librespot_core::dealer::manager::{Reply, RequestReply}; use librespot_core::dealer::protocol::RequestEndpoint; use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason}; -use librespot_protocol::player::{Context, ContextPage, ProvidedTrack, TransferState}; +use librespot_protocol::player::{Context, TransferState}; use protobuf::Message; use thiserror::Error; use tokio::sync::mpsc; @@ -99,7 +96,6 @@ struct SpircTask { session: Session, resolve_context: Option, autoplay_context: bool, - context: Option, spirc_id: usize, } @@ -155,7 +151,6 @@ impl From for State { } } -const CONTEXT_TRACKS_HISTORY: usize = 10; const CONTEXT_FETCH_THRESHOLD: u32 = 5; const VOLUME_STEP_SIZE: u16 = 1024; // (u16::MAX + 1) / VOLUME_STEPS @@ -276,7 +271,6 @@ impl Spirc { resolve_context: None, autoplay_context: false, - context: None, spirc_id, }; @@ -429,7 +423,7 @@ impl SpircTask { match context { Ok(value) => { - self.context = match serde_json::from_slice::(&value) { + let context = match serde_json::from_slice::(&value) { Ok(context) => { info!( "Resolved {:?} tracks from <{:?}>", @@ -443,6 +437,7 @@ impl SpircTask { None } }; + self.connect_state.update_context(context) }, Err(err) => { error!("ContextError: {:?}", err) @@ -535,7 +530,7 @@ impl SpircTask { self.notify().await } SpircCommand::Load(command) => { - self.handle_load(&command.into()).await?; + self.handle_load(command).await?; self.notify().await } _ => Ok(()), @@ -818,7 +813,7 @@ impl SpircTask { if let Some(session) = transfer.current_session.as_mut() { if let Some(ctx) = session.context.as_mut() { self.resolve_context(ctx).await?; - self.context = ctx.pages.pop(); + self.connect_state.update_context(ctx.pages.pop()) } } @@ -851,7 +846,7 @@ impl SpircTask { .insert(key.clone(), value.clone()); } - if let Some(context) = &self.context { + if let Some((context, _)) = &state.context { for (key, value) in &context.metadata { state .player @@ -948,24 +943,26 @@ impl SpircTask { .emit_repeat_changed_event(options.repeating_context, options.repeating_track); } - async fn handle_load(&mut self, state: &State) -> Result<(), Error> { + async fn handle_load(&mut self, cmd: SpircLoadCommand) -> Result<(), Error> { if !self.connect_state.active { self.handle_activate(); } - let context_uri = state.context_uri().to_owned(); + let mut ctx = Context { + uri: cmd.context_uri, + ..Default::default() + }; - // completely ignore local playback. - if context_uri.starts_with("spotify:local-files") { - self.notify().await?; - return Err(SpircError::UnsupportedLocalPlayBack.into()); - } + self.resolve_context(&mut ctx).await?; + self.connect_state.update_context(ctx.pages.pop()); + self.connect_state.reset_playback_context()?; - self.update_tracks(state); + self.connect_state.set_shuffle(cmd.shuffle); + self.connect_state.set_repeat_context(cmd.repeat); + self.connect_state.set_repeat_track(cmd.repeat_track); - if !self.state.track.is_empty() { - let start_playing = state.status() == PlayStatus::kPlayStatusPlay; - self.load_track(start_playing, state.position_ms())?; + if !self.connect_state.player.next_tracks.is_empty() { + self.load_track(self.connect_state.player.is_playing, 0)?; } else { info!("No more tracks left in queue"); self.handle_stop(); @@ -1100,72 +1097,74 @@ impl SpircTask { // 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, - ); - } + self.connect_state.mark_all_as_unavailable(track_id); self.handle_preload_next_track(); } fn handle_next(&mut self) -> Result<(), Error> { - 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; + let context_uri = self.connect_state.player.context_uri.to_owned(); + let mut continue_playing = self.connect_state.player.is_playing; + + let new_track_index = match self.connect_state.move_to_next_track() { + Ok(index) => Some(index), + Err(ConnectStateError::NoNextTrack) => None, + Err(why) => return Err(why.into()), + }; + + let (ctx, ctx_index) = self + .connect_state + .context + .as_ref() + .ok_or(ConnectStateError::NoContext)?; + let context_length = ctx.tracks.len() as u32; + let context_index = ctx_index.track; let update_tracks = - self.autoplay_context && tracks_len - new_index < CONTEXT_FETCH_THRESHOLD; + self.autoplay_context && context_length - context_index < CONTEXT_FETCH_THRESHOLD; debug!( - "At track {:?} of {:?} <{:?}> update [{}]", - new_index + 1, - tracks_len, + "At context track {:?} of {:?} <{:?}> update [{}]", + context_index + 1, + context_length, 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 let Some((ctx, _)) = self.connect_state.context.as_ref() { + self.resolve_context = Some(ctx.next_page_url.to_owned()); } + todo!("update tracks from context: preloading"); } // When not in autoplay, either start autoplay or loop back to the start - if new_index >= tracks_len { + if matches!(new_track_index, Some(i) if i >= context_length) || new_track_index.is_none() { // 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() { + if self.connect_state.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.resolve_context = Some(context_uri); + todo!("update tracks from context: autoplay"); 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); + self.connect_state.reset_playback_context()?; + continue_playing &= self.connect_state.player.options.repeating_context; + debug!( + "Looping back to start, repeating_context is {}", + continue_playing + ); } } - if tracks_len > 0 { - self.state.set_playing_track_index(new_index); + if context_length > 0 { 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_context()?; self.handle_stop(); Ok(()) } @@ -1239,96 +1238,6 @@ 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 next_tracks", - new_tracks.len() - ); - - let mut track_vec = self.connect_state.player.next_tracks.clone(); - if let Some(head) = track_vec.len().checked_sub(CONTEXT_TRACKS_HISTORY) { - track_vec.drain(0..head); - } - - let new_tracks = new_tracks - .iter() - .map(|track| ProvidedTrack { - uri: track.uri.clone(), - uid: track.uid.clone(), - metadata: track.metadata.clone(), - // todo: correct provider - provider: "autoplay".to_string(), - ..Default::default() - }) - .collect::>(); - - track_vec.extend_from_slice(&new_tracks); - self.connect_state.player.next_tracks = track_vec; - - // Update playing index - if let Some(new_index) = self - .connect_state - .player - .index - .track - .checked_sub(CONTEXT_TRACKS_HISTORY as u32) - { - self.connect_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 - } - fn load_track(&mut self, start_playing: bool, position_ms: u32) -> Result<(), Error> { let track_to_load = match self.connect_state.player.track.as_ref() { None => { From a038e910382a54b078fe3a010007413d7ef20623 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sun, 29 Sep 2024 16:21:32 +0200 Subject: [PATCH 020/138] spirc: remove state, adjust remaining methods --- connect/src/spirc.rs | 114 +++++++------------------------------------ 1 file changed, 17 insertions(+), 97 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index e701d55a2..2d16bf969 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -16,7 +16,6 @@ use crate::{ }, protocol::{ explicit_content_pubsub::UserAttributesUpdate, - spirc::{PlayStatus, State, TrackRef}, user_attributes::UserAttributesMutation, }, }; @@ -79,7 +78,6 @@ struct SpircTask { connect_state: ConnectState, - state: State, play_request_id: Option, play_status: SpircPlayStatus, @@ -131,24 +129,6 @@ pub struct SpircLoadCommand { pub repeat: bool, pub repeat_track: bool, pub playing_track_index: u32, - pub tracks: Vec, -} - -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_FETCH_THRESHOLD: u32 = 5; @@ -159,20 +139,6 @@ pub struct Spirc { commands: mpsc::UnboundedSender, } -fn initial_state() -> State { - let mut frame = 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 url_encode(bytes: impl AsRef<[u8]>) -> String { - form_urlencoded::byte_serialize(bytes.as_ref()).collect() -} - impl Spirc { pub async fn new( config: ConnectStateConfig, @@ -254,7 +220,6 @@ impl Spirc { connect_state, - state: initial_state(), play_request_id: None, play_status: SpircPlayStatus::Stopped, @@ -408,9 +373,13 @@ impl SpircTask { 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 previous_tracks = self + .connect_state + .player.prev_tracks + .iter() + .map(SpotifyId::try_from) + .filter_map(Result::ok) + .collect(); let scope = if self.autoplay_context { "stations" // this returns a `StationContext` but we deserialize it into a `PageContext` @@ -428,7 +397,7 @@ impl SpircTask { info!( "Resolved {:?} tracks from <{:?}>", context.tracks.len(), - self.state.context_uri(), + self.connect_state.player.context_uri, ); Some(context.into()) } @@ -568,16 +537,13 @@ impl SpircTask { match self.play_status { SpircPlayStatus::LoadingPlay { position_ms } => { self.update_state_position(position_ms); - self.state.set_status(PlayStatus::kPlayStatusPlay); trace!("==> kPlayStatusPlay"); } SpircPlayStatus::LoadingPause { position_ms } => { self.update_state_position(position_ms); - self.state.set_status(PlayStatus::kPlayStatusPause); trace!("==> kPlayStatusPause"); } _ => { - self.state.set_status(PlayStatus::kPlayStatusLoading); self.update_state_position(0); trace!("==> kPlayStatusLoading"); } @@ -604,7 +570,6 @@ impl SpircTask { } SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::LoadingPause { .. } => { - self.state.set_status(PlayStatus::kPlayStatusPlay); self.update_state_position(position_ms); self.play_status = SpircPlayStatus::Playing { nominal_start_time: new_nominal_start_time, @@ -622,7 +587,6 @@ 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.play_status = SpircPlayStatus::Paused { position_ms: new_position_ms, @@ -632,7 +596,6 @@ impl SpircTask { } SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::LoadingPause { .. } => { - self.state.set_status(PlayStatus::kPlayStatusPause); self.update_state_position(new_position_ms); self.play_status = SpircPlayStatus::Paused { position_ms: new_position_ms, @@ -648,7 +611,6 @@ impl SpircTask { match self.play_status { SpircPlayStatus::Stopped => Ok(()), _ => { - self.state.set_status(PlayStatus::kPlayStatusStop); self.play_status = SpircPlayStatus::Stopped; self.notify().await } @@ -977,7 +939,6 @@ impl SpircTask { preloading_of_next_track_triggered, } => { self.player.play(); - self.state.set_status(PlayStatus::kPlayStatusPlay); self.update_state_position(position_ms); self.play_status = SpircPlayStatus::Playing { nominal_start_time: self.now_ms() - position_ms as i64, @@ -1016,7 +977,6 @@ 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.play_status = SpircPlayStatus::Paused { @@ -1055,18 +1015,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 { let next = self.connect_state.player.next_tracks.iter().next()?; SpotifyId::try_from(next).ok() @@ -1175,36 +1123,17 @@ impl SpircTask { // 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 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 + let new_track_index = match self.connect_state.move_to_prev_track() { + Ok(index) => Some(index), + Err(ConnectStateError::NoPrevTrack) => None, + Err(why) => return Err(why.into()), }; - // 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); + if new_track_index.is_none() && self.connect_state.player.options.repeating_context { + self.connect_state.reset_playback_context()? + } - let start_playing = self.state.status() == PlayStatus::kPlayStatusPlay; - self.load_track(start_playing, 0) + self.load_track(self.connect_state.player.is_playing, 0) } else { self.handle_seek(0); Ok(()) @@ -1262,16 +1191,7 @@ impl SpircTask { } async fn notify(&mut self) -> 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 { - return Ok(()); - } - + self.connect_state.set_status(&self.play_status); self.connect_state .update_state(&self.session, PutStateReason::PLAYER_STATE_CHANGED) .await From 9684cfa3db2eaa7e574cfd857e366c743f2fa8c9 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sun, 29 Sep 2024 16:39:51 +0200 Subject: [PATCH 021/138] spirc: handle more dealer request commands --- connect/src/spirc.rs | 104 ++++++++++++++++++++----- core/src/dealer/protocol.rs | 8 +- core/src/dealer/protocol/request.rs | 116 ++++++++++++++++++++++++---- 3 files changed, 189 insertions(+), 39 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 2d16bf969..50201b694 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -15,14 +15,13 @@ use crate::{ player::{Player, PlayerEvent, PlayerEventChannel}, }, protocol::{ - explicit_content_pubsub::UserAttributesUpdate, - user_attributes::UserAttributesMutation, + explicit_content_pubsub::UserAttributesUpdate, user_attributes::UserAttributesMutation, }, }; use futures_util::{FutureExt, Stream, StreamExt}; use librespot_core::dealer::manager::{Reply, RequestReply}; -use librespot_core::dealer::protocol::RequestEndpoint; -use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason}; +use librespot_core::dealer::protocol::RequestCommand; +use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason, SetVolumeCommand}; use librespot_protocol::player::{Context, TransferState}; use protobuf::Message; use thiserror::Error; @@ -83,6 +82,7 @@ struct SpircTask { connection_id_update: BoxedStream>, connect_state_update: BoxedStream>, + connect_state_volume_update: BoxedStream>, connect_state_command: BoxedStream, user_attributes_update: BoxedStream>, user_attributes_mutation: BoxedStream>, @@ -150,6 +150,7 @@ impl Spirc { let spirc_id = SPIRC_COUNTER.fetch_add(1, Ordering::AcqRel); debug!("new Spirc[{}]", spirc_id); + let initial_volume = config.initial_volume; let connect_state = ConnectState::new(config, &session); let connection_id_update = Box::pin( @@ -169,8 +170,18 @@ impl Spirc { session .dealer() .listen_for("hm://connect-state/v1/cluster")? - .map(|response| -> Result { - ClusterUpdate::parse_from_bytes(&response.payload) + .map(|msg| -> Result { + ClusterUpdate::parse_from_bytes(&msg.payload) + .map_err(Error::failed_precondition) + }), + ); + + let connect_state_volume_update = Box::pin( + session + .dealer() + .listen_for("hm://connect-state/v1/connect/volume")? + .map(|msg| { + SetVolumeCommand::parse_from_bytes(&msg.payload) .map_err(Error::failed_precondition) }), ); @@ -182,6 +193,7 @@ impl Spirc { .map(UnboundedReceiverStream::new)?, ); + // todo: remove later? probably have to find the equivalent for the dealer let user_attributes_update = Box::pin( session .mercury() @@ -194,6 +206,7 @@ impl Spirc { }), ); + // todo: remove later? probably have to find the equivalent for the dealer let user_attributes_mutation = Box::pin( session .mercury() @@ -214,7 +227,7 @@ impl Spirc { let player_events = player.get_player_event_channel(); - let task = SpircTask { + let mut task = SpircTask { player, mixer, @@ -225,6 +238,7 @@ impl Spirc { connection_id_update, connect_state_update, + connect_state_volume_update, connect_state_command, user_attributes_update, user_attributes_mutation, @@ -241,6 +255,7 @@ impl Spirc { }; let spirc = Spirc { commands: cmd_tx }; + task.set_volume(initial_volume as u16 - 1); Ok((spirc, task.run())) } @@ -315,6 +330,18 @@ impl SpircTask { break; } }, + volume_update = self.connect_state_volume_update.next() => match volume_update { + Some(result) => match result { + Ok(volume_update) => { + self.set_volume(volume_update.volume as u16) + }, + Err(e) => error!("could not parse set volume update request: {}", e), + } + None => { + error!("volume update selected, but none received"); + break; + } + }, connect_state_command = self.connect_state_command.next() => match connect_state_command { Some(request) => if let Err(e) = self.handle_connect_state_command(request).await { error!("could handle connect state command: {}", e); @@ -436,8 +463,7 @@ impl SpircTask { async fn handle_command(&mut self, cmd: SpircCommand) -> Result<(), Error> { if matches!(cmd, SpircCommand::Shutdown) { trace!("Received SpircCommand::Shutdown"); - todo!("signal shutdown to spotify"); - self.handle_disconnect(); + self.handle_disconnect().await?; self.shutdown = true; if let Some(rx) = self.commands.as_mut() { rx.close() @@ -475,7 +501,7 @@ impl SpircTask { self.notify().await } SpircCommand::Disconnect => { - self.handle_disconnect(); + self.handle_disconnect().await?; self.notify().await } SpircCommand::Shuffle(shuffle) => { @@ -746,25 +772,58 @@ impl SpircTask { self.connect_state.last_command = Some(request.clone()); debug!( - "handling {:?} player command from {}", - request.command.endpoint, request.sent_by_device_id + "handling: '{}' from {}", + request.command, request.sent_by_device_id ); - let response = match request.command.endpoint { - RequestEndpoint::Transfer if request.command.data.is_some() => { - self.handle_transfer(request.command.data.expect("by condition checked")) + let response = match request.command { + RequestCommand::Transfer(transfer) if transfer.data.is_some() => { + self.handle_transfer(transfer.data.expect("by condition checked")) .await?; self.notify().await?; Reply::Success } - RequestEndpoint::Transfer => { + RequestCommand::Transfer(_) => { warn!("transfer endpoint didn't contain any data to transfer"); Reply::Failure } - RequestEndpoint::Unknown(endpoint) => { - warn!("unhandled command! endpoint '{endpoint}'"); - Reply::Unanswered + RequestCommand::Play(play) => { + self.handle_load(SpircLoadCommand { + context_uri: play.context.uri, + start_playing: true, + playing_track_index: play.options.skip_to.track_index, + shuffle: false, + repeat: false, + repeat_track: false, + }) + .await?; + self.notify().await.map(|_| Reply::Success)? + } + RequestCommand::Pause(_) => { + self.handle_pause(); + self.notify().await.map(|_| Reply::Success)? + } + RequestCommand::SeekTo(seek_to) => { + self.handle_seek(seek_to.position); + self.notify().await.map(|_| Reply::Success)? + } + RequestCommand::SkipNext(_) => { + self.handle_next()?; + self.notify().await.map(|_| Reply::Success)? + } + RequestCommand::SkipPrev(_) => { + self.handle_prev()?; + self.notify().await.map(|_| Reply::Success)? + } + RequestCommand::Resume(_) => { + self.handle_play(); + self.notify().await.map(|_| Reply::Success)? + } + RequestCommand::Unknown(unknown) => { + warn!("unknown request command: {unknown}"); + // we just don't handle the command, by that we don't lose our connect state + Reply::Success } }; @@ -865,12 +924,15 @@ impl SpircTask { Ok(()) } - fn handle_disconnect(&mut self) { + async fn handle_disconnect(&mut self) -> Result<(), Error> { self.connect_state.set_active(false); + self.notify().await?; self.handle_stop(); self.player .emit_session_disconnected_event(self.session.connection_id(), self.session.username()); + + Ok(()) } fn handle_stop(&mut self) { @@ -1016,7 +1078,7 @@ impl SpircTask { } fn preview_next_track(&mut self) -> Option { - let next = self.connect_state.player.next_tracks.iter().next()?; + let next = self.connect_state.player.next_tracks.first()?; SpotifyId::try_from(next).ok() } diff --git a/core/src/dealer/protocol.rs b/core/src/dealer/protocol.rs index 0723a9ca4..01e9584d8 100644 --- a/core/src/dealer/protocol.rs +++ b/core/src/dealer/protocol.rs @@ -21,7 +21,7 @@ enum ProtocolError { Base64(DecodeError), #[error("gzip decoding failed: {0}")] GZip(IoError), - #[error("Deserialization failed: {0}")] + #[error("deserialization failed: {0}")] Deserialization(SerdeError), } @@ -108,10 +108,10 @@ impl WebsocketRequest { let payload = handle_transfer_encoding(&self.headers, payload_bytes)?; let payload = String::from_utf8(payload)?; - debug!("request payload: {payload}"); - let request = serde_json::from_str(&payload).map_err(ProtocolError::Deserialization)?; - Ok(request) + serde_json::from_str(&payload) + .map_err(ProtocolError::Deserialization) + .map_err(Into::into) } } diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index 83c9b032c..ca6e47118 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -1,8 +1,12 @@ +use crate::dealer::protocol::JsonValue; use base64::prelude::BASE64_STANDARD; use base64::Engine; -use librespot_protocol::player::TransferState; +use librespot_protocol::player::{ + Context, ContextPlayerOptionOverrides, PlayOrigin, TransferState, +}; use protobuf::MessageFull; use serde::{Deserialize, Deserializer}; +use std::fmt::{Display, Formatter}; #[derive(Clone, Debug, Deserialize)] pub struct Request { @@ -14,37 +18,110 @@ pub struct Request { } #[derive(Clone, Debug, Deserialize)] -pub struct RequestCommand { - pub endpoint: RequestEndpoint, +#[serde(tag = "endpoint", rename_all = "snake_case")] +pub enum RequestCommand { + Transfer(TransferCommand), + Play(PlayCommand), + Pause(PauseCommand), + SeekTo(SeekToCommand), + // commands that don't send any context + SkipNext(GenericCommand), + SkipPrev(GenericCommand), + Resume(GenericCommand), + // catch unknown commands, so that we can implement them later + #[serde(untagged)] + Unknown(JsonValue), +} + +impl Display for RequestCommand { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "endpoint: {}", + match self { + RequestCommand::Transfer(_) => "transfer", + RequestCommand::Play(_) => "play", + RequestCommand::Pause(_) => "pause", + RequestCommand::SeekTo(_) => "seek_to", + RequestCommand::SkipNext(_) => "skip_next", + RequestCommand::SkipPrev(_) => "skip_prev", + RequestCommand::Resume(_) => "resume", + RequestCommand::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 = "deserialize_base64_proto")] pub data: Option, - pub options: Option, + pub options: TransferOptions, pub from_device_identifier: String, pub logging_params: LoggingParams, } #[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RequestEndpoint { - Transfer, - #[serde(untagged)] - Unknown(String), +pub struct PlayCommand { + #[serde(deserialize_with = "deserialize_json_proto")] + pub context: Context, + #[serde(deserialize_with = "deserialize_json_proto")] + pub play_origin: PlayOrigin, + pub options: PlayOptions, } #[derive(Clone, Debug, Deserialize)] -pub struct Options { +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 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(deserialize_with = "deserialize_json_proto")] + pub player_option_overrides: ContextPlayerOptionOverrides, + pub license: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SkipTo { + pub track_uid: String, + pub track_uri: String, + pub track_index: u32, +} + #[derive(Clone, Debug, Deserialize)] pub struct LoggingParams { - interaction_ids: Vec, - device_identifier: Option, - command_initiated_time: Option, - page_instance_ids: Option>, + pub interaction_ids: Option>, + pub device_identifier: Option, + pub command_initiated_time: Option, + pub page_instance_ids: Option>, } fn deserialize_base64_proto<'de, T, D>(de: D) -> Result, D::Error> @@ -61,3 +138,14 @@ where T::parse_from_bytes(&bytes).map(Some).map_err(Error::custom) } + +fn deserialize_json_proto<'de, T, D>(de: D) -> Result +where + T: MessageFull, + D: Deserializer<'de>, +{ + use serde::de::Error; + + let v: serde_json::Value = serde::Deserialize::deserialize(de)?; + protobuf_json_mapping::parse_from_str(&v.to_string()).map_err(Error::custom) +} From 959993abcc9b38396f2cac4325bf7b81b1d6f2d4 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sun, 29 Sep 2024 17:09:56 +0200 Subject: [PATCH 022/138] revert rustfmt.toml --- connect/src/context.rs | 8 ++++++-- connect/src/state.rs | 16 ++++++---------- core/src/spclient.rs | 32 ++++++++++++++++---------------- rustfmt.toml | 1 - 4 files changed, 28 insertions(+), 29 deletions(-) diff --git a/connect/src/context.rs b/connect/src/context.rs index 6c75b0403..22e0dfb14 100644 --- a/connect/src/context.rs +++ b/connect/src/context.rs @@ -3,11 +3,11 @@ use crate::core::spotify_id::SpotifyId; use crate::protocol::spirc::TrackRef; +use librespot_protocol::player::{ContextPage, ContextTrack}; use serde::{ de::{Error, Unexpected}, Deserialize, }; -use librespot_protocol::player::{ContextPage, ContextTrack}; #[derive(Deserialize, Debug, Default, Clone)] pub struct StationContext { @@ -92,7 +92,11 @@ impl From for ContextPage { fn from(value: PageContext) -> Self { Self { next_page_url: value.next_page_url, - tracks: value.tracks.into_iter().map(track_ref_to_context_track).collect(), + tracks: value + .tracks + .into_iter() + .map(track_ref_to_context_track) + .collect(), loading: false, ..Default::default() } diff --git a/connect/src/state.rs b/connect/src/state.rs index 0fbbfed34..2e62c64e0 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -248,10 +248,7 @@ impl ConnectState { } pub fn move_to_next_track(&mut self) -> Result { - let old_track = self - .player - .track - .take(); + let old_track = self.player.track.take(); if let Some(old_track) = old_track { // only add songs not from the queue to our previous tracks @@ -261,7 +258,7 @@ impl ConnectState { self.player.prev_tracks.remove(0); } self.player.prev_tracks.push(old_track); - } + } } if self.player.next_tracks.is_empty() { @@ -302,10 +299,7 @@ impl ConnectState { pub fn move_to_prev_track( &mut self, ) -> Result<&MessageField, ConnectStateError> { - let old_track = self - .player - .track - .take(); + let old_track = self.player.track.take(); if let Some(old_track) = old_track { if old_track.provider != QUEUE_PROVIDER { @@ -340,7 +334,9 @@ impl ConnectState { .player .index .as_mut() - .ok_or(ConnectStateError::MessageFieldNone("player.index".to_string()))?; + .ok_or(ConnectStateError::MessageFieldNone( + "player.index".to_string(), + ))?; index.track -= 1; diff --git a/core/src/spclient.rs b/core/src/spclient.rs index a07b8997e..25a7d7bb2 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -4,22 +4,6 @@ use std::{ time::{Duration, Instant}, }; -use byteorder::{BigEndian, ByteOrder}; -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 sha1::{Digest, Sha1}; -use sysinfo::System; -use thiserror::Error; -use librespot_protocol::player::Context; use crate::{ apresolve::SocketAddress, cdn_url::CdnUrl, @@ -38,6 +22,22 @@ use crate::{ version::spotify_semantic_version, Error, FileId, SpotifyId, }; +use byteorder::{BigEndian, ByteOrder}; +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::player::Context; +use protobuf::{Enum, Message, MessageFull}; +use rand::RngCore; +use sha1::{Digest, Sha1}; +use sysinfo::System; +use thiserror::Error; component! { SpClient : SpClientInner { diff --git a/rustfmt.toml b/rustfmt.toml index dd3bd0d72..3a26366d4 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,2 +1 @@ edition = "2021" -group_imports = "StdExternalCrate" From 7d7843f7781554c8c2f203b94b075dd43f691432 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sat, 5 Oct 2024 12:51:18 +0200 Subject: [PATCH 023/138] 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 --- connect/src/spirc.rs | 28 +++-- connect/src/state.rs | 189 +++++++++++++++++----------- core/src/dealer/protocol/request.rs | 8 ++ 3 files changed, 143 insertions(+), 82 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 50201b694..e0c8dbf64 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -333,7 +333,7 @@ impl SpircTask { volume_update = self.connect_state_volume_update.next() => match volume_update { Some(result) => match result { Ok(volume_update) => { - self.set_volume(volume_update.volume as u16) + self.set_volume(volume_update.volume as u16); }, Err(e) => error!("could not parse set volume update request: {}", e), } @@ -505,7 +505,7 @@ impl SpircTask { self.notify().await } SpircCommand::Shuffle(shuffle) => { - self.connect_state.set_shuffle(shuffle); + self.connect_state.set_shuffle(shuffle)?; self.notify().await } SpircCommand::Repeat(repeat) => { @@ -808,6 +808,10 @@ impl SpircTask { self.handle_seek(seek_to.position); self.notify().await.map(|_| Reply::Success)? } + RequestCommand::SetShufflingContext(shuffle) => { + self.connect_state.set_shuffle(shuffle.value)?; + self.notify().await.map(|_| Reply::Success)? + } RequestCommand::SkipNext(_) => { self.handle_next()?; self.notify().await.map(|_| Reply::Success)? @@ -889,6 +893,10 @@ impl SpircTask { state.player.position_as_of_timestamp + time_since_position_update }; + if self.connect_state.player.options.shuffling_context { + self.connect_state.set_shuffle(true)?; + } + self.load_track(self.connect_state.player.is_playing, position.try_into()?) } @@ -979,9 +987,10 @@ impl SpircTask { self.resolve_context(&mut ctx).await?; self.connect_state.update_context(ctx.pages.pop()); - self.connect_state.reset_playback_context()?; + self.connect_state + .reset_playback_context(Some(cmd.playing_track_index as usize))?; - self.connect_state.set_shuffle(cmd.shuffle); + self.connect_state.set_shuffle(cmd.shuffle)?; self.connect_state.set_repeat_context(cmd.repeat); self.connect_state.set_repeat_track(cmd.repeat_track); @@ -1161,7 +1170,12 @@ impl SpircTask { todo!("update tracks from context: autoplay"); self.player.set_auto_normalise_as_album(false); } else { - self.connect_state.reset_playback_context()?; + if self.connect_state.player.options.shuffling_context { + self.connect_state.shuffle()? + } else { + self.connect_state.reset_playback_context(None)?; + } + continue_playing &= self.connect_state.player.options.repeating_context; debug!( "Looping back to start, repeating_context is {}", @@ -1174,7 +1188,7 @@ impl SpircTask { self.load_track(continue_playing, 0) } else { info!("Not playing next track because there are no more tracks left in queue."); - self.connect_state.reset_playback_context()?; + self.connect_state.reset_playback_context(None)?; self.handle_stop(); Ok(()) } @@ -1192,7 +1206,7 @@ impl SpircTask { }; if new_track_index.is_none() && self.connect_state.player.options.repeating_context { - self.connect_state.reset_playback_context()? + self.connect_state.reset_playback_context(None)? } self.load_track(self.connect_state.player.is_playing, 0) diff --git a/connect/src/state.rs b/connect/src/state.rs index 2e62c64e0..3744462ee 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -14,6 +14,7 @@ use librespot_protocol::player::{ ProvidedTrack, Suppressions, }; use protobuf::{EnumOrUnknown, Message, MessageField}; +use rand::prelude::SliceRandom; use thiserror::Error; // these limitations are essential, otherwise to many tracks will overload the web-player @@ -39,6 +40,8 @@ pub enum ConnectStateError { NoContext, #[error("not the first context page")] NotFirstContextPage, + #[error("could not find the new track")] + CanNotFindTrackInContext, } impl From for Error { @@ -91,6 +94,8 @@ pub struct ConnectState { // the context from which we play, is used to top up prev and next tracks // the index is used to keep track which tracks are already loaded into next tracks pub context: Option<(ContextPage, ContextIndex)>, + // a context to keep track of our shuffled context, should be only available when option.shuffling_context is true + pub shuffle_context: Option<(ContextPage, ContextIndex)>, pub last_command: Option, } @@ -126,7 +131,7 @@ impl ConnectState { supports_command_request: true, supports_gzip_pushes: true, - // todo: not handled yet + // todo: not handled yet, repeat missing supports_set_options_command: true, is_voice_enabled: false, @@ -200,17 +205,50 @@ impl ConnectState { } } - pub fn set_shuffle(&mut self, shuffle: bool) { + pub fn set_shuffle(&mut self, shuffle: bool) -> Result<(), Error> { if let Some(options) = self.player.options.as_mut() { options.shuffling_context = shuffle; } + + if !shuffle { + self.shuffle_context = None; + + let (ctx, _) = self.context.as_mut().ok_or(ConnectStateError::NoContext)?; + let new_index = Self::find_index_in_context(ctx, &self.player.track.uri)?; + + self.reset_playback_context(Some(new_index))?; + return Ok(()); + } + + self.shuffle() } - pub fn set_playing_track_index(&mut self, new_index: u32) { - if let Some(index) = self.player.index.as_mut() { - index.track = new_index; + pub fn shuffle(&mut self) -> Result<(), Error> { + let (ctx, _) = self.context.as_mut().ok_or(ConnectStateError::NoContext)?; + + self.player.prev_tracks.clear(); + + // respect queued track and don't throw them out of our next played tracks + let first_non_queued_track = self + .player + .next_tracks + .iter() + .enumerate() + .find(|(_, track)| track.provider != QUEUE_PROVIDER); + if let Some((non_queued_track, _)) = first_non_queued_track { + while self.player.next_tracks.pop().is_some() + && self.player.next_tracks.len() > non_queued_track + {} } - todo!("remove later") + + let mut shuffle_context = ctx.clone(); + let mut rng = rand::thread_rng(); + shuffle_context.tracks.shuffle(&mut rng); + + self.shuffle_context = Some((shuffle_context, ContextIndex::new())); + self.fill_up_next_tracks_from_current_context()?; + + Ok(()) } pub(crate) fn set_status(&mut self, status: &SpircPlayStatus) { @@ -266,32 +304,29 @@ impl ConnectState { } let new_track = self.player.next_tracks.remove(0); - - let (ctx, ctx_index) = match self.context.as_mut() { - None => todo!("handle no context available"), - Some(ctx) => ctx, - }; - - ctx_index.track = Self::top_up_list( - &mut self.player.next_tracks, - (&ctx.tracks, &ctx_index.track), - SPOTIFY_MAX_NEXT_TRACKS_SIZE, - false, - ) as u32; + self.fill_up_next_tracks_from_current_context()?; let is_queued_track = new_track.provider == QUEUE_PROVIDER; - self.player.track = MessageField::some(new_track); - if is_queued_track { // the index isn't send when we are a queued track, but we have to preserve it for later self.player_index = self.player.index.take(); self.player.index = MessageField::none() } else if let Some(index) = self.player.index.as_mut() { - index.track += 1; + if self.player.options.shuffling_context { + let (ctx, _) = self.context.as_ref().ok_or(ConnectStateError::NoContext)?; + let new_index = Self::find_index_in_context(ctx, &new_track.uri); + match new_index { + Err(why) => { + error!("didn't find the shuffled track in the current context: {why}") + } + Ok(new_index) => index.track = new_index as u32, + } + } else { + index.track += 1; + } }; - // the web-player needs a revision update - self.player.queue_revision = self.new_queue_revision(); + self.player.track = MessageField::some(new_track); Ok(self.player.index.track) } @@ -317,17 +352,7 @@ impl ConnectState { .pop() .ok_or(ConnectStateError::NoPrevTrack)?; - let (ctx, index) = match self.context.as_mut() { - None => todo!("handle no context available"), - Some(ctx) => ctx, - }; - - index.track = Self::top_up_list( - &mut self.player.next_tracks, - (&ctx.tracks, &index.track), - SPOTIFY_MAX_NEXT_TRACKS_SIZE, - false, - ) as u32; + self.fill_up_next_tracks_from_current_context()?; self.player.track = MessageField::some(new_track); let index = self @@ -340,46 +365,51 @@ impl ConnectState { index.track -= 1; - // the web-player needs a revision update - self.player.queue_revision = self.new_queue_revision(); - Ok(&self.player.track) } - pub fn reset_playback_context(&mut self) -> Result<(), Error> { + pub fn reset_playback_context(&mut self, new_index: Option) -> Result<(), Error> { let (context, context_index) = self.context.as_mut().ok_or(ConnectStateError::NoContext)?; if context_index.page != 0 { // todo: hmm, probably needs to resolve the correct context_page return Err(ConnectStateError::NotFirstContextPage.into()); } + let new_index = new_index.unwrap_or(0); if let Some(player_index) = self.player.index.as_mut() { - player_index.track = 0; + player_index.track = new_index as u32; } - let new_track = context.tracks.first().ok_or(ConnectStateError::NoContext)?; + let new_track = context + .tracks + .get(new_index) + .ok_or(ConnectStateError::CanNotFindTrackInContext)?; + let is_unavailable = self.unavailable_uri.contains(&new_track.uri); let new_track = Self::context_to_provided_track(new_track, is_unavailable); - self.player.track = MessageField::some(new_track); - context_index.track = 1; + + context_index.track = new_index as u32 + 1; + self.player.prev_tracks.clear(); self.player.next_tracks.clear(); - while self.player.next_tracks.len() < SPOTIFY_MAX_NEXT_TRACKS_SIZE { - if let Some(track) = context.tracks.get(context_index.track as usize) { + if new_index > 0 { + let rev_ctx = context + .tracks + .iter() + .rev() + .skip(context.tracks.len() - new_index) + .take(SPOTIFY_MAX_PREV_TRACKS_SIZE); + for track in rev_ctx { let is_unavailable = self.unavailable_uri.contains(&track.uri); self.player - .next_tracks - .push(Self::context_to_provided_track(track, is_unavailable)); - context_index.track += 1; - } else { - break; + .prev_tracks + .push(Self::context_to_provided_track(track, is_unavailable)) } } - // the web-player needs a revision update - self.player.queue_revision = self.new_queue_revision(); + self.fill_up_next_tracks_from_current_context()?; Ok(()) } @@ -462,37 +492,46 @@ impl ConnectState { .await } - fn mark_as_unavailable_for_match(track: &mut ProvidedTrack, id: &str) { - debug!("Marked <{}:{}> as unavailable", track.provider, track.uri); - if track.uri == id { - track.provider = UNAVAILABLE_PROVIDER.to_string(); - } - } + fn fill_up_next_tracks_from_current_context(&mut self) -> Result<(), ConnectStateError> { + let current_context = if self.player.options.shuffling_context { + self.shuffle_context.as_mut() + } else { + self.context.as_mut() + }; - fn top_up_list( - list: &mut Vec, - (context, index): (&Vec, &u32), - limit: usize, - add_to_top: bool, - ) -> usize { - let mut new_index = *index as usize; + let (ctx, ctx_index) = current_context.ok_or(ConnectStateError::NoContext)?; + let mut new_index = ctx_index.track as usize; - while list.len() < limit { - new_index += 1; - - let track = match context.get(new_index) { - None => return new_index - 1, + while self.player.next_tracks.len() < SPOTIFY_MAX_NEXT_TRACKS_SIZE { + let track = match ctx.tracks.get(new_index + 1) { + None => break, Some(ct) => Self::context_to_provided_track(ct, false), }; - if add_to_top { - list.insert(0, track) - } else { - list.push(track); - } + new_index += 1; + self.player.next_tracks.push(track); } - new_index + ctx_index.track = new_index as u32; + + // the web-player needs a revision update, otherwise the queue isn't updated in the ui + self.player.queue_revision = self.new_queue_revision(); + + Ok(()) + } + + fn find_index_in_context(ctx: &ContextPage, uri: &str) -> Result { + ctx.tracks + .iter() + .position(|track| track.uri == uri) + .ok_or(ConnectStateError::CanNotFindTrackInContext) + } + + fn mark_as_unavailable_for_match(track: &mut ProvidedTrack, id: &str) { + debug!("Marked <{}:{}> as unavailable", track.provider, track.uri); + if track.uri == id { + track.provider = UNAVAILABLE_PROVIDER.to_string(); + } } pub fn context_to_provided_track( diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index ca6e47118..3aeb4e012 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -24,6 +24,7 @@ pub enum RequestCommand { Play(PlayCommand), Pause(PauseCommand), SeekTo(SeekToCommand), + SetShufflingContext(SetShufflingCommand), // commands that don't send any context SkipNext(GenericCommand), SkipPrev(GenericCommand), @@ -43,6 +44,7 @@ impl Display for RequestCommand { RequestCommand::Play(_) => "play", RequestCommand::Pause(_) => "pause", RequestCommand::SeekTo(_) => "seek_to", + RequestCommand::SetShufflingContext(_) => "set_shuffling_context", RequestCommand::SkipNext(_) => "skip_next", RequestCommand::SkipPrev(_) => "skip_prev", RequestCommand::Resume(_) => "resume", @@ -88,6 +90,12 @@ pub struct SeekToCommand { pub logging_params: LoggingParams, } +#[derive(Clone, Debug, Deserialize)] +pub struct SetShufflingCommand { + pub value: bool, + pub logging_params: LoggingParams, +} + #[derive(Clone, Debug, Deserialize)] pub struct GenericCommand { pub logging_params: LoggingParams, From 4cdd30e79120dd58bbfbb65de8db81a474a60fc5 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sat, 5 Oct 2024 15:24:44 +0200 Subject: [PATCH 024/138] spirc: handle device became inactive --- connect/src/spirc.rs | 21 ++++++++++++++++++--- connect/src/state.rs | 28 ++++++++++++++-------------- core/src/spclient.rs | 10 ++++++++++ 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index e0c8dbf64..2f38ee6e4 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -319,7 +319,7 @@ impl SpircTask { cluster_update = self.connect_state_update.next() => match cluster_update { Some(result) => match result { Ok(cluster_update) => { - if let Err(e) = self.handle_cluster_update(cluster_update) { + if let Err(e) = self.handle_cluster_update(cluster_update).await { error!("could not dispatch connect state update: {}", e); } }, @@ -743,7 +743,10 @@ impl SpircTask { } } - fn handle_cluster_update(&mut self, mut cluster_update: ClusterUpdate) -> Result<(), Error> { + async fn handle_cluster_update( + &mut self, + mut cluster_update: ClusterUpdate, + ) -> Result<(), Error> { let reason = cluster_update.update_reason.enum_value_or_default(); let device_ids = cluster_update.devices_that_changed.join(", "); @@ -756,10 +759,22 @@ impl SpircTask { let state = &mut self.connect_state; - if let Some(cluster) = cluster_update.cluster.as_mut() { + if let Some(mut cluster) = cluster_update.cluster.take() { if let Some(player_state) = cluster.player_state.take() { state.player = player_state; } + + let became_inactive = + self.connect_state.active && cluster.active_device_id != self.session.device_id(); + if became_inactive { + info!("device became inactive"); + self.handle_stop(); + self.connect_state.reset(); + let _ = self + .connect_state + .update_state(&self.session, PutStateReason::BECAME_INACTIVE) + .await?; + } } Ok(()) diff --git a/connect/src/state.rs b/connect/src/state.rs index 3744462ee..bc0fc87da 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -154,19 +154,6 @@ impl ConnectState { state } - fn reset(&mut self) { - self.active = false; - self.active_since = None; - self.player = PlayerState { - is_system_initiated: true, - playback_speed: 1., - play_origin: MessageField::some(PlayOrigin::new()), - suppressions: MessageField::some(Suppressions::new()), - options: MessageField::some(ContextPlayerOptions::new()), - ..Default::default() - } - } - // todo: is there maybe a better way to calculate the hash? fn new_queue_revision(&self) -> String { let mut hasher = DefaultHasher::new(); @@ -179,6 +166,19 @@ impl ConnectState { hasher.finish().to_string() } + pub fn reset(&mut self) { + self.active = false; + self.active_since = None; + self.player = PlayerState { + is_system_initiated: true, + playback_speed: 1., + play_origin: MessageField::some(PlayOrigin::new()), + suppressions: MessageField::some(Suppressions::new()), + options: MessageField::some(ContextPlayerOptions::new()), + ..Default::default() + } + } + pub fn set_active(&mut self, value: bool) { if value { if self.active { @@ -437,7 +437,7 @@ impl ConnectState { pub async fn update_state(&self, session: &Session, reason: PutStateReason) -> SpClientResult { if matches!(reason, PutStateReason::BECAME_INACTIVE) { - todo!("handle became inactive") + return session.spclient().put_connect_state_inactive(false).await; } let now = SystemTime::now(); diff --git a/core/src/spclient.rs b/core/src/spclient.rs index 25a7d7bb2..3801eae41 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -538,6 +538,16 @@ impl SpClient { .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 From a51c83f3b72932add9bd33f9df13f63e1ae75680 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sat, 5 Oct 2024 15:27:00 +0200 Subject: [PATCH 025/138] dealer: adjust payload handling --- connect/src/spirc.rs | 20 +++++--- core/src/dealer/mod.rs | 6 +-- core/src/dealer/protocol.rs | 76 +++++++++++++++++++++++------ core/src/dealer/protocol/request.rs | 36 ++------------ 4 files changed, 81 insertions(+), 57 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 2f38ee6e4..98e686695 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -20,7 +20,7 @@ use crate::{ }; use futures_util::{FutureExt, Stream, StreamExt}; use librespot_core::dealer::manager::{Reply, RequestReply}; -use librespot_core::dealer::protocol::RequestCommand; +use librespot_core::dealer::protocol::{PayloadValue, RequestCommand}; use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason, SetVolumeCommand}; use librespot_protocol::player::{Context, TransferState}; use protobuf::Message; @@ -32,6 +32,8 @@ use tokio_stream::wrappers::UnboundedReceiverStream; pub enum SpircError { #[error("response payload empty")] NoData, + #[error("received unexpected data {0:#?}")] + UnexpectedData(PayloadValue), #[error("playback of local files is not supported")] UnsupportedLocalPlayBack, #[error("message addressed at another ident: {0}")] @@ -44,7 +46,7 @@ impl From for Error { fn from(err: SpircError) -> Self { use SpircError::*; match err { - NoData | UnsupportedLocalPlayBack => Error::unavailable(err), + NoData | UnsupportedLocalPlayBack | UnexpectedData(_) => Error::unavailable(err), Ident(_) | InvalidUri(_) => Error::aborted(err), } } @@ -171,8 +173,11 @@ impl Spirc { .dealer() .listen_for("hm://connect-state/v1/cluster")? .map(|msg| -> Result { - ClusterUpdate::parse_from_bytes(&msg.payload) - .map_err(Error::failed_precondition) + match msg.payload { + PayloadValue::Raw(bytes) => ClusterUpdate::parse_from_bytes(&bytes) + .map_err(Error::failed_precondition), + other => Err(SpircError::UnexpectedData(other).into()), + } }), ); @@ -180,9 +185,10 @@ impl Spirc { session .dealer() .listen_for("hm://connect-state/v1/connect/volume")? - .map(|msg| { - SetVolumeCommand::parse_from_bytes(&msg.payload) - .map_err(Error::failed_precondition) + .map(|msg| match msg.payload { + PayloadValue::Raw(bytes) => SetVolumeCommand::parse_from_bytes(&bytes) + .map_err(Error::failed_precondition), + other => Err(SpircError::UnexpectedData(other).into()), }), ); diff --git a/core/src/dealer/mod.rs b/core/src/dealer/mod.rs index 726144b14..8dc0ecb3b 100644 --- a/core/src/dealer/mod.rs +++ b/core/src/dealer/mod.rs @@ -305,11 +305,11 @@ struct DealerShared { } impl DealerShared { - fn dispatch_message(&self, msg: WebsocketMessage) { + fn dispatch_message(&self, mut msg: WebsocketMessage) { let msg = match msg.handle_payload() { - Ok(data) => Message { + Ok(value) => Message { headers: msg.headers, - payload: data, + payload: value, uri: msg.uri, }, Err(why) => { diff --git a/core/src/dealer/protocol.rs b/core/src/dealer/protocol.rs index 01e9584d8..e0b3902c9 100644 --- a/core/src/dealer/protocol.rs +++ b/core/src/dealer/protocol.rs @@ -9,7 +9,8 @@ use crate::Error; use base64::prelude::BASE64_STANDARD; use base64::{DecodeError, Engine}; use flate2::read::GzDecoder; -use serde::Deserialize; +use protobuf::MessageFull; +use serde::{Deserialize, Deserializer}; use serde_json::Error as SerdeError; use thiserror::Error; @@ -23,6 +24,10 @@ enum ProtocolError { GZip(IoError), #[error("deserialization failed: {0}")] Deserialization(SerdeError), + #[error("payload had more then one value. had {0} values")] + MoreThenOneValue(usize), + #[error("payload was empty")] + Empty, } impl From for Error { @@ -51,16 +56,16 @@ pub(super) struct WebsocketMessage { 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 PayloadValue { +pub enum MessagePayloadValue { String(String), Bytes(Vec), - Others(JsonValue), + Json(JsonValue), } #[derive(Clone, Debug, Deserialize)] @@ -70,33 +75,41 @@ pub(super) enum MessageOrRequest { Request(WebsocketRequest), } +#[derive(Clone, Debug)] +pub enum PayloadValue { + Empty, + Raw(Vec), +} + #[derive(Clone, Debug)] pub struct Message { pub headers: HashMap, - pub payload: Vec, + pub payload: PayloadValue, pub uri: String, } impl WebsocketMessage { - pub fn handle_payload(&self) -> Result, Error> { - let payload = match self.payloads.first() { - None => return Ok(Vec::new()), - Some(p) => p, - }; - + 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 { - PayloadValue::String(string) => BASE64_STANDARD + MessagePayloadValue::String(string) => BASE64_STANDARD .decode(string) .map_err(ProtocolError::Base64)?, - PayloadValue::Bytes(bytes) => bytes.clone(), - PayloadValue::Others(others) => { + MessagePayloadValue::Bytes(bytes) => bytes, + MessagePayloadValue::Json(json) => { return Err(Error::unimplemented(format!( - "Received unknown data from websocket message: {others:?}" + "Received unknown data from websocket message: {json:?}" ))) } }; - handle_transfer_encoding(&self.headers, bytes) + handle_transfer_encoding(&self.headers, bytes).map(PayloadValue::Raw) } } @@ -108,6 +121,7 @@ impl WebsocketRequest { let payload = handle_transfer_encoding(&self.headers, payload_bytes)?; let payload = String::from_utf8(payload)?; + debug!("request: {payload}"); serde_json::from_str(&payload) .map_err(ProtocolError::Deserialization) @@ -138,3 +152,33 @@ fn handle_transfer_encoding( Err(why) => Err(ProtocolError::GZip(why).into()), } } + +fn deserialize_base64_proto<'de, T, D>(de: D) -> Result, D::Error> +where + T: MessageFull, + D: Deserializer<'de>, +{ + use serde::de::Error; + + let v: String = serde::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) +} + +fn deserialize_json_proto<'de, T, D>(de: D) -> Result +where + T: MessageFull, + D: Deserializer<'de>, +{ + use serde::de::Error; + + let v: serde_json::Value = serde::Deserialize::deserialize(de)?; + protobuf_json_mapping::parse_from_str(&v.to_string()).map_err(|why| { + warn!("deserialize_json_proto: {v}"); + error!("deserialize_json_proto: {why}"); + Error::custom(why) + }) +} diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index 3aeb4e012..61fc56dd5 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -1,13 +1,12 @@ use crate::dealer::protocol::JsonValue; -use base64::prelude::BASE64_STANDARD; -use base64::Engine; use librespot_protocol::player::{ Context, ContextPlayerOptionOverrides, PlayOrigin, TransferState, }; -use protobuf::MessageFull; -use serde::{Deserialize, Deserializer}; +use serde::Deserialize; use std::fmt::{Display, Formatter}; +use super::{deserialize_base64_proto, deserialize_json_proto}; + #[derive(Clone, Debug, Deserialize)] pub struct Request { pub message_id: u32, @@ -21,7 +20,7 @@ pub struct Request { #[serde(tag = "endpoint", rename_all = "snake_case")] pub enum RequestCommand { Transfer(TransferCommand), - Play(PlayCommand), + Play(Box), Pause(PauseCommand), SeekTo(SeekToCommand), SetShufflingContext(SetShufflingCommand), @@ -75,6 +74,7 @@ pub struct PlayCommand { #[serde(deserialize_with = "deserialize_json_proto")] pub play_origin: PlayOrigin, pub options: PlayOptions, + pub logging_params: LoggingParams, } #[derive(Clone, Debug, Deserialize)] @@ -131,29 +131,3 @@ pub struct LoggingParams { pub command_initiated_time: Option, pub page_instance_ids: Option>, } - -fn deserialize_base64_proto<'de, T, D>(de: D) -> Result, D::Error> -where - T: MessageFull, - D: Deserializer<'de>, -{ - use serde::de::Error; - - let v: String = serde::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) -} - -fn deserialize_json_proto<'de, T, D>(de: D) -> Result -where - T: MessageFull, - D: Deserializer<'de>, -{ - use serde::de::Error; - - let v: serde_json::Value = serde::Deserialize::deserialize(de)?; - protobuf_json_mapping::parse_from_str(&v.to_string()).map_err(Error::custom) -} From 9b802455a18569e840bb580e9e8e638a96518f37 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sat, 5 Oct 2024 15:46:37 +0200 Subject: [PATCH 026/138] spirc: better set volume handling --- connect/src/spirc.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 98e686695..275e64aa5 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -338,9 +338,7 @@ impl SpircTask { }, volume_update = self.connect_state_volume_update.next() => match volume_update { Some(result) => match result { - Ok(volume_update) => { - self.set_volume(volume_update.volume as u16); - }, + Ok(volume_update) => self.handle_set_volume(volume_update).await, Err(e) => error!("could not parse set volume update request: {}", e), } None => { @@ -786,6 +784,19 @@ impl SpircTask { Ok(()) } + async fn handle_set_volume(&mut self, set_volume_command: SetVolumeCommand) { + + let volume_difference = set_volume_command.volume - self.connect_state.device.volume as i32; + if volume_difference < self.connect_state.device.capabilities.volume_steps { + return; + } + + self.set_volume(set_volume_command.volume as u16); + if let Err(why) = self.notify().await { + error!("couldn't notify after updating the volume: {why}") + } + } + async fn handle_connect_state_command( &mut self, (request, sender): RequestReply, From 7dd51596285d51faff0199dbac41cf14e5e8d4ed Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sat, 5 Oct 2024 17:24:17 +0200 Subject: [PATCH 027/138] dealer: box PlayCommand (clippy warning) --- core/src/dealer/protocol.rs | 30 +++++++++++++++++++++++++++-- core/src/dealer/protocol/request.rs | 9 +++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/core/src/dealer/protocol.rs b/core/src/dealer/protocol.rs index e0b3902c9..668877811 100644 --- a/core/src/dealer/protocol.rs +++ b/core/src/dealer/protocol.rs @@ -160,7 +160,7 @@ where { use serde::de::Error; - let v: String = serde::Deserialize::deserialize(de)?; + let v: String = Deserialize::deserialize(de)?; let bytes = BASE64_STANDARD .decode(v) .map_err(|e| Error::custom(e.to_string()))?; @@ -175,10 +175,36 @@ where { use serde::de::Error; - let v: serde_json::Value = serde::Deserialize::deserialize(de)?; + let v: serde_json::Value = Deserialize::deserialize(de)?; protobuf_json_mapping::parse_from_str(&v.to_string()).map_err(|why| { warn!("deserialize_json_proto: {v}"); error!("deserialize_json_proto: {why}"); Error::custom(why) }) } + +fn deserialize_option_json_proto<'de, T, D>(de: D) -> Result, D::Error> +where + T: MessageFull, + D: Deserializer<'de>, +{ + use serde::de::Error; + + let v: serde_json::Value = Deserialize::deserialize(de)?; + protobuf_json_mapping::parse_from_str(&v.to_string()) + .map(Some) + .map_err(|why| { + warn!("deserialize_json_proto: {v}"); + error!("deserialize_json_proto: {why}"); + Error::custom(why) + }) +} + +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)) +} diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index 61fc56dd5..0a0b4bb1e 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -1,11 +1,10 @@ -use crate::dealer::protocol::JsonValue; use librespot_protocol::player::{ Context, ContextPlayerOptionOverrides, PlayOrigin, TransferState, }; use serde::Deserialize; use std::fmt::{Display, Formatter}; -use super::{deserialize_base64_proto, deserialize_json_proto}; +use super::*; #[derive(Clone, Debug, Deserialize)] pub struct Request { @@ -20,6 +19,7 @@ pub struct Request { #[serde(tag = "endpoint", rename_all = "snake_case")] pub enum RequestCommand { Transfer(TransferCommand), + #[serde(deserialize_with = "boxed")] Play(Box), Pause(PauseCommand), SeekTo(SeekToCommand), @@ -112,8 +112,8 @@ pub struct TransferOptions { #[derive(Clone, Debug, Deserialize)] pub struct PlayOptions { pub skip_to: SkipTo, - #[serde(deserialize_with = "deserialize_json_proto")] - pub player_option_overrides: ContextPlayerOptionOverrides, + #[serde(default, deserialize_with = "deserialize_option_json_proto")] + pub player_option_overrides: Option, pub license: String, } @@ -130,4 +130,5 @@ pub struct LoggingParams { pub device_identifier: Option, pub command_initiated_time: Option, pub page_instance_ids: Option>, + pub command_id: Option, } From 9b70d4c3d501072ec049e3671677766033da506c Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sat, 5 Oct 2024 17:24:38 +0200 Subject: [PATCH 028/138] dealer: always respect queued tracks --- connect/src/state.rs | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/connect/src/state.rs b/connect/src/state.rs index bc0fc87da..6d13e90de 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -224,22 +224,10 @@ impl ConnectState { } pub fn shuffle(&mut self) -> Result<(), Error> { - let (ctx, _) = self.context.as_mut().ok_or(ConnectStateError::NoContext)?; - self.player.prev_tracks.clear(); + self.clear_next_tracks(); - // respect queued track and don't throw them out of our next played tracks - let first_non_queued_track = self - .player - .next_tracks - .iter() - .enumerate() - .find(|(_, track)| track.provider != QUEUE_PROVIDER); - if let Some((non_queued_track, _)) = first_non_queued_track { - while self.player.next_tracks.pop().is_some() - && self.player.next_tracks.len() > non_queued_track - {} - } + let (ctx, _) = self.context.as_mut().ok_or(ConnectStateError::NoContext)?; let mut shuffle_context = ctx.clone(); let mut rng = rand::thread_rng(); @@ -392,7 +380,6 @@ impl ConnectState { context_index.track = new_index as u32 + 1; self.player.prev_tracks.clear(); - self.player.next_tracks.clear(); if new_index > 0 { let rev_ctx = context @@ -409,6 +396,7 @@ impl ConnectState { } } + self.clear_next_tracks(); self.fill_up_next_tracks_from_current_context()?; Ok(()) @@ -491,6 +479,19 @@ impl ConnectState { .put_connect_state_request(put_state) .await } + + fn clear_next_tracks(&mut self) { + // respect queued track and don't throw them out of our next played tracks + let first_non_queued_track = self + .player + .next_tracks + .iter() + .enumerate() + .find(|(_, track)| track.provider != QUEUE_PROVIDER); + if let Some((non_queued_track, _)) = first_non_queued_track { + while self.player.next_tracks.len() > non_queued_track && self.player.next_tracks.pop().is_some() {} + } + } fn fill_up_next_tracks_from_current_context(&mut self) -> Result<(), ConnectStateError> { let current_context = if self.player.options.shuffling_context { From cadb79d999c3facce48a7edc77c3f8fe09016a42 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sat, 5 Oct 2024 19:16:22 +0200 Subject: [PATCH 029/138] spirc: update duration of track --- connect/src/spirc.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 275e64aa5..4bda552d2 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -550,6 +550,11 @@ impl SpircTask { } async fn handle_player_event(&mut self, event: PlayerEvent) -> Result<(), Error> { + if let PlayerEvent::TrackChanged { audio_item } = event { + self.connect_state.player.duration = audio_item.duration_ms.into(); + return Ok(()) + } + // update play_request_id if let PlayerEvent::PlayRequestIdChanged { play_request_id } = event { self.play_request_id = Some(play_request_id); From 81babd75f32de9420e7da52accd3368d9c4ed9a5 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sat, 5 Oct 2024 19:16:57 +0200 Subject: [PATCH 030/138] ConnectState: update more restrictions --- connect/src/state.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/connect/src/state.rs b/connect/src/state.rs index 6d13e90de..8ff4c9075 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -220,7 +220,10 @@ impl ConnectState { return Ok(()); } - self.shuffle() + self.shuffle()?; + self.update_restrictions(); + + Ok(()) } pub fn shuffle(&mut self) -> Result<(), Error> { @@ -316,6 +319,8 @@ impl ConnectState { self.player.track = MessageField::some(new_track); + self.update_restrictions(); + Ok(self.player.index.track) } @@ -353,6 +358,8 @@ impl ConnectState { index.track -= 1; + self.update_restrictions(); + Ok(&self.player.track) } @@ -398,6 +405,7 @@ impl ConnectState { self.clear_next_tracks(); self.fill_up_next_tracks_from_current_context()?; + self.update_restrictions(); Ok(()) } @@ -479,6 +487,29 @@ impl ConnectState { .put_connect_state_request(put_state) .await } + + pub fn update_restrictions(&mut self) { + const NO_PREV: &str = "no previous tracks"; + const NO_NEXT: &str = "no next tracks"; + + if let Some(restrictions) = self.player.restrictions.as_mut() { + if self.player.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 self.player.next_tracks.is_empty() { + restrictions.disallow_peeking_next_reasons = vec![ NO_NEXT.to_string() ]; + restrictions.disallow_skipping_next_reasons = vec![ NO_NEXT.to_string() ]; + } else { + restrictions.disallow_peeking_next_reasons.clear(); + restrictions.disallow_skipping_next_reasons.clear(); + } + } + } fn clear_next_tracks(&mut self) { // respect queued track and don't throw them out of our next played tracks From 4b135a01e1198af30402e56f8636602121a3d00b Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sat, 5 Oct 2024 19:19:44 +0200 Subject: [PATCH 031/138] cleanup --- connect/src/spirc.rs | 7 +++---- connect/src/state.rs | 14 ++++++++------ core/src/spclient.rs | 5 ++++- core/src/spotify_id.rs | 13 ------------- 4 files changed, 15 insertions(+), 24 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 4bda552d2..893838969 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -552,9 +552,9 @@ impl SpircTask { async fn handle_player_event(&mut self, event: PlayerEvent) -> Result<(), Error> { if let PlayerEvent::TrackChanged { audio_item } = event { self.connect_state.player.duration = audio_item.duration_ms.into(); - return Ok(()) + return Ok(()); } - + // update play_request_id if let PlayerEvent::PlayRequestIdChanged { play_request_id } = event { self.play_request_id = Some(play_request_id); @@ -790,7 +790,6 @@ impl SpircTask { } async fn handle_set_volume(&mut self, set_volume_command: SetVolumeCommand) { - let volume_difference = set_volume_command.volume - self.connect_state.device.volume as i32; if volume_difference < self.connect_state.device.capabilities.volume_steps { return; @@ -1204,8 +1203,8 @@ impl SpircTask { // force reloading the current context with an autoplay context self.autoplay_context = true; self.resolve_context = Some(context_uri); - todo!("update tracks from context: autoplay"); self.player.set_auto_normalise_as_album(false); + todo!("update tracks from context: autoplay"); } else { if self.connect_state.player.options.shuffling_context { self.connect_state.shuffle()? diff --git a/connect/src/state.rs b/connect/src/state.rs index 8ff4c9075..57cd3ce22 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -494,23 +494,23 @@ impl ConnectState { if let Some(restrictions) = self.player.restrictions.as_mut() { if self.player.prev_tracks.is_empty() { - restrictions.disallow_peeking_prev_reasons = vec![ NO_PREV.to_string() ]; - restrictions.disallow_skipping_prev_reasons = vec![ NO_PREV.to_string() ]; + 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 self.player.next_tracks.is_empty() { - restrictions.disallow_peeking_next_reasons = vec![ NO_NEXT.to_string() ]; - restrictions.disallow_skipping_next_reasons = vec![ NO_NEXT.to_string() ]; + restrictions.disallow_peeking_next_reasons = vec![NO_NEXT.to_string()]; + restrictions.disallow_skipping_next_reasons = vec![NO_NEXT.to_string()]; } else { restrictions.disallow_peeking_next_reasons.clear(); restrictions.disallow_skipping_next_reasons.clear(); } } } - + fn clear_next_tracks(&mut self) { // respect queued track and don't throw them out of our next played tracks let first_non_queued_track = self @@ -520,7 +520,9 @@ impl ConnectState { .enumerate() .find(|(_, track)| track.provider != QUEUE_PROVIDER); if let Some((non_queued_track, _)) = first_non_queued_track { - while self.player.next_tracks.len() > non_queued_track && self.player.next_tracks.pop().is_some() {} + while self.player.next_tracks.len() > non_queued_track + && self.player.next_tracks.pop().is_some() + {} } } diff --git a/core/src/spclient.rs b/core/src/spclient.rs index 3801eae41..2ef48fdae 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -539,7 +539,10 @@ impl SpClient { } 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 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()?); diff --git a/core/src/spotify_id.rs b/core/src/spotify_id.rs index 2e17e68ed..5849dd962 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::player::ProvidedTrack> for SpotifyId { type Error = crate::Error; From 274fe97ede238228dd36483b7ecc23e46527b09f Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sun, 6 Oct 2024 13:50:46 +0200 Subject: [PATCH 032/138] spirc: handle queue requests --- connect/src/spirc.rs | 16 +++++++++- connect/src/state.rs | 47 ++++++++++++++++++++++------- core/src/dealer/protocol.rs | 24 +++++++++++++-- core/src/dealer/protocol/request.rs | 27 +++++++++++++++-- 4 files changed, 97 insertions(+), 17 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 893838969..92e36ceb4 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -20,7 +20,7 @@ use crate::{ }; use futures_util::{FutureExt, Stream, StreamExt}; use librespot_core::dealer::manager::{Reply, RequestReply}; -use librespot_core::dealer::protocol::{PayloadValue, RequestCommand}; +use librespot_core::dealer::protocol::{PayloadValue, RequestCommand, SetQueueCommand}; use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason, SetVolumeCommand}; use librespot_protocol::player::{Context, TransferState}; use protobuf::Message; @@ -848,6 +848,14 @@ impl SpircTask { self.connect_state.set_shuffle(shuffle.value)?; self.notify().await.map(|_| Reply::Success)? } + RequestCommand::AddToQueue(add_to_queue) => { + self.connect_state.add_to_queue(add_to_queue.track); + self.notify().await.map(|_| Reply::Success)? + } + RequestCommand::SetQueue(set_queue) => { + self.handle_set_queue(set_queue); + self.notify().await.map(|_| Reply::Success)? + } RequestCommand::SkipNext(_) => { self.handle_next()?; self.notify().await.map(|_| Reply::Success)? @@ -1324,6 +1332,12 @@ impl SpircTask { } } } + + fn handle_set_queue(&mut self, set_queue_command: SetQueueCommand) { + self.connect_state.player.next_tracks = set_queue_command.next_tracks; + self.connect_state.player.prev_tracks = set_queue_command.prev_tracks; + self.connect_state.player.queue_revision = self.connect_state.new_queue_revision(); + } } impl Drop for SpircTask { diff --git a/connect/src/state.rs b/connect/src/state.rs index 57cd3ce22..fcdbcc282 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -155,7 +155,7 @@ impl ConnectState { } // todo: is there maybe a better way to calculate the hash? - fn new_queue_revision(&self) -> String { + pub fn new_queue_revision(&self) -> String { let mut hasher = DefaultHasher::new(); for track in &self.player.next_tracks { if let Ok(bytes) = track.write_to_bytes() { @@ -303,17 +303,13 @@ impl ConnectState { self.player_index = self.player.index.take(); self.player.index = MessageField::none() } else if let Some(index) = self.player.index.as_mut() { - if self.player.options.shuffling_context { - let (ctx, _) = self.context.as_ref().ok_or(ConnectStateError::NoContext)?; - let new_index = Self::find_index_in_context(ctx, &new_track.uri); - match new_index { - Err(why) => { - error!("didn't find the shuffled track in the current context: {why}") - } - Ok(new_index) => index.track = new_index as u32, + let (ctx, _) = self.context.as_ref().ok_or(ConnectStateError::NoContext)?; + let new_index = Self::find_index_in_context(ctx, &new_track.uri); + match new_index { + Err(why) => { + error!("didn't find the shuffled track in the current context: {why}") } - } else { - index.track += 1; + Ok(new_index) => index.track = new_index as u32, } }; @@ -410,6 +406,35 @@ impl ConnectState { Ok(()) } + pub fn add_to_queue(&mut self, mut track: ProvidedTrack) { + const IS_QUEUED: &str = "is_queued"; + + track.provider = QUEUE_PROVIDER.to_string(); + if !track.metadata.contains_key(IS_QUEUED) { + track + .metadata + .insert(IS_QUEUED.to_string(), true.to_string()); + } + + if let Some(next_not_queued_track) = self + .player + .next_tracks + .iter() + .position(|track| track.provider != QUEUE_PROVIDER) + { + self.player.next_tracks.insert(next_not_queued_track, track); + } else { + self.player.next_tracks.push(track) + } + + while self.player.next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE { + self.player.next_tracks.pop(); + } + + self.player.queue_revision = self.new_queue_revision(); + self.update_restrictions(); + } + pub fn update_context(&mut self, context: Option) { self.context = context.map(|ctx| (ctx, ContextIndex::default())) } diff --git a/core/src/dealer/protocol.rs b/core/src/dealer/protocol.rs index 668877811..b38150a85 100644 --- a/core/src/dealer/protocol.rs +++ b/core/src/dealer/protocol.rs @@ -175,7 +175,7 @@ where { use serde::de::Error; - let v: serde_json::Value = Deserialize::deserialize(de)?; + let v: JsonValue = Deserialize::deserialize(de)?; protobuf_json_mapping::parse_from_str(&v.to_string()).map_err(|why| { warn!("deserialize_json_proto: {v}"); error!("deserialize_json_proto: {why}"); @@ -190,7 +190,7 @@ where { use serde::de::Error; - let v: serde_json::Value = Deserialize::deserialize(de)?; + let v: JsonValue = Deserialize::deserialize(de)?; protobuf_json_mapping::parse_from_str(&v.to_string()) .map(Some) .map_err(|why| { @@ -200,6 +200,26 @@ where }) } +fn deserialize_vec_json_proto<'de, T, D>(de: D) -> Result, D::Error> +where + T: MessageFull, + D: Deserializer<'de>, +{ + use serde::de::Error; + + let v: JsonValue = Deserialize::deserialize(de)?; + let array = match v { + JsonValue::Array(array) => array, + _ => return Err(Error::custom("the value wasn't an array")), + }; + + let res = array + .into_iter() + .flat_map(|elem| protobuf_json_mapping::parse_from_str::(&elem.to_string())) + .collect(); + Ok(res) +} + fn boxed<'de, T, D>(de: D) -> Result, D::Error> where T: Deserialize<'de>, diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index 0a0b4bb1e..1e79396e0 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -1,6 +1,4 @@ -use librespot_protocol::player::{ - Context, ContextPlayerOptionOverrides, PlayOrigin, TransferState, -}; +use librespot_protocol::player::{Context, ContextPlayerOptionOverrides, PlayOrigin, ProvidedTrack, TransferState}; use serde::Deserialize; use std::fmt::{Display, Formatter}; @@ -24,6 +22,8 @@ pub enum RequestCommand { Pause(PauseCommand), SeekTo(SeekToCommand), SetShufflingContext(SetShufflingCommand), + AddToQueue(AddToQueueCommand), + SetQueue(SetQueueCommand), // commands that don't send any context SkipNext(GenericCommand), SkipPrev(GenericCommand), @@ -44,6 +44,8 @@ impl Display for RequestCommand { RequestCommand::Pause(_) => "pause", RequestCommand::SeekTo(_) => "seek_to", RequestCommand::SetShufflingContext(_) => "set_shuffling_context", + RequestCommand::AddToQueue(_) => "add_to_queue", + RequestCommand::SetQueue(_) => "set_queue", RequestCommand::SkipNext(_) => "skip_next", RequestCommand::SkipPrev(_) => "skip_prev", RequestCommand::Resume(_) => "resume", @@ -96,6 +98,25 @@ pub struct SetShufflingCommand { pub logging_params: LoggingParams, } +#[derive(Clone, Debug, Deserialize)] +pub struct AddToQueueCommand { + #[serde(deserialize_with = "deserialize_json_proto")] + pub track: ProvidedTrack, + pub logging_params: LoggingParams +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SetQueueCommand { + #[serde(deserialize_with = "deserialize_vec_json_proto")] + pub next_tracks: Vec, + #[serde(deserialize_with = "deserialize_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 GenericCommand { pub logging_params: LoggingParams, From dd26b5c3ed0b26e776f1fc68a81e8339f22d841f Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sun, 6 Oct 2024 14:15:55 +0200 Subject: [PATCH 033/138] spirc: skip next with track --- connect/src/spirc.rs | 32 +++++++++++++++++++---------- core/src/dealer/protocol/request.rs | 23 ++++++++++++++------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 92e36ceb4..4da517773 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -22,7 +22,7 @@ use futures_util::{FutureExt, Stream, StreamExt}; use librespot_core::dealer::manager::{Reply, RequestReply}; use librespot_core::dealer::protocol::{PayloadValue, RequestCommand, SetQueueCommand}; use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason, SetVolumeCommand}; -use librespot_protocol::player::{Context, TransferState}; +use librespot_protocol::player::{Context, ProvidedTrack, TransferState}; use protobuf::Message; use thiserror::Error; use tokio::sync::mpsc; @@ -493,7 +493,7 @@ impl SpircTask { self.notify().await } SpircCommand::Next => { - self.handle_next()?; + self.handle_next(None)?; self.notify().await } SpircCommand::VolumeUp => { @@ -856,8 +856,8 @@ impl SpircTask { self.handle_set_queue(set_queue); self.notify().await.map(|_| Reply::Success)? } - RequestCommand::SkipNext(_) => { - self.handle_next()?; + RequestCommand::SkipNext(skip_next) => { + self.handle_next(skip_next.track)?; self.notify().await.map(|_| Reply::Success)? } RequestCommand::SkipPrev(_) => { @@ -1164,14 +1164,24 @@ impl SpircTask { self.handle_preload_next_track(); } - fn handle_next(&mut self) -> Result<(), Error> { + fn handle_next(&mut self, track: Option) -> Result<(), Error> { let context_uri = self.connect_state.player.context_uri.to_owned(); let mut continue_playing = self.connect_state.player.is_playing; - let new_track_index = match self.connect_state.move_to_next_track() { - Ok(index) => Some(index), - Err(ConnectStateError::NoNextTrack) => None, - Err(why) => return Err(why.into()), + let new_track_index = loop { + let index = match self.connect_state.move_to_next_track() { + Ok(index) => Some(index), + Err(ConnectStateError::NoNextTrack) => break None, + Err(why) => return Err(why.into()), + }; + + if track.is_some() + && matches!(track, Some(ref track) if self.connect_state.player.track.uri != track.uri) + { + continue; + } else { + break index; + } }; let (ctx, ctx_index) = self @@ -1271,7 +1281,7 @@ impl SpircTask { } async fn handle_end_of_track(&mut self) -> Result<(), Error> { - self.handle_next()?; + self.handle_next(None)?; self.notify().await } @@ -1332,7 +1342,7 @@ impl SpircTask { } } } - + fn handle_set_queue(&mut self, set_queue_command: SetQueueCommand) { self.connect_state.player.next_tracks = set_queue_command.next_tracks; self.connect_state.player.prev_tracks = set_queue_command.prev_tracks; diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index 1e79396e0..7426f49b4 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -1,4 +1,6 @@ -use librespot_protocol::player::{Context, ContextPlayerOptionOverrides, PlayOrigin, ProvidedTrack, TransferState}; +use librespot_protocol::player::{ + Context, ContextPlayerOptionOverrides, PlayOrigin, ProvidedTrack, TransferState, +}; use serde::Deserialize; use std::fmt::{Display, Formatter}; @@ -21,11 +23,11 @@ pub enum RequestCommand { Play(Box), Pause(PauseCommand), SeekTo(SeekToCommand), + SkipNext(SkipNextCommand), SetShufflingContext(SetShufflingCommand), AddToQueue(AddToQueueCommand), SetQueue(SetQueueCommand), - // commands that don't send any context - SkipNext(GenericCommand), + // 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 @@ -92,6 +94,13 @@ pub struct SeekToCommand { pub logging_params: LoggingParams, } +#[derive(Clone, Debug, Deserialize)] +pub struct SkipNextCommand { + #[serde(default, deserialize_with = "deserialize_option_json_proto")] + pub track: Option, + pub logging_params: LoggingParams, +} + #[derive(Clone, Debug, Deserialize)] pub struct SetShufflingCommand { pub value: bool, @@ -102,7 +111,7 @@ pub struct SetShufflingCommand { pub struct AddToQueueCommand { #[serde(deserialize_with = "deserialize_json_proto")] pub track: ProvidedTrack, - pub logging_params: LoggingParams + pub logging_params: LoggingParams, } #[derive(Clone, Debug, Deserialize)] @@ -112,10 +121,10 @@ pub struct SetQueueCommand { #[serde(deserialize_with = "deserialize_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 + // might be that internally they use the last revision to create the next revision pub queue_revision: String, - pub logging_params: LoggingParams -} + pub logging_params: LoggingParams, +} #[derive(Clone, Debug, Deserialize)] pub struct GenericCommand { From dff943a2db4f7c21e17dd78d7235ceb78283933d Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sun, 13 Oct 2024 14:39:13 +0200 Subject: [PATCH 034/138] proto: exclude spirc.proto - move "deserialize_with" functions into own file - replace TrackRef with ProvidedTrack --- connect/src/context.rs | 73 +++---------------- core/src/dealer/protocol.rs | 79 +------------------- core/src/dealer/protocol/request.rs | 22 +++--- core/src/deserialize_with.rs | 109 ++++++++++++++++++++++++++++ core/src/lib.rs | 1 + core/src/spclient.rs | 9 ++- protocol/build.rs | 1 - 7 files changed, 142 insertions(+), 152 deletions(-) create mode 100644 core/src/deserialize_with.rs diff --git a/connect/src/context.rs b/connect/src/context.rs index 22e0dfb14..aac0eae4a 100644 --- a/connect/src/context.rs +++ b/connect/src/context.rs @@ -1,13 +1,9 @@ // TODO : move to metadata -use crate::core::spotify_id::SpotifyId; -use crate::protocol::spirc::TrackRef; +use crate::core::deserialize_with::{bool_from_string, vec_json_proto}; -use librespot_protocol::player::{ContextPage, ContextTrack}; -use serde::{ - de::{Error, Unexpected}, - Deserialize, -}; +use librespot_protocol::player::{ContextPage, ContextTrack, ProvidedTrack}; +use serde::Deserialize; #[derive(Deserialize, Debug, Default, Clone)] pub struct StationContext { @@ -19,8 +15,8 @@ pub struct StationContext { #[serde(rename = "imageUri")] pub image_uri: String, pub seeds: Vec, - #[serde(deserialize_with = "deserialize_protobuf_TrackRef")] - pub tracks: Vec, + #[serde(deserialize_with = "vec_json_proto")] + pub tracks: Vec, pub next_page_url: String, pub correlation_id: String, pub related_artists: Vec, @@ -28,8 +24,8 @@ pub struct StationContext { #[derive(Deserialize, Debug, Default, Clone)] pub struct PageContext { - #[serde(deserialize_with = "deserialize_protobuf_TrackRef")] - pub tracks: Vec, + #[serde(deserialize_with = "vec_json_proto")] + pub tracks: Vec, pub next_page_url: String, pub correlation_id: String, } @@ -80,14 +76,6 @@ pub struct SubtitleContext { uri: String, } -fn track_ref_to_context_track(track_ref: TrackRef) -> ContextTrack { - ContextTrack { - uri: track_ref.uri.unwrap_or_default(), - gid: track_ref.gid.unwrap_or_default(), - ..Default::default() - } -} - impl From for ContextPage { fn from(value: PageContext) -> Self { Self { @@ -95,51 +83,14 @@ impl From for ContextPage { tracks: value .tracks .into_iter() - .map(track_ref_to_context_track) + .map(|track| ContextTrack { + uri: track.uri, + metadata: track.metadata, + ..Default::default() + }) .collect(), loading: false, ..Default::default() } } } - -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/core/src/dealer/protocol.rs b/core/src/dealer/protocol.rs index b38150a85..d6d89d4af 100644 --- a/core/src/dealer/protocol.rs +++ b/core/src/dealer/protocol.rs @@ -9,8 +9,7 @@ use crate::Error; use base64::prelude::BASE64_STANDARD; use base64::{DecodeError, Engine}; use flate2::read::GzDecoder; -use protobuf::MessageFull; -use serde::{Deserialize, Deserializer}; +use serde::Deserialize; use serde_json::Error as SerdeError; use thiserror::Error; @@ -152,79 +151,3 @@ fn handle_transfer_encoding( Err(why) => Err(ProtocolError::GZip(why).into()), } } - -fn deserialize_base64_proto<'de, T, D>(de: D) -> Result, D::Error> -where - T: MessageFull, - D: Deserializer<'de>, -{ - use serde::de::Error; - - 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) -} - -fn deserialize_json_proto<'de, T, D>(de: D) -> Result -where - T: MessageFull, - D: Deserializer<'de>, -{ - use serde::de::Error; - - let v: JsonValue = Deserialize::deserialize(de)?; - protobuf_json_mapping::parse_from_str(&v.to_string()).map_err(|why| { - warn!("deserialize_json_proto: {v}"); - error!("deserialize_json_proto: {why}"); - Error::custom(why) - }) -} - -fn deserialize_option_json_proto<'de, T, D>(de: D) -> Result, D::Error> -where - T: MessageFull, - D: Deserializer<'de>, -{ - use serde::de::Error; - - let v: JsonValue = Deserialize::deserialize(de)?; - protobuf_json_mapping::parse_from_str(&v.to_string()) - .map(Some) - .map_err(|why| { - warn!("deserialize_json_proto: {v}"); - error!("deserialize_json_proto: {why}"); - Error::custom(why) - }) -} - -fn deserialize_vec_json_proto<'de, T, D>(de: D) -> Result, D::Error> -where - T: MessageFull, - D: Deserializer<'de>, -{ - use serde::de::Error; - - let v: JsonValue = Deserialize::deserialize(de)?; - let array = match v { - JsonValue::Array(array) => array, - _ => return Err(Error::custom("the value wasn't an array")), - }; - - let res = array - .into_iter() - .flat_map(|elem| protobuf_json_mapping::parse_from_str::(&elem.to_string())) - .collect(); - Ok(res) -} - -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)) -} diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index 7426f49b4..72954f3a5 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -1,11 +1,11 @@ +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}; -use super::*; - #[derive(Clone, Debug, Deserialize)] pub struct Request { pub message_id: u32, @@ -32,7 +32,7 @@ pub enum RequestCommand { Resume(GenericCommand), // catch unknown commands, so that we can implement them later #[serde(untagged)] - Unknown(JsonValue), + Unknown(Value), } impl Display for RequestCommand { @@ -64,7 +64,7 @@ impl Display for RequestCommand { #[derive(Clone, Debug, Deserialize)] pub struct TransferCommand { - #[serde(default, deserialize_with = "deserialize_base64_proto")] + #[serde(default, deserialize_with = "base64_proto")] pub data: Option, pub options: TransferOptions, pub from_device_identifier: String, @@ -73,9 +73,9 @@ pub struct TransferCommand { #[derive(Clone, Debug, Deserialize)] pub struct PlayCommand { - #[serde(deserialize_with = "deserialize_json_proto")] + #[serde(deserialize_with = "json_proto")] pub context: Context, - #[serde(deserialize_with = "deserialize_json_proto")] + #[serde(deserialize_with = "json_proto")] pub play_origin: PlayOrigin, pub options: PlayOptions, pub logging_params: LoggingParams, @@ -96,7 +96,7 @@ pub struct SeekToCommand { #[derive(Clone, Debug, Deserialize)] pub struct SkipNextCommand { - #[serde(default, deserialize_with = "deserialize_option_json_proto")] + #[serde(default, deserialize_with = "option_json_proto")] pub track: Option, pub logging_params: LoggingParams, } @@ -109,16 +109,16 @@ pub struct SetShufflingCommand { #[derive(Clone, Debug, Deserialize)] pub struct AddToQueueCommand { - #[serde(deserialize_with = "deserialize_json_proto")] + #[serde(deserialize_with = "json_proto")] pub track: ProvidedTrack, pub logging_params: LoggingParams, } #[derive(Clone, Debug, Deserialize)] pub struct SetQueueCommand { - #[serde(deserialize_with = "deserialize_vec_json_proto")] + #[serde(deserialize_with = "vec_json_proto")] pub next_tracks: Vec, - #[serde(deserialize_with = "deserialize_vec_json_proto")] + #[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 @@ -142,7 +142,7 @@ pub struct TransferOptions { #[derive(Clone, Debug, Deserialize)] pub struct PlayOptions { pub skip_to: SkipTo, - #[serde(default, deserialize_with = "deserialize_option_json_proto")] + #[serde(default, deserialize_with = "option_json_proto")] pub player_option_overrides: Option, pub license: String, } diff --git a/core/src/deserialize_with.rs b/core/src/deserialize_with.rs new file mode 100644 index 000000000..1752f2912 --- /dev/null +++ b/core/src/deserialize_with.rs @@ -0,0 +1,109 @@ +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>, +{ + use serde::de::Error; + + 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>, +{ + use serde::de::Error; + + 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>, +{ + use serde::de::Error; + + let v: Value = Deserialize::deserialize(de)?; + parse_value_to_msg(&v) + .map(Some) + .map_err(|why| { + warn!("deserialize_json_proto: {v}"); + error!("deserialize_json_proto: {why}"); + Error::custom(why) + }) +} + +pub fn vec_json_proto<'de, T, D>(de: D) -> Result, D::Error> +where + T: MessageFull, + D: Deserializer<'de>, +{ + use serde::de::Error; + + 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/lib.rs b/core/src/lib.rs index 4cf10affa..5c716e958 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -17,6 +17,7 @@ mod connection; pub mod date; #[allow(dead_code)] pub mod dealer; +pub mod deserialize_with; #[doc(hidden)] pub mod diffie_hellman; pub mod error; diff --git a/core/src/spclient.rs b/core/src/spclient.rs index 2ef48fdae..501ba5b4f 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -796,7 +796,6 @@ impl SpClient { } pub async fn get_context(&self, uri: &str) -> Result { - // requesting this endpoint with metrics results in a somewhat consistent 502 errors let uri = format!("/context-resolve/v1/{uri}"); let res = self.request(&Method::GET, &uri, None, None).await?; @@ -805,4 +804,12 @@ impl SpClient { 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/protocol/build.rs b/protocol/build.rs index e1378d378..c5b3df572 100644 --- a/protocol/build.rs +++ b/protocol/build.rs @@ -38,7 +38,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::>(); From 0785aaeb319c4cbf66d9650b857afe8fb049aed4 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sun, 13 Oct 2024 23:03:38 +0200 Subject: [PATCH 035/138] spirc: stabilize transfer/context handling --- connect/src/spirc.rs | 180 ++++++++++++++++++++++++++---------- connect/src/state.rs | 213 ++++++++++++++++++++++++++++++------------- 2 files changed, 280 insertions(+), 113 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 4da517773..e6c2a3cc8 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,12 +1,6 @@ -use std::{ - future::Future, - pin::Pin, - sync::atomic::{AtomicUsize, Ordering}, - sync::Arc, - time::{SystemTime, UNIX_EPOCH}, +use crate::state::{ + ConnectState, ConnectStateConfig, ConnectStateError, CONTEXT_PROVIDER, QUEUE_PROVIDER, }; - -use crate::state::{ConnectState, ConnectStateConfig, ConnectStateError}; use crate::{ context::PageContext, core::{authentication::Credentials, session::UserAttributes, Error, Session, SpotifyId}, @@ -23,7 +17,14 @@ use librespot_core::dealer::manager::{Reply, RequestReply}; use librespot_core::dealer::protocol::{PayloadValue, RequestCommand, SetQueueCommand}; use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason, SetVolumeCommand}; use librespot_protocol::player::{Context, ProvidedTrack, TransferState}; -use protobuf::Message; +use protobuf::{Message, MessageField}; +use std::{ + future::Future, + pin::Pin, + sync::atomic::{AtomicUsize, Ordering}, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; use thiserror::Error; use tokio::sync::mpsc; use tokio_stream::wrappers::UnboundedReceiverStream; @@ -348,7 +349,7 @@ impl SpircTask { }, connect_state_command = self.connect_state_command.next() => match connect_state_command { Some(request) => if let Err(e) = self.handle_connect_state_command(request).await { - error!("could handle connect state command: {}", e); + error!("couldn't handle connect state command: {}", e); }, None => { error!("connect state command selected, but none received"); @@ -406,9 +407,10 @@ impl SpircTask { } else { let previous_tracks = self .connect_state - .player.prev_tracks + .player + .prev_tracks .iter() - .map(SpotifyId::try_from) + .map(|t| SpotifyId::from_uri(&t.uri)) .filter_map(Result::ok) .collect(); @@ -437,7 +439,11 @@ impl SpircTask { None } }; - self.connect_state.update_context(context) + self.connect_state.update_context(Context { + uri: self.connect_state.player.context_uri.to_owned(), + pages: context.map(|page| vec!(page)).unwrap_or_default(), + ..Default::default() + }) }, Err(err) => { error!("ContextError: {:?}", err) @@ -509,7 +515,7 @@ impl SpircTask { self.notify().await } SpircCommand::Shuffle(shuffle) => { - self.connect_state.set_shuffle(shuffle)?; + self.handle_shuffle(shuffle)?; self.notify().await } SpircCommand::Repeat(repeat) => { @@ -766,12 +772,13 @@ impl SpircTask { info!("cluster update! {reason:?} for {device_ids} from {devices} has {prev_tracks:?} previous tracks and {next_tracks} next tracks"); - let state = &mut self.connect_state; - - if let Some(mut cluster) = cluster_update.cluster.take() { - if let Some(player_state) = cluster.player_state.take() { - state.player = player_state; - } + if let Some(cluster) = cluster_update.cluster.take() { + // // we could transfer the player state here, which would result in less work that we + // // need to do, but it's probably better to handle it ourselves, otherwise we will + // // only notice problems after the tracks from the transferred player state have ended + // if let Some(player_state) = cluster.player_state.take() { + // state.player = player_state; + // } let became_inactive = self.connect_state.active && cluster.active_device_id != self.session.device_id(); @@ -796,6 +803,9 @@ impl SpircTask { } self.set_volume(set_volume_command.volume as u16); + + // todo: we only want to notify after we didn't receive an update for like one or two seconds, + // otherwise we will run in 429er errors if let Err(why) = self.notify().await { error!("couldn't notify after updating the volume: {why}") } @@ -826,14 +836,19 @@ impl SpircTask { } RequestCommand::Play(play) => { self.handle_load(SpircLoadCommand { - context_uri: play.context.uri, + context_uri: play.context.uri.clone(), start_playing: true, playing_track_index: play.options.skip_to.track_index, - shuffle: false, - repeat: false, - repeat_track: false, + shuffle: self.connect_state.player.options.shuffling_context, + repeat: self.connect_state.player.options.repeating_context, + repeat_track: self.connect_state.player.options.repeating_track, }) .await?; + + self.connect_state.player.context_uri = play.context.uri; + self.connect_state.player.context_uri = play.context.url; + self.connect_state.player.play_origin = MessageField::some(play.play_origin); + self.notify().await.map(|_| Reply::Success)? } RequestCommand::Pause(_) => { @@ -845,11 +860,11 @@ impl SpircTask { self.notify().await.map(|_| Reply::Success)? } RequestCommand::SetShufflingContext(shuffle) => { - self.connect_state.set_shuffle(shuffle.value)?; + self.handle_shuffle(shuffle.value)?; self.notify().await.map(|_| Reply::Success)? } RequestCommand::AddToQueue(add_to_queue) => { - self.connect_state.add_to_queue(add_to_queue.track); + self.connect_state.add_to_queue(add_to_queue.track, true); self.notify().await.map(|_| Reply::Success)? } RequestCommand::SetQueue(set_queue) => { @@ -880,9 +895,9 @@ impl SpircTask { async fn handle_transfer(&mut self, mut transfer: TransferState) -> Result<(), Error> { if let Some(session) = transfer.current_session.as_mut() { - if let Some(ctx) = session.context.as_mut() { - self.resolve_context(ctx).await?; - self.connect_state.update_context(ctx.pages.pop()) + if let Some(mut ctx) = session.context.take() { + self.resolve_context(&mut ctx).await?; + self.connect_state.update_context(ctx) } } @@ -903,10 +918,16 @@ impl SpircTask { } state.player.play_origin = transfer.current_session.play_origin.clone(); - state.player.context_uri = transfer.current_session.context.uri.clone(); - state.player.context_url = transfer.current_session.context.url.clone(); - state.player.context_restrictions = transfer.current_session.context.restrictions.clone(); - state.player.suppressions = transfer.current_session.suppressions.clone(); + + if let Some(suppressions) = transfer.current_session.suppressions.as_ref() { + state.player.suppressions = MessageField::some(suppressions.clone()); + } + + if let Some(context) = transfer.current_session.context.as_ref() { + state.player.context_uri = context.uri.clone(); + state.player.context_url = context.url.clone(); + state.player.context_restrictions = context.restrictions.clone(); + } for (key, value) in &transfer.current_session.context.metadata { state @@ -924,21 +945,49 @@ impl SpircTask { } } - if state.player.track.is_none() { - // todo: now we need to resolve this stuff, we can ignore this to some degree for now if we come from an already running context - todo!("resolving player_state required") - } - // update position if the track continued playing let position = if transfer.playback.is_paused { state.player.position_as_of_timestamp } else { - let time_since_position_update = timestamp - state.player.timestamp; + let time_since_position_update = timestamp - transfer.playback.timestamp; state.player.position_as_of_timestamp + time_since_position_update }; - if self.connect_state.player.options.shuffling_context { - self.connect_state.set_shuffle(true)?; + let current_index = if transfer.queue.is_playing_queue { + debug!("queued track is playing, {}", transfer.queue.tracks.len()); + + if let Some(queue) = transfer.queue.as_mut() { + let mut provided_track = + state.context_to_provided_track(&queue.tracks.remove(0))?; + provided_track.provider = QUEUE_PROVIDER.to_string(); + + state.player.track = MessageField::some(provided_track); + } else { + error!("if we are playing we should at least have a single queued track") + } + + state.find_index_in_context(|c| c.uid == transfer.current_session.current_uid) + } else if transfer.playback.current_track.uri.is_empty() + && !transfer.playback.current_track.gid.is_empty() + { + let uri = SpotifyId::from_raw(&transfer.playback.current_track.gid)?.to_uri()?; + let uri = uri.replace("unknown", "track"); + + state.find_index_in_context(|c| c.uri == uri) + } else { + state.find_index_in_context(|c| c.uri == transfer.playback.current_track.uri) + }?; + + debug!( + "setting up next and prev: index is at {current_index} while shuffle {}", + state.player.options.shuffling_context + ); + + if state.player.options.shuffling_context { + self.connect_state.set_shuffle(true); + self.connect_state.shuffle()?; + } else { + state.reset_playback_context(Some(current_index))?; } self.load_track(self.connect_state.player.is_playing, position.try_into()?) @@ -958,7 +1007,7 @@ impl SpircTask { let resolved_ctx = self.session.spclient().get_context(&ctx.uri).await?; debug!( - "context was resolved {} pages and {} tracks", + "context resolved {} pages and {} tracks in total", resolved_ctx.pages.len(), resolved_ctx .pages @@ -1030,11 +1079,20 @@ impl SpircTask { }; self.resolve_context(&mut ctx).await?; - self.connect_state.update_context(ctx.pages.pop()); - self.connect_state - .reset_playback_context(Some(cmd.playing_track_index as usize))?; + self.connect_state.update_context(ctx); + + self.connect_state.player.next_tracks.clear(); + self.connect_state.player.track = MessageField::none(); + + let index = cmd.playing_track_index as usize; + self.connect_state.set_shuffle(cmd.shuffle); + if cmd.shuffle { + self.connect_state.set_current_track(index)?; + self.connect_state.shuffle()?; + } else { + self.connect_state.reset_playback_context(Some(index))?; + } - self.connect_state.set_shuffle(cmd.shuffle)?; self.connect_state.set_repeat_context(cmd.repeat); self.connect_state.set_repeat_track(cmd.repeat_track); @@ -1132,7 +1190,7 @@ impl SpircTask { fn preview_next_track(&mut self) -> Option { let next = self.connect_state.player.next_tracks.first()?; - SpotifyId::try_from(next).ok() + SpotifyId::from_uri(&next.uri).ok() } fn handle_preload_next_track(&mut self) { @@ -1298,7 +1356,7 @@ impl SpircTask { } fn load_track(&mut self, start_playing: bool, position_ms: u32) -> Result<(), Error> { - let track_to_load = match self.connect_state.player.track.as_ref() { + let track_to_load = match self.connect_state.player.track.as_mut() { None => { self.handle_stop(); return Ok(()); @@ -1306,9 +1364,22 @@ impl SpircTask { Some(track) => track, }; - let id = SpotifyId::try_from(track_to_load)?; + let id = SpotifyId::from_uri(&track_to_load.uri)?; self.player.load(id, start_playing, position_ms); + const CONTEXT_URI_METADATA: &str = "context_uri"; + const ENTITY_URI_METADATA: &str = "entity_uri"; + if track_to_load.provider == CONTEXT_PROVIDER { + track_to_load.metadata.insert( + CONTEXT_URI_METADATA.to_string(), + self.connect_state.player.context_uri.to_owned(), + ); + track_to_load.metadata.insert( + ENTITY_URI_METADATA.to_string(), + self.connect_state.player.context_uri.to_owned(), + ); + } + self.update_state_position(position_ms); if start_playing { self.play_status = SpircPlayStatus::LoadingPlay { position_ms }; @@ -1343,6 +1414,17 @@ impl SpircTask { } } + fn handle_shuffle(&mut self, shuffle: bool) -> Result<(), Error> { + self.connect_state.set_shuffle(shuffle); + + if shuffle { + self.connect_state.shuffle() + } else { + self.connect_state.shuffle_context = None; + self.connect_state.reset_playback_to_current_track() + } + } + fn handle_set_queue(&mut self, set_queue_command: SetQueueCommand) { self.connect_state.player.next_tracks = set_queue_command.next_tracks; self.connect_state.player.prev_tracks = set_queue_command.prev_tracks; diff --git a/connect/src/state.rs b/connect/src/state.rs index fcdbcc282..6a725a0df 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -10,8 +10,8 @@ use librespot_protocol::connect::{ Capabilities, Device, DeviceInfo, MemberType, PutStateReason, PutStateRequest, }; use librespot_protocol::player::{ - ContextIndex, ContextPage, ContextPlayerOptions, ContextTrack, PlayOrigin, PlayerState, - ProvidedTrack, Suppressions, + Context, ContextIndex, ContextPage, ContextPlayerOptions, ContextTrack, PlayOrigin, + PlayerState, ProvidedTrack, Suppressions, }; use protobuf::{EnumOrUnknown, Message, MessageField}; use rand::prelude::SliceRandom; @@ -22,8 +22,10 @@ const SPOTIFY_MAX_PREV_TRACKS_SIZE: usize = 10; const SPOTIFY_MAX_NEXT_TRACKS_SIZE: usize = 80; // provider used by spotify -const CONTEXT_PROVIDER: &str = "context"; -const QUEUE_PROVIDER: &str = "queue"; +pub const CONTEXT_PROVIDER: &str = "context"; +pub const QUEUE_PROVIDER: &str = "queue"; +// todo: there is a separator provider which is used to realise repeat + // our own provider to flag tracks as a specific states // todo: we might just need to remove tracks that are unavailable to play, will have to see how the official clients handle this provider const UNAVAILABLE_PROVIDER: &str = "unavailable"; @@ -40,8 +42,8 @@ pub enum ConnectStateError { NoContext, #[error("not the first context page")] NotFirstContextPage, - #[error("could not find the new track")] - CanNotFindTrackInContext, + #[error("could not find track {0:?} in context of {1}")] + CanNotFindTrackInContext(Option, usize), } impl From for Error { @@ -90,7 +92,6 @@ pub struct ConnectState { // next_track: top => bottom, aka the first track is the next track pub player: PlayerState, - // todo: still a bit jank, have to overhaul the resolving, especially when transferring playback // the context from which we play, is used to top up prev and next tracks // the index is used to keep track which tracks are already loaded into next tracks pub context: Option<(ContextPage, ContextIndex)>, @@ -193,35 +194,53 @@ impl ConnectState { } } + fn add_options_if_empty(&mut self) { + if self.player.options.is_none() { + self.player.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.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.options.as_mut() { options.repeating_track = repeat; } } - pub fn set_shuffle(&mut self, shuffle: bool) -> Result<(), Error> { + pub fn set_shuffle(&mut self, shuffle: bool) { + self.add_options_if_empty(); if let Some(options) = self.player.options.as_mut() { options.shuffling_context = shuffle; } + } - if !shuffle { - self.shuffle_context = None; + pub fn reset_playback_to_current_track(&mut self) -> Result<(), Error> { + let new_index = self.find_index_in_context(|c| c.uri == self.player.track.uri)?; + self.reset_playback_context(Some(new_index)) + } - let (ctx, _) = self.context.as_mut().ok_or(ConnectStateError::NoContext)?; - let new_index = Self::find_index_in_context(ctx, &self.player.track.uri)?; + pub fn set_current_track(&mut self, index: usize) -> Result<(), Error> { + let (context, _) = self.context.as_ref().ok_or(ConnectStateError::NoContext)?; + let new_track = + context + .tracks + .get(index) + .ok_or(ConnectStateError::CanNotFindTrackInContext( + Some(index), + context.tracks.len(), + ))?; - self.reset_playback_context(Some(new_index))?; - return Ok(()); - } + debug!("track: {}", new_track.uri); - self.shuffle()?; - self.update_restrictions(); + let new_track = self.context_to_provided_track(new_track)?; + self.player.track = MessageField::some(new_track); Ok(()) } @@ -298,21 +317,27 @@ impl ConnectState { self.fill_up_next_tracks_from_current_context()?; let is_queued_track = new_track.provider == QUEUE_PROVIDER; - if is_queued_track { + let update_index = if is_queued_track { // the index isn't send when we are a queued track, but we have to preserve it for later self.player_index = self.player.index.take(); - self.player.index = MessageField::none() - } else if let Some(index) = self.player.index.as_mut() { - let (ctx, _) = self.context.as_ref().ok_or(ConnectStateError::NoContext)?; - let new_index = Self::find_index_in_context(ctx, &new_track.uri); + None + } else { + let new_index = self.find_index_in_context(|c| c.uri == new_track.uri); match new_index { + Ok(new_index) => Some(new_index as u32), Err(why) => { - error!("didn't find the shuffled track in the current context: {why}") + error!("didn't find the shuffled track in the current context: {why}"); + None } - Ok(new_index) => index.track = new_index as u32, } }; + if let Some(update_index) = update_index { + if let Some(index) = self.player.index.as_mut() { + index.track = update_index + } + } + self.player.track = MessageField::some(new_track); self.update_restrictions(); @@ -359,31 +384,40 @@ impl ConnectState { Ok(&self.player.track) } - pub fn reset_playback_context(&mut self, new_index: Option) -> Result<(), Error> { - let (context, context_index) = self.context.as_mut().ok_or(ConnectStateError::NoContext)?; - if context_index.page != 0 { - // todo: hmm, probably needs to resolve the correct context_page - return Err(ConnectStateError::NotFirstContextPage.into()); - } - + fn update_player_index(&mut self, new_index: Option) -> Result { let new_index = new_index.unwrap_or(0); if let Some(player_index) = self.player.index.as_mut() { player_index.track = new_index as u32; } - let new_track = context - .tracks - .get(new_index) - .ok_or(ConnectStateError::CanNotFindTrackInContext)?; + Ok(new_index) + } - let is_unavailable = self.unavailable_uri.contains(&new_track.uri); - let new_track = Self::context_to_provided_track(new_track, is_unavailable); - self.player.track = MessageField::some(new_track); + fn update_context_index(&mut self, new_index: usize) -> Result<(), ConnectStateError> { + let (_, context_index) = if self.player.options.shuffling_context { + self.shuffle_context.as_mut() + } else { + self.context.as_mut() + } + .ok_or(ConnectStateError::NoContext)?; - context_index.track = new_index as u32 + 1; + context_index.track = new_index as u32; + Ok(()) + } + + pub fn reset_playback_context(&mut self, new_index: Option) -> Result<(), Error> { + let new_index = self.update_player_index(new_index)?; + self.update_context_index(new_index + 1)?; + + debug!("resetting playback state to {new_index}"); + + if self.player.track.provider != QUEUE_PROVIDER { + self.set_current_track(new_index)?; + } self.player.prev_tracks.clear(); + let (context, _) = self.context.as_ref().ok_or(ConnectStateError::NoContext)?; if new_index > 0 { let rev_ctx = context .tracks @@ -391,11 +425,11 @@ impl ConnectState { .rev() .skip(context.tracks.len() - new_index) .take(SPOTIFY_MAX_PREV_TRACKS_SIZE); + for track in rev_ctx { - let is_unavailable = self.unavailable_uri.contains(&track.uri); self.player .prev_tracks - .push(Self::context_to_provided_track(track, is_unavailable)) + .push(self.context_to_provided_track(track)?) } } @@ -406,7 +440,7 @@ impl ConnectState { Ok(()) } - pub fn add_to_queue(&mut self, mut track: ProvidedTrack) { + pub fn add_to_queue(&mut self, mut track: ProvidedTrack, rev_update: bool) { const IS_QUEUED: &str = "is_queued"; track.provider = QUEUE_PROVIDER.to_string(); @@ -431,14 +465,33 @@ impl ConnectState { self.player.next_tracks.pop(); } - self.player.queue_revision = self.new_queue_revision(); + if rev_update { + self.player.queue_revision = self.new_queue_revision(); + } self.update_restrictions(); } - pub fn update_context(&mut self, context: Option) { - self.context = context.map(|ctx| (ctx, ContextIndex::default())) + pub fn update_context(&mut self, mut context: Context) { + debug!("context: {}, {}", context.uri, context.url); + self.context = context + .pages + .pop() + .map(|ctx| (ctx, ContextIndex::default())); + + self.player.context_url = format!("context://{}", context.uri); + self.player.context_uri = context.uri; + + if context.restrictions.is_some() { + self.player.context_restrictions = context.restrictions; + } + + if !context.metadata.is_empty() { + self.player.context_metadata = context.metadata; + } } + // todo: for some reason, after we run once into an unavailable track, + // a whole batch is marked as unavailable... have to look into that and see why and even how... pub fn mark_all_as_unavailable(&mut self, id: SpotifyId) { let id = match id.to_uri() { Ok(uri) => uri, @@ -544,6 +597,7 @@ impl ConnectState { .iter() .enumerate() .find(|(_, track)| track.provider != QUEUE_PROVIDER); + if let Some((non_queued_track, _)) = first_non_queued_track { while self.player.next_tracks.len() > non_queued_track && self.player.next_tracks.pop().is_some() @@ -552,26 +606,35 @@ impl ConnectState { } fn fill_up_next_tracks_from_current_context(&mut self) -> Result<(), ConnectStateError> { - let current_context = if self.player.options.shuffling_context { - self.shuffle_context.as_mut() + let (ctx, ctx_index) = if self.player.options.shuffling_context { + self.shuffle_context.as_ref() } else { - self.context.as_mut() - }; + self.context.as_ref() + } + .ok_or(ConnectStateError::NoContext)?; - let (ctx, ctx_index) = current_context.ok_or(ConnectStateError::NoContext)?; let mut new_index = ctx_index.track as usize; - while self.player.next_tracks.len() < SPOTIFY_MAX_NEXT_TRACKS_SIZE { - let track = match ctx.tracks.get(new_index + 1) { - None => break, - Some(ct) => Self::context_to_provided_track(ct, false), + let track = match ctx.tracks.get(new_index) { + None => { + // todo: what do we do if we can't fill up anymore? autoplay? + break; + } + Some(ct) => match self.context_to_provided_track(ct) { + Err(why) => { + error!("bad thing happened: {why}"); + // todo: handle bad things probably + break; + } + Ok(track) => track, + }, }; new_index += 1; self.player.next_tracks.push(track); } - ctx_index.track = new_index as u32; + self.update_context_index(new_index)?; // the web-player needs a revision update, otherwise the queue isn't updated in the ui self.player.queue_revision = self.new_queue_revision(); @@ -579,11 +642,19 @@ impl ConnectState { Ok(()) } - fn find_index_in_context(ctx: &ContextPage, uri: &str) -> Result { + pub fn find_index_in_context bool>( + &self, + f: F, + ) -> Result { + let (ctx, _) = self.context.as_ref().ok_or(ConnectStateError::NoContext)?; + ctx.tracks .iter() - .position(|track| track.uri == uri) - .ok_or(ConnectStateError::CanNotFindTrackInContext) + .position(f) + .ok_or(ConnectStateError::CanNotFindTrackInContext( + None, + ctx.tracks.len(), + )) } fn mark_as_unavailable_for_match(track: &mut ProvidedTrack, id: &str) { @@ -594,21 +665,35 @@ impl ConnectState { } pub fn context_to_provided_track( + &self, ctx_track: &ContextTrack, - is_unavailable: bool, - ) -> ProvidedTrack { - let provider = if is_unavailable { + ) -> Result { + let provider = if self.unavailable_uri.contains(&ctx_track.uri) { UNAVAILABLE_PROVIDER } else { CONTEXT_PROVIDER }; - ProvidedTrack { - uri: ctx_track.uri.to_string(), + let uri = if !ctx_track.uri.is_empty() { + ctx_track.uri.to_owned() + } else if !ctx_track.gid.is_empty() { + SpotifyId::from_raw(&ctx_track.gid)? + .to_uri()? + .replace("unknown", "track") + } else if !ctx_track.uid.is_empty() { + SpotifyId::from_raw(ctx_track.uid.as_bytes())? + .to_uri()? + .replace("unknown", "track") + } else { + return Err(Error::unavailable("track not available")); + }; + + Ok(ProvidedTrack { + uri, uid: ctx_track.uid.to_string(), metadata: ctx_track.metadata.clone(), provider: provider.to_string(), ..Default::default() - } + }) } } From 029419486fcdbf9f5e004cd7d4ebf0c9be8d6c5c Mon Sep 17 00:00:00 2001 From: photovoltex Date: Sun, 13 Oct 2024 23:04:46 +0200 Subject: [PATCH 036/138] core: cleanup some remains --- core/src/dealer/mod.rs | 1 - core/src/dealer/protocol.rs | 1 - core/src/dealer/protocol/request.rs | 4 ++-- core/src/deserialize_with.rs | 17 +++++------------ core/src/http_client.rs | 2 +- core/src/spotify_id.rs | 8 -------- 6 files changed, 8 insertions(+), 25 deletions(-) diff --git a/core/src/dealer/mod.rs b/core/src/dealer/mod.rs index 8dc0ecb3b..bf78e3dcc 100644 --- a/core/src/dealer/mod.rs +++ b/core/src/dealer/mod.rs @@ -345,7 +345,6 @@ impl DealerShared { return; } }; - debug!("request command: {payload_request:?}"); // ResponseSender will automatically send "success: false" if it is dropped without an answer. let responder = Responder::new(request.key.clone(), send_tx.clone()); diff --git a/core/src/dealer/protocol.rs b/core/src/dealer/protocol.rs index d6d89d4af..de6c558e1 100644 --- a/core/src/dealer/protocol.rs +++ b/core/src/dealer/protocol.rs @@ -120,7 +120,6 @@ impl WebsocketRequest { let payload = handle_transfer_encoding(&self.headers, payload_bytes)?; let payload = String::from_utf8(payload)?; - debug!("request: {payload}"); serde_json::from_str(&payload) .map_err(ProtocolError::Deserialization) diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index 72954f3a5..d41fe2c44 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -149,8 +149,8 @@ pub struct PlayOptions { #[derive(Clone, Debug, Deserialize)] pub struct SkipTo { - pub track_uid: String, - pub track_uri: String, + pub track_uid: Option, + pub track_uri: Option, pub track_index: u32, } diff --git a/core/src/deserialize_with.rs b/core/src/deserialize_with.rs index 1752f2912..a70f4834a 100644 --- a/core/src/deserialize_with.rs +++ b/core/src/deserialize_with.rs @@ -10,11 +10,10 @@ const IGNORE_UNKNOWN: protobuf_json_mapping::ParseOptions = protobuf_json_mappin _future_options: (), }; -fn parse_value_to_msg(value: &Value) -> Result { - protobuf_json_mapping::parse_from_str_with_options::( - &value.to_string(), - &IGNORE_UNKNOWN, - ) +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> @@ -55,13 +54,7 @@ where use serde::de::Error; let v: Value = Deserialize::deserialize(de)?; - parse_value_to_msg(&v) - .map(Some) - .map_err(|why| { - warn!("deserialize_json_proto: {v}"); - error!("deserialize_json_proto: {why}"); - Error::custom(why) - }) + parse_value_to_msg(&v).map(Some).map_err(|why| Error::custom(why)) } pub fn vec_json_proto<'de, T, D>(de: D) -> Result, D::Error> diff --git a/core/src/http_client.rs b/core/src/http_client.rs index 4b500cd6a..38663f6c2 100644 --- a/core/src/http_client.rs +++ b/core/src/http_client.rs @@ -205,7 +205,7 @@ impl HttpClient { } } - if code != StatusCode::OK { + if !code.is_success() { return Err(HttpClientError::StatusCode(code).into()); } } diff --git a/core/src/spotify_id.rs b/core/src/spotify_id.rs index 5849dd962..f7478f541 100644 --- a/core/src/spotify_id.rs +++ b/core/src/spotify_id.rs @@ -423,14 +423,6 @@ impl TryFrom<&Vec> for SpotifyId { } } -impl TryFrom<&protocol::player::ProvidedTrack> for SpotifyId { - type Error = crate::Error; - - fn try_from(track: &protocol::player::ProvidedTrack) -> Result { - SpotifyId::from_uri(&track.uri) - } -} - impl TryFrom<&protocol::metadata::Album> for SpotifyId { type Error = crate::Error; fn try_from(album: &protocol::metadata::Album) -> Result { From 1f14f66d1419869722a36fb70185c179270954b9 Mon Sep 17 00:00:00 2001 From: photovoltex Date: Fri, 18 Oct 2024 19:42:55 +0200 Subject: [PATCH 037/138] connect: improvements to code structure and performance - use VecDeque for next and prev tracks --- connect/src/spirc.rs | 36 +-- connect/src/state.rs | 431 ++++++++++++++++++----------------- core/src/deserialize_with.rs | 4 +- 3 files changed, 247 insertions(+), 224 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index e6c2a3cc8..16cf75618 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,5 +1,5 @@ use crate::state::{ - ConnectState, ConnectStateConfig, ConnectStateError, CONTEXT_PROVIDER, QUEUE_PROVIDER, + ConnectState, ConnectStateConfig, StateError, CONTEXT_PROVIDER, QUEUE_PROVIDER, }; use crate::{ context::PageContext, @@ -407,7 +407,6 @@ impl SpircTask { } else { let previous_tracks = self .connect_state - .player .prev_tracks .iter() .map(|t| SpotifyId::from_uri(&t.uri)) @@ -1081,13 +1080,13 @@ impl SpircTask { self.resolve_context(&mut ctx).await?; self.connect_state.update_context(ctx); - self.connect_state.player.next_tracks.clear(); + self.connect_state.next_tracks.clear(); self.connect_state.player.track = MessageField::none(); let index = cmd.playing_track_index as usize; self.connect_state.set_shuffle(cmd.shuffle); if cmd.shuffle { - self.connect_state.set_current_track(index)?; + self.connect_state.set_current_track(index, false)?; self.connect_state.shuffle()?; } else { self.connect_state.reset_playback_context(Some(index))?; @@ -1096,7 +1095,7 @@ impl SpircTask { self.connect_state.set_repeat_context(cmd.repeat); self.connect_state.set_repeat_track(cmd.repeat_track); - if !self.connect_state.player.next_tracks.is_empty() { + if !self.connect_state.next_tracks.is_empty() { self.load_track(self.connect_state.player.is_playing, 0)?; } else { info!("No more tracks left in queue"); @@ -1189,7 +1188,7 @@ impl SpircTask { } fn preview_next_track(&mut self) -> Option { - let next = self.connect_state.player.next_tracks.first()?; + let next = self.connect_state.next_tracks.front()?; SpotifyId::from_uri(&next.uri).ok() } @@ -1227,9 +1226,9 @@ impl SpircTask { let mut continue_playing = self.connect_state.player.is_playing; let new_track_index = loop { - let index = match self.connect_state.move_to_next_track() { + let index = match self.connect_state.next_track() { Ok(index) => Some(index), - Err(ConnectStateError::NoNextTrack) => break None, + Err(StateError::NoNextTrack) => break None, Err(why) => return Err(why.into()), }; @@ -1246,7 +1245,7 @@ impl SpircTask { .connect_state .context .as_ref() - .ok_or(ConnectStateError::NoContext)?; + .ok_or(StateError::NoContext(false))?; let context_length = ctx.tracks.len() as u32; let context_index = ctx_index.track; @@ -1311,9 +1310,9 @@ impl SpircTask { // Under 3s it goes to the previous song (starts playing) // Over 3s it seeks to zero (retains previous play status) if self.position() < 3000 { - let new_track_index = match self.connect_state.move_to_prev_track() { + let new_track_index = match self.connect_state.prev_track() { Ok(index) => Some(index), - Err(ConnectStateError::NoPrevTrack) => None, + Err(StateError::NoPrevTrack) => None, Err(why) => return Err(why.into()), }; @@ -1421,13 +1420,22 @@ impl SpircTask { self.connect_state.shuffle() } else { self.connect_state.shuffle_context = None; - self.connect_state.reset_playback_to_current_track() + + let state = &mut self.connect_state; + let current_index = match state.player.track.as_ref() { + None => None, + Some(track) => state + .find_index_in_context(|c| c.uri == track.uri) + .map(Some)?, + }; + + state.reset_playback_context(current_index) } } fn handle_set_queue(&mut self, set_queue_command: SetQueueCommand) { - self.connect_state.player.next_tracks = set_queue_command.next_tracks; - self.connect_state.player.prev_tracks = set_queue_command.prev_tracks; + self.connect_state.next_tracks = set_queue_command.next_tracks.into(); + self.connect_state.prev_tracks = set_queue_command.prev_tracks.into(); self.connect_state.player.queue_revision = self.connect_state.new_queue_revision(); } } diff --git a/connect/src/state.rs b/connect/src/state.rs index 6a725a0df..05a5fdf2f 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -1,3 +1,4 @@ +use std::collections::VecDeque; use std::hash::{DefaultHasher, Hasher}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; @@ -17,6 +18,8 @@ use protobuf::{EnumOrUnknown, Message, MessageField}; use rand::prelude::SliceRandom; use thiserror::Error; +type ContextState = (ContextPage, ContextIndex); + // 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; @@ -31,23 +34,21 @@ pub const QUEUE_PROVIDER: &str = "queue"; const UNAVAILABLE_PROVIDER: &str = "unavailable"; #[derive(Debug, Error)] -pub enum ConnectStateError { +pub enum StateError { #[error("no next track available")] NoNextTrack, #[error("no prev track available")] NoPrevTrack, #[error("message field {0} was not available")] MessageFieldNone(String), - #[error("context is not available")] - NoContext, - #[error("not the first context page")] - NotFirstContextPage, + #[error("context is not available. shuffle: {0}")] + NoContext(bool), #[error("could not find track {0:?} in context of {1}")] CanNotFindTrackInContext(Option, usize), } -impl From for Error { - fn from(err: ConnectStateError) -> Self { +impl From for Error { + fn from(err: StateError) -> Self { Error::failed_precondition(err) } } @@ -75,7 +76,7 @@ impl Default for ConnectStateConfig { } } -#[derive(Default, Debug, Clone)] +#[derive(Default, Debug)] pub struct ConnectState { pub active: bool, pub active_since: Option, @@ -87,16 +88,22 @@ pub struct ConnectState { unavailable_uri: Vec, // is only some when we're playing a queued item and have to preserve the index player_index: Option, + // index: 0 based, so the first track is index 0 - // prev_track: bottom => top, aka the last track is the prev track - // next_track: top => bottom, aka the first track is the next track + // prev_track: bottom => top, aka the last track of the list is the prev track + // next_track: top => bottom, aka the first track of the list is the next track pub player: PlayerState, + // we don't work directly on the lists of the player state, because + // we mostly need to push and pop at the beginning of both + pub prev_tracks: VecDeque, + pub next_tracks: VecDeque, + // the context from which we play, is used to top up prev and next tracks // the index is used to keep track which tracks are already loaded into next tracks - pub context: Option<(ContextPage, ContextIndex)>, + pub context: Option, // a context to keep track of our shuffled context, should be only available when option.shuffling_context is true - pub shuffle_context: Option<(ContextPage, ContextIndex)>, + pub shuffle_context: Option, pub last_command: Option, } @@ -116,7 +123,7 @@ impl ConnectState { is_group: cfg.is_group, capabilities: MessageField::some(Capabilities { volume_steps: cfg.volume_steps, - hidden: false, + hidden: false, // could be exposed later to only observe the playback gaia_eq_connect_id: true, can_be_player: true, @@ -126,7 +133,7 @@ impl ConnectState { is_controllable: true, supports_logout: cfg.zeroconf_enabled, - supported_types: vec!["audio/episode".to_string(), "audio/track".to_string()], + supported_types: vec!["audio/episode".into(), "audio/track".into()], supports_playlist_v2: true, supports_transfer_command: true, supports_command_request: true, @@ -141,7 +148,7 @@ impl ConnectState { connect_disabled: false, supports_rename: false, supports_external_episodes: false, - supports_set_backend_metadata: false, // TODO: impl + supports_set_backend_metadata: false, supports_hifi: MessageField::none(), command_acks: true, @@ -149,27 +156,16 @@ impl ConnectState { }), ..Default::default() }, + prev_tracks: VecDeque::with_capacity(SPOTIFY_MAX_PREV_TRACKS_SIZE), + next_tracks: VecDeque::with_capacity(SPOTIFY_MAX_NEXT_TRACKS_SIZE), ..Default::default() }; state.reset(); state } - // todo: is there maybe a better way to calculate the hash? - pub fn new_queue_revision(&self) -> String { - let mut hasher = DefaultHasher::new(); - for track in &self.player.next_tracks { - if let Ok(bytes) = track.write_to_bytes() { - hasher.write(&bytes) - } - } - - hasher.finish().to_string() - } - pub fn reset(&mut self) { - self.active = false; - self.active_since = None; + self.set_active(false); self.player = PlayerState { is_system_initiated: true, playback_speed: 1., @@ -194,6 +190,54 @@ impl ConnectState { } } + pub(crate) fn set_status(&mut self, status: &SpircPlayStatus) { + self.player.is_paused = matches!( + status, + SpircPlayStatus::LoadingPause { .. } + | SpircPlayStatus::Paused { .. } + | SpircPlayStatus::Stopped + ); + self.player.is_buffering = matches!( + status, + SpircPlayStatus::LoadingPause { .. } | SpircPlayStatus::LoadingPlay { .. } + ); + self.player.is_playing = matches!( + status, + SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::Playing { .. } + ); + + debug!( + "updated connect play status playing: {}, paused: {}, buffering: {}", + self.player.is_playing, self.player.is_paused, self.player.is_buffering + ); + + if let Some(restrictions) = self.player.restrictions.as_mut() { + if self.player.is_playing && !self.player.is_paused { + restrictions.disallow_pausing_reasons.clear(); + restrictions.disallow_resuming_reasons = vec!["not_paused".to_string()] + } + + if self.player.is_paused && !self.player.is_playing { + restrictions.disallow_resuming_reasons.clear(); + restrictions.disallow_pausing_reasons = vec!["not_playing".to_string()] + } + } + } + + // todo: is there maybe a better or more efficient way to calculate the hash? + pub fn new_queue_revision(&self) -> String { + let mut hasher = DefaultHasher::new(); + for track in &self.next_tracks { + if let Ok(bytes) = track.write_to_bytes() { + hasher.write(&bytes) + } + } + + hasher.finish().to_string() + } + + // region options (shuffle, repeat) + fn add_options_if_empty(&mut self) { if self.player.options.is_none() { self.player.options = MessageField::some(ContextPlayerOptions::new()) @@ -221,100 +265,76 @@ impl ConnectState { } } - pub fn reset_playback_to_current_track(&mut self) -> Result<(), Error> { - let new_index = self.find_index_in_context(|c| c.uri == self.player.track.uri)?; - self.reset_playback_context(Some(new_index)) - } - - pub fn set_current_track(&mut self, index: usize) -> Result<(), Error> { - let (context, _) = self.context.as_ref().ok_or(ConnectStateError::NoContext)?; - let new_track = - context - .tracks - .get(index) - .ok_or(ConnectStateError::CanNotFindTrackInContext( - Some(index), - context.tracks.len(), - ))?; - - debug!("track: {}", new_track.uri); - - let new_track = self.context_to_provided_track(new_track)?; - self.player.track = MessageField::some(new_track); - - Ok(()) - } - pub fn shuffle(&mut self) -> Result<(), Error> { - self.player.prev_tracks.clear(); + self.prev_tracks.clear(); self.clear_next_tracks(); - let (ctx, _) = self.context.as_mut().ok_or(ConnectStateError::NoContext)?; + let (ctx, _) = self.context.as_mut().ok_or(StateError::NoContext(false))?; let mut shuffle_context = ctx.clone(); let mut rng = rand::thread_rng(); shuffle_context.tracks.shuffle(&mut rng); self.shuffle_context = Some((shuffle_context, ContextIndex::new())); - self.fill_up_next_tracks_from_current_context()?; + self.fill_up_next_tracks()?; Ok(()) } - pub(crate) fn set_status(&mut self, status: &SpircPlayStatus) { - self.player.is_paused = matches!( - status, - SpircPlayStatus::LoadingPause { .. } - | SpircPlayStatus::Paused { .. } - | SpircPlayStatus::Stopped - ); - self.player.is_buffering = matches!( - status, - SpircPlayStatus::LoadingPause { .. } | SpircPlayStatus::LoadingPlay { .. } - ); - self.player.is_playing = matches!( - status, - SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::Playing { .. } - ); + // endregion + + pub fn set_current_track(&mut self, index: usize, shuffle_context: bool) -> Result<(), Error> { + let (context, _) = if shuffle_context { + self.shuffle_context.as_ref() + } else { + self.context.as_ref() + } + .ok_or(StateError::NoContext(shuffle_context))?; + + let new_track = context + .tracks + .get(index) + .ok_or(StateError::CanNotFindTrackInContext( + Some(index), + context.tracks.len(), + ))?; debug!( - "updated connect play status playing: {}, paused: {}, buffering: {}", - self.player.is_playing, self.player.is_paused, self.player.is_buffering + "set track to: {} at {index} of {} tracks", + new_track.uri, + context.tracks.len() ); - if let Some(restrictions) = self.player.restrictions.as_mut() { - if self.player.is_playing && !self.player.is_paused { - restrictions.disallow_pausing_reasons.clear(); - restrictions.disallow_resuming_reasons = vec!["not_paused".to_string()] - } + let new_track = self.context_to_provided_track(new_track)?; + self.player.track = MessageField::some(new_track); - if self.player.is_paused && !self.player.is_playing { - restrictions.disallow_resuming_reasons.clear(); - restrictions.disallow_pausing_reasons = vec!["not_playing".to_string()] - } - } + Ok(()) } - pub fn move_to_next_track(&mut self) -> Result { + /// 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 { let old_track = self.player.track.take(); if let Some(old_track) = old_track { - // only add songs not from the queue to our previous tracks - if old_track.provider != QUEUE_PROVIDER { + // only add songs from our context to our previous tracks + if old_track.provider == CONTEXT_PROVIDER { // add old current track to prev tracks, while preserving a length of 10 - if self.player.prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { - self.player.prev_tracks.remove(0); + if self.prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { + self.prev_tracks.pop_front(); } - self.player.prev_tracks.push(old_track); + self.prev_tracks.push_back(old_track); } } - if self.player.next_tracks.is_empty() { - return Err(ConnectStateError::NoNextTrack); - } + let new_track = self + .next_tracks + .pop_front() + .ok_or(StateError::NoNextTrack)?; - let new_track = self.player.next_tracks.remove(0); - self.fill_up_next_tracks_from_current_context()?; + self.fill_up_next_tracks()?; let is_queued_track = new_track.provider == QUEUE_PROVIDER; let update_index = if is_queued_track { @@ -345,37 +365,34 @@ impl ConnectState { Ok(self.player.index.track) } - pub fn move_to_prev_track( - &mut self, - ) -> Result<&MessageField, ConnectStateError> { + /// 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<&MessageField, StateError> { let old_track = self.player.track.take(); if let Some(old_track) = old_track { - if old_track.provider != QUEUE_PROVIDER { - self.player.next_tracks.insert(0, old_track); + if old_track.provider == CONTEXT_PROVIDER { + self.next_tracks.push_front(old_track); } } - while self.player.next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE { - let _ = self.player.next_tracks.pop(); + while self.next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE { + let _ = self.next_tracks.pop_back(); } - let new_track = self - .player - .prev_tracks - .pop() - .ok_or(ConnectStateError::NoPrevTrack)?; + let new_track = self.prev_tracks.pop_back().ok_or(StateError::NoPrevTrack)?; - self.fill_up_next_tracks_from_current_context()?; + self.fill_up_next_tracks()?; self.player.track = MessageField::some(new_track); let index = self .player .index .as_mut() - .ok_or(ConnectStateError::MessageFieldNone( - "player.index".to_string(), - ))?; + .ok_or(StateError::MessageFieldNone("player.index".to_string()))?; index.track -= 1; @@ -384,40 +401,35 @@ impl ConnectState { Ok(&self.player.track) } - fn update_player_index(&mut self, new_index: Option) -> Result { - let new_index = new_index.unwrap_or(0); - if let Some(player_index) = self.player.index.as_mut() { - player_index.track = new_index as u32; - } - - Ok(new_index) - } - - fn update_context_index(&mut self, new_index: usize) -> Result<(), ConnectStateError> { + fn update_context_index(&mut self, new_index: usize) -> Result<(), StateError> { let (_, context_index) = if self.player.options.shuffling_context { self.shuffle_context.as_mut() } else { self.context.as_mut() } - .ok_or(ConnectStateError::NoContext)?; + .ok_or(StateError::NoContext(self.player.options.shuffling_context))?; context_index.track = new_index as u32; Ok(()) } pub fn reset_playback_context(&mut self, new_index: Option) -> Result<(), Error> { - let new_index = self.update_player_index(new_index)?; + let new_index = new_index.unwrap_or(0); + if let Some(player_index) = self.player.index.as_mut() { + player_index.track = new_index as u32; + } + self.update_context_index(new_index + 1)?; - debug!("resetting playback state to {new_index}"); + debug!("reset playback state to {new_index}"); if self.player.track.provider != QUEUE_PROVIDER { - self.set_current_track(new_index)?; + self.set_current_track(new_index, self.player.options.shuffling_context)?; } - self.player.prev_tracks.clear(); + self.prev_tracks.clear(); - let (context, _) = self.context.as_ref().ok_or(ConnectStateError::NoContext)?; + let (context, _) = self.context.as_ref().ok_or(StateError::NoContext(false))?; if new_index > 0 { let rev_ctx = context .tracks @@ -427,14 +439,13 @@ impl ConnectState { .take(SPOTIFY_MAX_PREV_TRACKS_SIZE); for track in rev_ctx { - self.player - .prev_tracks - .push(self.context_to_provided_track(track)?) + self.prev_tracks + .push_back(self.context_to_provided_track(track)?) } } self.clear_next_tracks(); - self.fill_up_next_tracks_from_current_context()?; + self.fill_up_next_tracks()?; self.update_restrictions(); Ok(()) @@ -456,13 +467,13 @@ impl ConnectState { .iter() .position(|track| track.provider != QUEUE_PROVIDER) { - self.player.next_tracks.insert(next_not_queued_track, track); + self.next_tracks.insert(next_not_queued_track, track); } else { - self.player.next_tracks.push(track) + self.next_tracks.push_back(track) } - while self.player.next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE { - self.player.next_tracks.pop(); + while self.next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE { + self.next_tracks.pop_back(); } if rev_update { @@ -498,80 +509,23 @@ impl ConnectState { Err(_) => return, }; - for next_track in &mut self.player.next_tracks { + for next_track in &mut self.next_tracks { Self::mark_as_unavailable_for_match(next_track, &id) } - for prev_track in &mut self.player.prev_tracks { + for prev_track in &mut self.prev_tracks { Self::mark_as_unavailable_for_match(prev_track, &id) } self.unavailable_uri.push(id); } - pub async fn update_state(&self, session: &Session, reason: PutStateReason) -> SpClientResult { - if matches!(reason, PutStateReason::BECAME_INACTIVE) { - return session.spclient().put_connect_state_inactive(false).await; - } - - let now = SystemTime::now(); - let since_the_epoch = now.duration_since(UNIX_EPOCH).expect("Time went backwards"); - let client_side_timestamp = u64::try_from(since_the_epoch.as_millis())?; - - let member_type = EnumOrUnknown::new(MemberType::CONNECT_STATE); - let put_state_reason = EnumOrUnknown::new(reason); - - let state = self.clone(); - - let is_active = state.active; - let device = MessageField::some(Device { - device_info: MessageField::some(state.device), - player_state: MessageField::some(state.player), - ..Default::default() - }); - - let mut put_state = PutStateRequest { - client_side_timestamp, - member_type, - put_state_reason, - is_active, - device, - ..Default::default() - }; - - if let Some(has_been_playing_for) = state.has_been_playing_for { - match has_been_playing_for.elapsed().as_millis().try_into() { - Ok(ms) => put_state.has_been_playing_for_ms = ms, - Err(why) => warn!("couldn't update has been playing for because {why}"), - } - } - - if let Some(active_since) = state.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) => put_state.started_playing_at = active_since_ms, - Err(why) => warn!("couldn't update active since because {why}"), - } - } - } - - if let Some(request) = state.last_command { - put_state.last_command_message_id = request.message_id; - put_state.last_command_sent_by_device_id = request.sent_by_device_id; - } - - session - .spclient() - .put_connect_state_request(put_state) - .await - } - pub fn update_restrictions(&mut self) { const NO_PREV: &str = "no previous tracks"; const NO_NEXT: &str = "no next tracks"; if let Some(restrictions) = self.player.restrictions.as_mut() { - if self.player.prev_tracks.is_empty() { + if self.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 { @@ -579,7 +533,7 @@ impl ConnectState { restrictions.disallow_skipping_prev_reasons.clear(); } - if self.player.next_tracks.is_empty() { + if self.next_tracks.is_empty() { restrictions.disallow_peeking_next_reasons = vec![NO_NEXT.to_string()]; restrictions.disallow_skipping_next_reasons = vec![NO_NEXT.to_string()]; } else { @@ -592,29 +546,28 @@ impl ConnectState { fn clear_next_tracks(&mut self) { // respect queued track and don't throw them out of our next played tracks let first_non_queued_track = self - .player .next_tracks .iter() .enumerate() .find(|(_, track)| track.provider != QUEUE_PROVIDER); if let Some((non_queued_track, _)) = first_non_queued_track { - while self.player.next_tracks.len() > non_queued_track - && self.player.next_tracks.pop().is_some() - {} + while self.next_tracks.len() > non_queued_track && self.next_tracks.pop_back().is_some() + { + } } } - fn fill_up_next_tracks_from_current_context(&mut self) -> Result<(), ConnectStateError> { + fn fill_up_next_tracks(&mut self) -> Result<(), StateError> { let (ctx, ctx_index) = if self.player.options.shuffling_context { self.shuffle_context.as_ref() } else { self.context.as_ref() } - .ok_or(ConnectStateError::NoContext)?; + .ok_or(StateError::NoContext(self.player.options.shuffling_context))?; let mut new_index = ctx_index.track as usize; - while self.player.next_tracks.len() < SPOTIFY_MAX_NEXT_TRACKS_SIZE { + while self.next_tracks.len() < SPOTIFY_MAX_NEXT_TRACKS_SIZE { let track = match ctx.tracks.get(new_index) { None => { // todo: what do we do if we can't fill up anymore? autoplay? @@ -631,7 +584,7 @@ impl ConnectState { }; new_index += 1; - self.player.next_tracks.push(track); + self.next_tracks.push_back(track); } self.update_context_index(new_index)?; @@ -645,16 +598,13 @@ impl ConnectState { pub fn find_index_in_context bool>( &self, f: F, - ) -> Result { - let (ctx, _) = self.context.as_ref().ok_or(ConnectStateError::NoContext)?; + ) -> Result { + let (ctx, _) = self.context.as_ref().ok_or(StateError::NoContext(false))?; ctx.tracks .iter() .position(f) - .ok_or(ConnectStateError::CanNotFindTrackInContext( - None, - ctx.tracks.len(), - )) + .ok_or(StateError::CanNotFindTrackInContext(None, ctx.tracks.len())) } fn mark_as_unavailable_for_match(track: &mut ProvidedTrack, id: &str) { @@ -696,4 +646,67 @@ impl ConnectState { ..Default::default() }) } + + // todo: i would like to refrain from copying the next and prev track lists... will have to see what we can come up with + /// Updates the connect state for the connect session + /// + /// Prepares a [PutStateRequest] from the current connect state + pub async fn update_state(&self, session: &Session, reason: PutStateReason) -> SpClientResult { + if matches!(reason, PutStateReason::BECAME_INACTIVE) { + return session.spclient().put_connect_state_inactive(false).await; + } + + let now = SystemTime::now(); + let since_the_epoch = now.duration_since(UNIX_EPOCH).expect("Time went backwards"); + let client_side_timestamp = u64::try_from(since_the_epoch.as_millis())?; + + let member_type = EnumOrUnknown::new(MemberType::CONNECT_STATE); + let put_state_reason = EnumOrUnknown::new(reason); + + let mut player_state = self.player.clone(); + player_state.next_tracks = self.next_tracks.clone().into(); + player_state.prev_tracks = self.prev_tracks.clone().into(); + + let is_active = self.active; + let device = MessageField::some(Device { + device_info: MessageField::some(self.device.clone()), + player_state: MessageField::some(player_state), + ..Default::default() + }); + + let mut put_state = PutStateRequest { + client_side_timestamp, + member_type, + put_state_reason, + is_active, + device, + ..Default::default() + }; + + if let Some(has_been_playing_for) = self.has_been_playing_for { + match has_been_playing_for.elapsed().as_millis().try_into() { + Ok(ms) => put_state.has_been_playing_for_ms = ms, + Err(why) => warn!("couldn't update has been playing for because {why}"), + } + } + + 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) => put_state.started_playing_at = active_since_ms, + Err(why) => warn!("couldn't update active since because {why}"), + } + } + } + + if let Some(request) = self.last_command.clone() { + put_state.last_command_message_id = request.message_id; + put_state.last_command_sent_by_device_id = request.sent_by_device_id; + } + + session + .spclient() + .put_connect_state_request(put_state) + .await + } } diff --git a/core/src/deserialize_with.rs b/core/src/deserialize_with.rs index a70f4834a..afe5b14cb 100644 --- a/core/src/deserialize_with.rs +++ b/core/src/deserialize_with.rs @@ -54,7 +54,9 @@ where use serde::de::Error; let v: Value = Deserialize::deserialize(de)?; - parse_value_to_msg(&v).map(Some).map_err(|why| Error::custom(why)) + parse_value_to_msg(&v) + .map(Some) + .map_err(|why| Error::custom(why)) } pub fn vec_json_proto<'de, T, D>(de: D) -> Result, D::Error> From 8dd2a61ae45cabc5524a877c5d141852ea9f9e53 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 20 Oct 2024 21:42:35 +0200 Subject: [PATCH 038/138] connect: delayed volume update --- connect/src/spirc.rs | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 16cf75618..aab0c07c7 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -23,10 +23,10 @@ use std::{ pin::Pin, sync::atomic::{AtomicUsize, Ordering}, sync::Arc, - time::{SystemTime, UNIX_EPOCH}, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use thiserror::Error; -use tokio::sync::mpsc; +use tokio::{sync::mpsc, time::sleep}; use tokio_stream::wrappers::UnboundedReceiverStream; #[derive(Debug, Error)] @@ -96,6 +96,7 @@ struct SpircTask { shutdown: bool, session: Session, resolve_context: Option, + update_volume: bool, autoplay_context: bool, spirc_id: usize, @@ -137,6 +138,7 @@ pub struct SpircLoadCommand { const CONTEXT_FETCH_THRESHOLD: u32 = 5; const VOLUME_STEP_SIZE: u16 = 1024; // (u16::MAX + 1) / VOLUME_STEPS +const VOLUME_UPDATE_DELAY_MS: u64 = 2000; pub struct Spirc { commands: mpsc::UnboundedSender, @@ -256,6 +258,7 @@ impl Spirc { session, resolve_context: None, + update_volume: false, autoplay_context: false, spirc_id, @@ -263,6 +266,7 @@ impl Spirc { let spirc = Spirc { commands: cmd_tx }; task.set_volume(initial_volume as u16 - 1); + task.update_volume = false; Ok((spirc, task.run())) } @@ -339,7 +343,10 @@ impl SpircTask { }, volume_update = self.connect_state_volume_update.next() => match volume_update { Some(result) => match result { - Ok(volume_update) => self.handle_set_volume(volume_update).await, + Ok(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}") + }, Err(e) => error!("could not parse set volume update request: {}", e), } None => { @@ -449,6 +456,14 @@ impl SpircTask { } } }, + _ = async { sleep(Duration::from_millis(VOLUME_UPDATE_DELAY_MS)).await }, if self.update_volume => { + self.update_volume = false; + + info!("delayed volume update for all devices: volume is now {}", self.connect_state.device.volume); + if let Err(why) = self.notify().await { + error!("error updating connect state for volume update: {why}") + } + }, else => break } } @@ -795,21 +810,6 @@ impl SpircTask { Ok(()) } - async fn handle_set_volume(&mut self, set_volume_command: SetVolumeCommand) { - let volume_difference = set_volume_command.volume - self.connect_state.device.volume as i32; - if volume_difference < self.connect_state.device.capabilities.volume_steps { - return; - } - - self.set_volume(set_volume_command.volume as u16); - - // todo: we only want to notify after we didn't receive an update for like one or two seconds, - // otherwise we will run in 429er errors - if let Err(why) = self.notify().await { - error!("couldn't notify after updating the volume: {why}") - } - } - async fn handle_connect_state_command( &mut self, (request, sender): RequestReply, @@ -1402,6 +1402,8 @@ impl SpircTask { let old_volume = self.connect_state.device.volume; let new_volume = volume as u32; if old_volume != new_volume { + self.update_volume = true; + self.connect_state.device.volume = new_volume; self.mixer.set_volume(volume); if let Some(cache) = self.session.cache() { From 7d4dfdcb6988726aa65de09ed3ab62c959d920b8 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 20 Oct 2024 22:46:22 +0200 Subject: [PATCH 039/138] connect: move context resolve into own function --- connect/src/spirc.rs | 114 ++++++++++++++++++++++++------------------- 1 file changed, 63 insertions(+), 51 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index aab0c07c7..e9d998f06 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -404,57 +404,7 @@ impl SpircTask { } }, 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 - } - - let context = if context_uri.starts_with("hm://") { - self.session.spclient().get_next_page(&context_uri).await - } else { - let previous_tracks = self - .connect_state - .prev_tracks - .iter() - .map(|t| SpotifyId::from_uri(&t.uri)) - .filter_map(Result::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) => { - let context = match serde_json::from_slice::(&value) { - Ok(context) => { - info!( - "Resolved {:?} tracks from <{:?}>", - context.tracks.len(), - self.connect_state.player.context_uri, - ); - Some(context.into()) - } - Err(e) => { - error!("Unable to parse JSONContext {:?}", e); - None - } - }; - self.connect_state.update_context(Context { - uri: self.connect_state.player.context_uri.to_owned(), - pages: context.map(|page| vec!(page)).unwrap_or_default(), - ..Default::default() - }) - }, - Err(err) => { - error!("ContextError: {:?}", err) - } - } + self.handle_resolve_context(context_uri.unwrap()).await }, _ = async { sleep(Duration::from_millis(VOLUME_UPDATE_DELAY_MS)).await }, if self.update_volume => { self.update_volume = false; @@ -471,6 +421,68 @@ impl SpircTask { self.session.dealer().close().await; } + async fn handle_resolve_context(&mut self, context_uri: String) { + if context_uri.contains("spotify:show:") || context_uri.contains("spotify:episode:") { + return; // not supported by apollo stations + } + + let context = if context_uri.starts_with("hm://") { + self.session.spclient().get_next_page(&context_uri).await + } else { + let previous_tracks = self + .connect_state + .prev_tracks + .iter() + .map(|t| SpotifyId::from_uri(&t.uri)) + .filter_map(Result::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) => { + let context = match serde_json::from_slice::(&value) { + Ok(context) => { + info!( + "Resolved {:?} tracks from <{:?}>", + context.tracks.len(), + self.connect_state.player.context_uri, + ); + Some(context.into()) + } + Err(e) => { + error!("Unable to parse JSONContext {:?}", e); + None + } + }; + self.connect_state.update_context(Context { + uri: self.connect_state.player.context_uri.to_owned(), + pages: context.map(|page| vec![page]).unwrap_or_default(), + ..Default::default() + }) + } + Err(err) => { + error!("ContextError: {:?}", err) + } + } + } + fn now_ms(&self) -> i64 { let dur = SystemTime::now() .duration_since(UNIX_EPOCH) From 6048a3585c7c7789283c993d3fcd7863488ea099 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Mon, 21 Oct 2024 00:28:36 +0200 Subject: [PATCH 040/138] connect: load context asynchronous --- connect/src/spirc.rs | 82 ++++++++++++++++++-------------------------- connect/src/state.rs | 60 ++++++++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 51 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index e9d998f06..eefd15a92 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,6 +1,4 @@ -use crate::state::{ - ConnectState, ConnectStateConfig, StateError, CONTEXT_PROVIDER, QUEUE_PROVIDER, -}; +use crate::state::{ConnectState, ConnectStateConfig, StateError, CONTEXT_PROVIDER}; use crate::{ context::PageContext, core::{authentication::Credentials, session::UserAttributes, Error, Session, SpotifyId}, @@ -423,12 +421,13 @@ impl SpircTask { async fn handle_resolve_context(&mut self, context_uri: String) { if context_uri.contains("spotify:show:") || context_uri.contains("spotify:episode:") { + // todo: did check and it is supported by context-resolve, maybe remove this check or adjust it return; // not supported by apollo stations } let context = if context_uri.starts_with("hm://") { self.session.spclient().get_next_page(&context_uri).await - } else { + } else if self.autoplay_context { let previous_tracks = self .connect_state .prev_tracks @@ -437,22 +436,25 @@ impl SpircTask { .filter_map(Result::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, + "stations", // this returns a `StationContext` but we deserialize it into a `PageContext` &context_uri, None, previous_tracks, self.autoplay_context, ) .await + } else { + match self.session.spclient().get_context(&context_uri).await { + Err(why) => error!("failed to resolve context '{context_uri}': {why}"), + Ok(ctx) => self.connect_state.update_context(ctx), + }; + if let Err(why) = self.notify().await { + error!("failed to update connect state, after updating the context: {why}") + } + return; }; match context { @@ -906,9 +908,13 @@ impl SpircTask { async fn handle_transfer(&mut self, mut transfer: TransferState) -> Result<(), Error> { if let Some(session) = transfer.current_session.as_mut() { - if let Some(mut ctx) = session.context.take() { - self.resolve_context(&mut ctx).await?; - self.connect_state.update_context(ctx) + if let Some(ctx) = session.context.take() { + if ctx.pages.is_empty() { + // resolve context asynchronously + self.resolve_context = Some(ctx.uri) + } else { + self.connect_state.update_context(ctx) + } } } @@ -918,7 +924,9 @@ impl SpircTask { state.set_active(true); state.player.is_buffering = false; - state.player.options = transfer.options; + if let Some(options) = transfer.options.take() { + state.player.options = MessageField::some(options); + } state.player.is_paused = transfer.playback.is_paused; state.player.is_playing = !transfer.playback.is_paused; @@ -964,41 +972,18 @@ impl SpircTask { state.player.position_as_of_timestamp + time_since_position_update }; - let current_index = if transfer.queue.is_playing_queue { - debug!("queued track is playing, {}", transfer.queue.tracks.len()); - - if let Some(queue) = transfer.queue.as_mut() { - let mut provided_track = - state.context_to_provided_track(&queue.tracks.remove(0))?; - provided_track.provider = QUEUE_PROVIDER.to_string(); - - state.player.track = MessageField::some(provided_track); - } else { - error!("if we are playing we should at least have a single queued track") - } - - state.find_index_in_context(|c| c.uid == transfer.current_session.current_uid) - } else if transfer.playback.current_track.uri.is_empty() - && !transfer.playback.current_track.gid.is_empty() - { - let uri = SpotifyId::from_raw(&transfer.playback.current_track.gid)?.to_uri()?; - let uri = uri.replace("unknown", "track"); - - state.find_index_in_context(|c| c.uri == uri) + if self.connect_state.context.is_some() { + self.connect_state.setup_current_state(transfer)?; } else { - state.find_index_in_context(|c| c.uri == transfer.playback.current_track.uri) - }?; - - debug!( - "setting up next and prev: index is at {current_index} while shuffle {}", - state.player.options.shuffling_context - ); + match self + .connect_state + .try_get_current_track_from_transfer(&transfer) + { + Err(why) => warn!("{why}"), + Ok(track) => self.connect_state.player.track = MessageField::some(track), + } - if state.player.options.shuffling_context { - self.connect_state.set_shuffle(true); - self.connect_state.shuffle()?; - } else { - state.reset_playback_context(Some(current_index))?; + self.connect_state.transfer_state = Some(transfer); } self.load_track(self.connect_state.player.is_playing, position.try_into()?) @@ -1378,6 +1363,7 @@ impl SpircTask { let id = SpotifyId::from_uri(&track_to_load.uri)?; self.player.load(id, start_playing, position_ms); + // todo: move into state.rs const CONTEXT_URI_METADATA: &str = "context_uri"; const ENTITY_URI_METADATA: &str = "entity_uri"; if track_to_load.provider == CONTEXT_PROVIDER { diff --git a/connect/src/state.rs b/connect/src/state.rs index 05a5fdf2f..d08f3e034 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -12,7 +12,7 @@ use librespot_protocol::connect::{ }; use librespot_protocol::player::{ Context, ContextIndex, ContextPage, ContextPlayerOptions, ContextTrack, PlayOrigin, - PlayerState, ProvidedTrack, Suppressions, + PlayerState, ProvidedTrack, Suppressions, TransferState, }; use protobuf::{EnumOrUnknown, Message, MessageField}; use rand::prelude::SliceRandom; @@ -25,8 +25,8 @@ const SPOTIFY_MAX_PREV_TRACKS_SIZE: usize = 10; const SPOTIFY_MAX_NEXT_TRACKS_SIZE: usize = 80; // provider used by spotify -pub const CONTEXT_PROVIDER: &str = "context"; -pub const QUEUE_PROVIDER: &str = "queue"; +pub(crate) const CONTEXT_PROVIDER: &str = "context"; +const QUEUE_PROVIDER: &str = "queue"; // todo: there is a separator provider which is used to realise repeat // our own provider to flag tracks as a specific states @@ -35,6 +35,8 @@ const UNAVAILABLE_PROVIDER: &str = "unavailable"; #[derive(Debug, Error)] pub enum StateError { + #[error("the current track couldn't be resolved from the transfer state")] + CouldNotResolveTrackFromTransfer, #[error("no next track available")] NoNextTrack, #[error("no prev track available")] @@ -105,6 +107,9 @@ pub struct ConnectState { // a context to keep track of our shuffled context, should be only available when option.shuffling_context is true pub shuffle_context: Option, + // is set when we receive a transfer state and are loading the context asynchronously + pub transfer_state: Option, + pub last_command: Option, } @@ -499,6 +504,55 @@ impl ConnectState { if !context.metadata.is_empty() { self.player.context_metadata = context.metadata; } + + if let Some(transfer_state) = self.transfer_state.take() { + if let Err(why) = self.setup_current_state(transfer_state) { + error!("setting up current state failed after updating the context: {why}") + } + } + } + + pub fn try_get_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) + } + + pub fn setup_current_state(&mut self, transfer: TransferState) -> Result<(), Error> { + let track = match self.player.track.as_ref() { + None => self.try_get_current_track_from_transfer(&transfer)?, + Some(track) => track.clone(), + }; + + let current_index = + self.find_index_in_context(|c| c.uri == track.uri || c.uid == track.uid)?; + if self.player.track.is_none() { + self.player.track = MessageField::some(track); + } + + debug!( + "setting up next and prev: index is at {current_index} while shuffle {}", + self.player.options.shuffling_context + ); + + if self.player.options.shuffling_context { + self.set_current_track(current_index, false)?; + self.set_shuffle(true); + self.shuffle()?; + } else { + // todo: it seems like, if we play a queued track and transfer we will reset that queued track... + self.reset_playback_context(Some(current_index))?; + } + + Ok(()) } // todo: for some reason, after we run once into an unavailable track, From f68ce4043399b11bc7f9a5cc7dad9af0480cb0d0 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Mon, 21 Oct 2024 00:34:21 +0200 Subject: [PATCH 041/138] connect: handle reconnect - might currently steal the active devices playback --- connect/src/spirc.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index eefd15a92..86e6e44dc 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -719,18 +719,25 @@ impl SpircTask { } }; - if let Some(mut cluster) = response { + if let Some(cluster) = response { + if !cluster.transfer_data.is_empty() { + if let Ok(transfer_state) = TransferState::parse_from_bytes(&cluster.transfer_data) + { + if !transfer_state.current_session.context.pages.is_empty() { + info!("received transfer state with context, trying to take over control again"); + match self.handle_transfer(transfer_state).await { + Ok(_) => info!("successfully re-acquired control"), + Err(why) => error!("failed handling transfer state: {why}"), + } + } + } + } + debug!( "successfully put connect state for {} with connection-id {connection_id}", self.session.device_id() ); info!("active device is {:?}", cluster.active_device_id); - - if let Some(player_state) = cluster.player_state.take() { - self.connect_state.player = player_state; - } else { - warn!("couldn't take player state from cluster") - } } } From f5508455c30f62a3e694ab9691c997986e80ab15 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Mon, 21 Oct 2024 20:37:32 +0200 Subject: [PATCH 042/138] 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/src/spirc.rs | 10 ++++++---- connect/src/state.rs | 1 - core/src/dealer/manager.rs | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 86e6e44dc..538c1df10 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -408,7 +408,7 @@ impl SpircTask { self.update_volume = false; info!("delayed volume update for all devices: volume is now {}", self.connect_state.device.volume); - if let Err(why) = self.notify().await { + if let Err(why) = self.connect_state.update_state(&self.session, PutStateReason::VOLUME_CHANGED).await { error!("error updating connect state for volume update: {why}") } }, @@ -866,7 +866,7 @@ impl SpircTask { .await?; self.connect_state.player.context_uri = play.context.uri; - self.connect_state.player.context_uri = play.context.url; + self.connect_state.player.context_url = play.context.url; self.connect_state.player.play_origin = MessageField::some(play.play_origin); self.notify().await.map(|_| Reply::Success)? @@ -876,7 +876,9 @@ impl SpircTask { self.notify().await.map(|_| Reply::Success)? } RequestCommand::SeekTo(seek_to) => { - self.handle_seek(seek_to.position); + // for some reason the position is stored in value, not in position + trace!("seek to {seek_to:?}"); + self.handle_seek(seek_to.value); self.notify().await.map(|_| Reply::Success)? } RequestCommand::SetShufflingContext(shuffle) => { @@ -976,7 +978,7 @@ impl SpircTask { state.player.position_as_of_timestamp } else { let time_since_position_update = timestamp - transfer.playback.timestamp; - state.player.position_as_of_timestamp + time_since_position_update + i64::from(transfer.playback.position_as_of_timestamp) + time_since_position_update }; if self.connect_state.context.is_some() { diff --git a/connect/src/state.rs b/connect/src/state.rs index d08f3e034..efd35e5a6 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -467,7 +467,6 @@ impl ConnectState { } if let Some(next_not_queued_track) = self - .player .next_tracks .iter() .position(|track| track.provider != QUEUE_PROVIDER) diff --git a/core/src/dealer/manager.rs b/core/src/dealer/manager.rs index b4a4a6638..65989c2b9 100644 --- a/core/src/dealer/manager.rs +++ b/core/src/dealer/manager.rs @@ -122,7 +122,7 @@ impl DealerManager { pub async fn start(&self) -> Result<(), Error> { let url = self.get_url().await?; - debug!("Launching dealer at {url}"); + debug!("Launching dealer"); let get_url = move || { let url = url.clone(); From 1a07ff535da49d61542df84e3aaeb6e7160d1172 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Mon, 21 Oct 2024 21:03:37 +0200 Subject: [PATCH 043/138] connect: replace error case with option --- connect/src/spirc.rs | 12 ++---------- connect/src/state.rs | 25 ++++++++++++------------- 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 538c1df10..df4aa8282 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1232,11 +1232,7 @@ impl SpircTask { let mut continue_playing = self.connect_state.player.is_playing; let new_track_index = loop { - let index = match self.connect_state.next_track() { - Ok(index) => Some(index), - Err(StateError::NoNextTrack) => break None, - Err(why) => return Err(why.into()), - }; + let index = self.connect_state.next_track()?; if track.is_some() && matches!(track, Some(ref track) if self.connect_state.player.track.uri != track.uri) @@ -1316,11 +1312,7 @@ impl SpircTask { // Under 3s it goes to the previous song (starts playing) // Over 3s it seeks to zero (retains previous play status) if self.position() < 3000 { - let new_track_index = match self.connect_state.prev_track() { - Ok(index) => Some(index), - Err(StateError::NoPrevTrack) => None, - Err(why) => return Err(why.into()), - }; + let new_track_index = self.connect_state.prev_track()?; if new_track_index.is_none() && self.connect_state.player.options.repeating_context { self.connect_state.reset_playback_context(None)? diff --git a/connect/src/state.rs b/connect/src/state.rs index efd35e5a6..63d000701 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -37,10 +37,6 @@ const UNAVAILABLE_PROVIDER: &str = "unavailable"; pub enum StateError { #[error("the current track couldn't be resolved from the transfer state")] CouldNotResolveTrackFromTransfer, - #[error("no next track available")] - NoNextTrack, - #[error("no prev track available")] - NoPrevTrack, #[error("message field {0} was not available")] MessageFieldNone(String), #[error("context is not available. shuffle: {0}")] @@ -320,7 +316,7 @@ impl ConnectState { /// /// 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 { + pub fn next_track(&mut self) -> Result, StateError> { let old_track = self.player.track.take(); if let Some(old_track) = old_track { @@ -334,10 +330,10 @@ impl ConnectState { } } - let new_track = self - .next_tracks - .pop_front() - .ok_or(StateError::NoNextTrack)?; + let new_track = match self.next_tracks.pop_front() { + None => return Ok(None), + Some(t) => t, + }; self.fill_up_next_tracks()?; @@ -367,7 +363,7 @@ impl ConnectState { self.update_restrictions(); - Ok(self.player.index.track) + Ok(Some(self.player.index.track)) } /// Move to the prev track @@ -375,7 +371,7 @@ impl ConnectState { /// 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<&MessageField, StateError> { + pub fn prev_track(&mut self) -> Result>, StateError> { let old_track = self.player.track.take(); if let Some(old_track) = old_track { @@ -388,7 +384,10 @@ impl ConnectState { let _ = self.next_tracks.pop_back(); } - let new_track = self.prev_tracks.pop_back().ok_or(StateError::NoPrevTrack)?; + let new_track = match self.prev_tracks.pop_back() { + None => return Ok(None), + Some(t) => t, + }; self.fill_up_next_tracks()?; @@ -403,7 +402,7 @@ impl ConnectState { self.update_restrictions(); - Ok(&self.player.track) + Ok(Some(&self.player.track)) } fn update_context_index(&mut self, new_index: usize) -> Result<(), StateError> { From 6c99a38243d722e72996f888447d505cd05b4276 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 26 Oct 2024 18:54:14 +0200 Subject: [PATCH 044/138] connect: use own context state --- connect/src/spirc.rs | 26 +++++---- connect/src/state.rs | 136 +++++++++++++++++++++++++++---------------- 2 files changed, 102 insertions(+), 60 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index df4aa8282..ef8dd766e 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -449,7 +449,11 @@ impl SpircTask { } else { match self.session.spclient().get_context(&context_uri).await { Err(why) => error!("failed to resolve context '{context_uri}': {why}"), - Ok(ctx) => self.connect_state.update_context(ctx), + Ok(ctx) => { + if let Err(why) = self.connect_state.update_context(ctx) { + error!("failed to resolve context '{context_uri}': {why}") + } + } }; if let Err(why) = self.notify().await { error!("failed to update connect state, after updating the context: {why}") @@ -473,11 +477,14 @@ impl SpircTask { None } }; - self.connect_state.update_context(Context { + + if let Err(why) = self.connect_state.update_context(Context { uri: self.connect_state.player.context_uri.to_owned(), pages: context.map(|page| vec![page]).unwrap_or_default(), ..Default::default() - }) + }) { + error!("failed updating context: {why}") + } } Err(err) => { error!("ContextError: {:?}", err) @@ -922,7 +929,7 @@ impl SpircTask { // resolve context asynchronously self.resolve_context = Some(ctx.uri) } else { - self.connect_state.update_context(ctx) + self.connect_state.update_context(ctx)? } } } @@ -964,7 +971,7 @@ impl SpircTask { .insert(key.clone(), value.clone()); } - if let Some((context, _)) = &state.context { + if let Some(context) = &state.context { for (key, value) in &context.metadata { state .player @@ -1084,7 +1091,7 @@ impl SpircTask { }; self.resolve_context(&mut ctx).await?; - self.connect_state.update_context(ctx); + self.connect_state.update_context(ctx)?; self.connect_state.next_tracks.clear(); self.connect_state.player.track = MessageField::none(); @@ -1243,13 +1250,13 @@ impl SpircTask { } }; - let (ctx, ctx_index) = self + let ctx = self .connect_state .context .as_ref() .ok_or(StateError::NoContext(false))?; let context_length = ctx.tracks.len() as u32; - let context_index = ctx_index.track; + let context_index = ctx.index.track; let update_tracks = self.autoplay_context && context_length - context_index < CONTEXT_FETCH_THRESHOLD; @@ -1264,9 +1271,6 @@ impl SpircTask { // When in autoplay, keep topping up the playlist when it nears the end if update_tracks { - if let Some((ctx, _)) = self.connect_state.context.as_ref() { - self.resolve_context = Some(ctx.next_page_url.to_owned()); - } todo!("update tracks from context: preloading"); } diff --git a/connect/src/state.rs b/connect/src/state.rs index 63d000701..de27270bb 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -1,4 +1,4 @@ -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::hash::{DefaultHasher, Hasher}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; @@ -11,14 +11,13 @@ use librespot_protocol::connect::{ Capabilities, Device, DeviceInfo, MemberType, PutStateReason, PutStateRequest, }; use librespot_protocol::player::{ - Context, ContextIndex, ContextPage, ContextPlayerOptions, ContextTrack, PlayOrigin, + Context, ContextIndex, ContextPlayerOptions, ContextTrack, PlayOrigin, PlayerState, ProvidedTrack, Suppressions, TransferState, }; use protobuf::{EnumOrUnknown, Message, MessageField}; use rand::prelude::SliceRandom; use thiserror::Error; -type ContextState = (ContextPage, ContextIndex); // these limitations are essential, otherwise to many tracks will overload the web-player const SPOTIFY_MAX_PREV_TRACKS_SIZE: usize = 10; @@ -74,6 +73,13 @@ impl Default for ConnectStateConfig { } } +#[derive(Debug, Clone)] +pub struct StateContext { + pub tracks: Vec, + pub metadata: HashMap, + pub index: ContextIndex, +} + #[derive(Default, Debug)] pub struct ConnectState { pub active: bool, @@ -99,9 +105,9 @@ pub struct ConnectState { // the context from which we play, is used to top up prev and next tracks // the index is used to keep track which tracks are already loaded into next tracks - pub context: Option, + pub context: Option, // a context to keep track of our shuffled context, should be only available when option.shuffling_context is true - pub shuffle_context: Option, + pub shuffle_context: Option, // is set when we receive a transfer state and are loading the context asynchronously pub transfer_state: Option, @@ -270,13 +276,20 @@ impl ConnectState { self.prev_tracks.clear(); self.clear_next_tracks(); - let (ctx, _) = self.context.as_mut().ok_or(StateError::NoContext(false))?; + let current_uri = &self.player.track.uri; + let current_track = self.find_index_in_context(|t| &t.uri == current_uri)?; + + let ctx = self.context.as_mut().ok_or(StateError::NoContext(false))?; 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, ContextIndex::new())); + self.shuffle_context = Some(shuffle_context); self.fill_up_next_tracks()?; Ok(()) @@ -285,7 +298,7 @@ impl ConnectState { // endregion pub fn set_current_track(&mut self, index: usize, shuffle_context: bool) -> Result<(), Error> { - let (context, _) = if shuffle_context { + let context = if shuffle_context { self.shuffle_context.as_ref() } else { self.context.as_ref() @@ -306,8 +319,7 @@ impl ConnectState { context.tracks.len() ); - let new_track = self.context_to_provided_track(new_track)?; - self.player.track = MessageField::some(new_track); + self.player.track = MessageField::some(new_track.clone()); Ok(()) } @@ -347,7 +359,7 @@ impl ConnectState { match new_index { Ok(new_index) => Some(new_index as u32), Err(why) => { - error!("didn't find the shuffled track in the current context: {why}"); + error!("didn't find the track in the current context: {why}"); None } } @@ -406,14 +418,14 @@ impl ConnectState { } fn update_context_index(&mut self, new_index: usize) -> Result<(), StateError> { - let (_, context_index) = if self.player.options.shuffling_context { + let context = if self.player.options.shuffling_context { self.shuffle_context.as_mut() } else { self.context.as_mut() } .ok_or(StateError::NoContext(self.player.options.shuffling_context))?; - context_index.track = new_index as u32; + context.index.track = new_index as u32; Ok(()) } @@ -433,7 +445,7 @@ impl ConnectState { self.prev_tracks.clear(); - let (context, _) = self.context.as_ref().ok_or(StateError::NoContext(false))?; + let context = self.context.as_ref().ok_or(StateError::NoContext(false))?; if new_index > 0 { let rev_ctx = context .tracks @@ -443,8 +455,7 @@ impl ConnectState { .take(SPOTIFY_MAX_PREV_TRACKS_SIZE); for track in rev_ctx { - self.prev_tracks - .push_back(self.context_to_provided_track(track)?) + self.prev_tracks.push_back(track.clone()) } } @@ -485,12 +496,29 @@ impl ConnectState { self.update_restrictions(); } - pub fn update_context(&mut self, mut context: Context) { + pub fn update_context(&mut self, mut context: Context) -> Result<(), Error> { debug!("context: {}, {}", context.uri, context.url); - self.context = context - .pages - .pop() - .map(|ctx| (ctx, ContextIndex::default())); + let page = context.pages.pop().ok_or(StateError::NoContext(false))?; + + let tracks = page + .tracks + .iter() + .flat_map( + |track| match self.context_to_provided_track(track, None, None) { + Ok(t) => Some(t), + Err(_) => { + error!("couldn't convert {track:#?} into ProvidedTrack"); + None + } + }, + ) + .collect(); + + self.context = Some(StateContext { + tracks, + metadata: page.metadata, + index: ContextIndex::new(), + }); self.player.context_url = format!("context://{}", context.uri); self.player.context_uri = context.uri; @@ -504,10 +532,10 @@ impl ConnectState { } if let Some(transfer_state) = self.transfer_state.take() { - if let Err(why) = self.setup_current_state(transfer_state) { - error!("setting up current state failed after updating the context: {why}") - } + self.setup_current_state(transfer_state)? } + + Ok(()) } pub fn try_get_current_track_from_transfer( @@ -521,7 +549,11 @@ impl ConnectState { } .ok_or(StateError::CouldNotResolveTrackFromTransfer)?; - self.context_to_provided_track(track) + self.context_to_provided_track( + track, + None, + transfer.queue.is_playing_queue.then_some(QUEUE_PROVIDER), + ) } pub fn setup_current_state(&mut self, transfer: TransferState) -> Result<(), Error> { @@ -532,6 +564,7 @@ impl ConnectState { let current_index = self.find_index_in_context(|c| c.uri == track.uri || c.uid == track.uid)?; + if self.player.track.is_none() { self.player.track = MessageField::some(track); } @@ -611,28 +644,21 @@ impl ConnectState { } fn fill_up_next_tracks(&mut self) -> Result<(), StateError> { - let (ctx, ctx_index) = if self.player.options.shuffling_context { + let ctx = if self.player.options.shuffling_context { self.shuffle_context.as_ref() } else { self.context.as_ref() } .ok_or(StateError::NoContext(self.player.options.shuffling_context))?; - let mut new_index = ctx_index.track as usize; + let mut new_index = ctx.index.track as usize; while self.next_tracks.len() < SPOTIFY_MAX_NEXT_TRACKS_SIZE { let track = match ctx.tracks.get(new_index) { None => { // todo: what do we do if we can't fill up anymore? autoplay? break; } - Some(ct) => match self.context_to_provided_track(ct) { - Err(why) => { - error!("bad thing happened: {why}"); - // todo: handle bad things probably - break; - } - Ok(track) => track, - }, + Some(ct) => ct.clone(), }; new_index += 1; @@ -647,11 +673,11 @@ impl ConnectState { Ok(()) } - pub fn find_index_in_context bool>( + pub fn find_index_in_context bool>( &self, f: F, ) -> Result { - let (ctx, _) = self.context.as_ref().ok_or(StateError::NoContext(false))?; + let ctx = self.context.as_ref().ok_or(StateError::NoContext(false))?; ctx.tracks .iter() @@ -669,31 +695,43 @@ impl ConnectState { pub fn context_to_provided_track( &self, ctx_track: &ContextTrack, + metadata: Option>, + provider: Option<&str>, ) -> Result { let provider = if self.unavailable_uri.contains(&ctx_track.uri) { UNAVAILABLE_PROVIDER } else { - CONTEXT_PROVIDER + provider.unwrap_or(CONTEXT_PROVIDER) }; - let uri = if !ctx_track.uri.is_empty() { - ctx_track.uri.to_owned() + let id = if !ctx_track.uri.is_empty() { + SpotifyId::from_uri(&ctx_track.uri) } else if !ctx_track.gid.is_empty() { - SpotifyId::from_raw(&ctx_track.gid)? - .to_uri()? - .replace("unknown", "track") + SpotifyId::from_raw(&ctx_track.gid) } else if !ctx_track.uid.is_empty() { - SpotifyId::from_raw(ctx_track.uid.as_bytes())? - .to_uri()? - .replace("unknown", "track") + SpotifyId::from_raw(ctx_track.uid.as_bytes()) } else { return Err(Error::unavailable("track not available")); + }?; + + let mut metadata = metadata.unwrap_or_default(); + + if !ctx_track.metadata.is_empty() { + for (k, v) in &ctx_track.metadata { + metadata.insert(k.to_string(), v.to_string()); + } + } + + let uid = if !ctx_track.uid.is_empty() { + ctx_track.uid.clone() + } else { + String::from_utf8(id.to_raw().to_vec()).unwrap_or_else(|_| "unknown".to_string()) }; Ok(ProvidedTrack { - uri, - uid: ctx_track.uid.to_string(), - metadata: ctx_track.metadata.clone(), + uri: id.to_uri()?.replace("unknown", "track"), + uid, + metadata, provider: provider.to_string(), ..Default::default() }) From 9856aebf2432496bee74688fded98202991d21c8 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 26 Oct 2024 19:19:58 +0200 Subject: [PATCH 045/138] connect: more stabilising - handle SkipTo having no Index - handle no transferred restrictions - handle no transferred index - update state before shutdown, for smoother reacquiring --- connect/src/spirc.rs | 29 ++++++++++++++++++++++++++++- connect/src/state.rs | 12 ++++++++++++ core/src/dealer/protocol/request.rs | 8 +++++++- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index ef8dd766e..4d65736ea 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -130,7 +130,31 @@ pub struct SpircLoadCommand { pub shuffle: bool, pub repeat: bool, pub repeat_track: bool, - pub playing_track_index: u32, + 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 is important as it seems that the index can be 0, + // but there might still be a uid or uri provided, so we try 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 + })) + } + } } const CONTEXT_FETCH_THRESHOLD: u32 = 5; @@ -416,6 +440,9 @@ impl SpircTask { } } + if let Err(why) = self.notify().await { + warn!("last notify before shutdown couldn't be send: {why}") + } self.session.dealer().close().await; } diff --git a/connect/src/state.rs b/connect/src/state.rs index de27270bb..e33ff7423 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -569,6 +569,16 @@ impl ConnectState { self.player.track = MessageField::some(track); } + if let Some(index) = self.player.index.as_mut() { + index.track = current_index as u32; + } else { + self.player.index = MessageField::some(ContextIndex { + page: 0, + track: current_index as u32, + ..Default::default() + }) + } + debug!( "setting up next and prev: index is at {current_index} while shuffle {}", self.player.options.shuffling_context @@ -583,6 +593,8 @@ impl ConnectState { self.reset_playback_context(Some(current_index))?; } + self.update_restrictions(); + Ok(()) } diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index d41fe2c44..a9ecb5a82 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -145,13 +145,19 @@ pub struct PlayOptions { #[serde(default, deserialize_with = "option_json_proto")] pub player_option_overrides: Option, pub license: String, + // 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 SkipTo { pub track_uid: Option, pub track_uri: Option, - pub track_index: u32, + pub track_index: Option, } #[derive(Clone, Debug, Deserialize)] From fdac4b2d11f5884f40ca1113fa67c7b15738662a Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 27 Oct 2024 00:49:22 +0200 Subject: [PATCH 046/138] connect: working autoplay --- connect/src/spirc.rs | 375 ++++++++++++++++++++----------------------- connect/src/state.rs | 303 ++++++++++++++++++++++++---------- core/src/spclient.rs | 21 ++- protocol/build.rs | 1 + 4 files changed, 413 insertions(+), 287 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 4d65736ea..ceb68fdab 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,6 +1,5 @@ -use crate::state::{ConnectState, ConnectStateConfig, StateError, CONTEXT_PROVIDER}; +use crate::state::{ConnectState, ConnectStateConfig, ContextType, AUTOPLAY_PROVIDER}; use crate::{ - context::PageContext, core::{authentication::Credentials, session::UserAttributes, Error, Session, SpotifyId}, playback::{ mixer::Mixer, @@ -12,9 +11,10 @@ use crate::{ }; use futures_util::{FutureExt, Stream, StreamExt}; use librespot_core::dealer::manager::{Reply, RequestReply}; -use librespot_core::dealer::protocol::{PayloadValue, RequestCommand, SetQueueCommand}; +use librespot_core::dealer::protocol::{PayloadValue, RequestCommand, SetQueueCommand, SkipTo}; +use librespot_protocol::autoplay_context_request::AutoplayContextRequest; use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason, SetVolumeCommand}; -use librespot_protocol::player::{Context, ProvidedTrack, TransferState}; +use librespot_protocol::player::{ProvidedTrack, TransferState}; use protobuf::{Message, MessageField}; use std::{ future::Future, @@ -39,13 +39,17 @@ pub enum SpircError { Ident(String), #[error("message pushed for another URI")] InvalidUri(String), + #[error("tried resolving not allowed context: {0:?}")] + NotAllowedContext(ResolveContext), } impl From for Error { fn from(err: SpircError) -> Self { use SpircError::*; match err { - NoData | UnsupportedLocalPlayBack | UnexpectedData(_) => Error::unavailable(err), + NoData | UnsupportedLocalPlayBack | UnexpectedData(_) | NotAllowedContext(_) => { + Error::unavailable(err) + } Ident(_) | InvalidUri(_) => Error::aborted(err), } } @@ -70,6 +74,12 @@ pub(crate) enum SpircPlayStatus { }, } +#[derive(Debug)] +pub struct ResolveContext { + uri: String, + autoplay: bool, +} + type BoxedStream = Pin + Send>>; struct SpircTask { @@ -93,9 +103,8 @@ struct SpircTask { shutdown: bool, session: Session, - resolve_context: Option, + resolve_context: Vec, update_volume: bool, - autoplay_context: bool, spirc_id: usize, } @@ -157,7 +166,7 @@ impl From for PlayingTrack { } } -const CONTEXT_FETCH_THRESHOLD: u32 = 5; +const CONTEXT_FETCH_THRESHOLD: usize = 2; const VOLUME_STEP_SIZE: u16 = 1024; // (u16::MAX + 1) / VOLUME_STEPS const VOLUME_UPDATE_DELAY_MS: u64 = 2000; @@ -279,9 +288,8 @@ impl Spirc { shutdown: false, session, - resolve_context: None, + resolve_context: Vec::new(), update_volume: false, - autoplay_context: false, spirc_id, }; @@ -425,8 +433,10 @@ impl SpircTask { error!("could not dispatch player event: {}", e); } }, - context_uri = async { self.resolve_context.take() }, if self.resolve_context.is_some() => { - self.handle_resolve_context(context_uri.unwrap()).await + _ = async {}, if !self.resolve_context.is_empty() => { + if let Err(why) = self.handle_resolve_context().await { + error!("ContextError: {why}") + } }, _ = async { sleep(Duration::from_millis(VOLUME_UPDATE_DELAY_MS)).await }, if self.update_volume => { self.update_volume = false; @@ -446,77 +456,70 @@ impl SpircTask { self.session.dealer().close().await; } - async fn handle_resolve_context(&mut self, context_uri: String) { - if context_uri.contains("spotify:show:") || context_uri.contains("spotify:episode:") { - // todo: did check and it is supported by context-resolve, maybe remove this check or adjust it - return; // not supported by apollo stations + async fn handle_resolve_context(&mut self) -> Result<(), Error> { + while let Some(resolve) = self.resolve_context.pop() { + self.resolve_context(resolve.uri, resolve.autoplay).await?; } - let context = if context_uri.starts_with("hm://") { - self.session.spclient().get_next_page(&context_uri).await - } else if self.autoplay_context { - let previous_tracks = self - .connect_state - .prev_tracks - .iter() - .map(|t| SpotifyId::from_uri(&t.uri)) - .filter_map(Result::ok) - .collect(); - - self.session - .spclient() - .get_apollo_station( - "stations", // this returns a `StationContext` but we deserialize it into a `PageContext` - &context_uri, - None, - previous_tracks, - self.autoplay_context, - ) - .await - } else { + self.connect_state.fill_up_next_tracks()?; + self.connect_state.update_restrictions(); + + self.conditional_preload_autoplay(self.connect_state.player.context_uri.clone()); + + self.notify().await + } + + async fn resolve_context(&mut self, context_uri: String, autoplay: bool) -> Result<(), Error> { + if !autoplay { match self.session.spclient().get_context(&context_uri).await { Err(why) => error!("failed to resolve context '{context_uri}': {why}"), - Ok(ctx) => { - if let Err(why) = self.connect_state.update_context(ctx) { - error!("failed to resolve context '{context_uri}': {why}") - } - } + Ok(ctx) => self.connect_state.update_context(ctx)?, }; if let Err(why) = self.notify().await { error!("failed to update connect state, after updating the context: {why}") } - return; - }; + return Ok(()); + } - match context { - Ok(value) => { - let context = match serde_json::from_slice::(&value) { - Ok(context) => { - info!( - "Resolved {:?} tracks from <{:?}>", - context.tracks.len(), - self.connect_state.player.context_uri, - ); - Some(context.into()) - } - Err(e) => { - error!("Unable to parse JSONContext {:?}", e); - None - } - }; + if context_uri.contains("spotify:show:") || context_uri.contains("spotify:episode:") { + // autoplay is not supported for podcasts + return Err(SpircError::NotAllowedContext(ResolveContext { + uri: context_uri, + autoplay: true, + }) + .into()); + } - if let Err(why) = self.connect_state.update_context(Context { - uri: self.connect_state.player.context_uri.to_owned(), - pages: context.map(|page| vec![page]).unwrap_or_default(), - ..Default::default() - }) { - error!("failed updating context: {why}") - } - } - Err(err) => { - error!("ContextError: {:?}", err) - } + let mut previous_tracks = self + .connect_state + .prev_tracks + .iter() + .flat_map(|t| (t.provider == AUTOPLAY_PROVIDER).then_some(t.uri.clone())) + .collect::>(); + + let current = &self.connect_state.player.track; + if current.provider == AUTOPLAY_PROVIDER { + previous_tracks.push(current.uri.clone()); } + + debug!( + "loading autoplay context {context_uri} with {} previous tracks", + previous_tracks.len() + ); + + let ctx_request = AutoplayContextRequest { + context_uri: Some(context_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_autoplay_context(context) } fn now_ms(&self) -> i64 { @@ -759,7 +762,7 @@ impl SpircTask { { if !transfer_state.current_session.context.pages.is_empty() { info!("received transfer state with context, trying to take over control again"); - match self.handle_transfer(transfer_state).await { + match self.handle_transfer(transfer_state) { Ok(_) => info!("successfully re-acquired control"), Err(why) => error!("failed handling transfer state: {why}"), } @@ -831,7 +834,7 @@ impl SpircTask { &mut self, mut cluster_update: ClusterUpdate, ) -> Result<(), Error> { - let reason = cluster_update.update_reason.enum_value_or_default(); + let reason = cluster_update.update_reason.enum_value().ok(); let device_ids = cluster_update.devices_that_changed.join(", "); let devices = cluster_update.cluster.device.len(); @@ -878,8 +881,7 @@ impl SpircTask { let response = match request.command { RequestCommand::Transfer(transfer) if transfer.data.is_some() => { - self.handle_transfer(transfer.data.expect("by condition checked")) - .await?; + self.handle_transfer(transfer.data.expect("by condition checked"))?; self.notify().await?; Reply::Success @@ -892,7 +894,7 @@ impl SpircTask { self.handle_load(SpircLoadCommand { context_uri: play.context.uri.clone(), start_playing: true, - playing_track_index: play.options.skip_to.track_index, + playing_track: play.options.skip_to.into(), shuffle: self.connect_state.player.options.shuffling_context, repeat: self.connect_state.player.options.repeating_context, repeat_track: self.connect_state.player.options.repeating_track, @@ -949,18 +951,24 @@ impl SpircTask { sender.send(response).map_err(Into::into) } - async fn handle_transfer(&mut self, mut transfer: TransferState) -> Result<(), Error> { - if let Some(session) = transfer.current_session.as_mut() { - if let Some(ctx) = session.context.take() { - if ctx.pages.is_empty() { - // resolve context asynchronously - self.resolve_context = Some(ctx.uri) - } else { - self.connect_state.update_context(ctx)? - } - } + fn handle_transfer(&mut self, mut transfer: TransferState) -> Result<(), Error> { + self.connect_state + .reset_context(&transfer.current_session.context.uri); + + let mut ctx_uri = transfer.current_session.context.uri.to_owned(); + let autoplay = ctx_uri.contains("station"); + + if autoplay { + ctx_uri = ctx_uri.replace("station:", ""); + self.connect_state.active_context = ContextType::Autoplay; } + debug!("async resolve context for {}", ctx_uri); + self.resolve_context.push(ResolveContext { + autoplay: false, + uri: ctx_uri.clone(), + }); + let timestamp = self.now_ms(); let state = &mut self.connect_state; @@ -1018,12 +1026,26 @@ impl SpircTask { if self.connect_state.context.is_some() { self.connect_state.setup_current_state(transfer)?; } else { + debug!("trying to find initial track"); match self .connect_state .try_get_current_track_from_transfer(&transfer) { Err(why) => warn!("{why}"), - Ok(track) => self.connect_state.player.track = MessageField::some(track), + Ok(track) => { + debug!("initial track found"); + self.connect_state.player.track = MessageField::some(track) + } + } + + if self.connect_state.autoplay_context.is_none() + && (self.connect_state.player.track.provider == AUTOPLAY_PROVIDER || autoplay) + { + debug!("currently in autoplay context, async resolving autoplay for {ctx_uri}"); + self.resolve_context.push(ResolveContext { + uri: ctx_uri, + autoplay: true, + }) } self.connect_state.transfer_state = Some(transfer); @@ -1032,38 +1054,6 @@ impl SpircTask { self.load_track(self.connect_state.player.is_playing, position.try_into()?) } - async fn resolve_context(&mut self, ctx: &mut Context) -> Result<(), Error> { - if ctx.uri.starts_with("spotify:local-files") { - return Err(SpircError::UnsupportedLocalPlayBack.into()); - } - - if !ctx.pages.is_empty() { - debug!("context already contains pages to use"); - return Ok(()); - } - - debug!("context didn't had any tracks, resolving tracks..."); - let resolved_ctx = self.session.spclient().get_context(&ctx.uri).await?; - - debug!( - "context resolved {} pages and {} tracks in total", - resolved_ctx.pages.len(), - resolved_ctx - .pages - .iter() - .map(|p| p.tracks.len()) - .sum::() - ); - - ctx.pages = resolved_ctx.pages; - ctx.metadata = resolved_ctx.metadata; - ctx.restrictions = resolved_ctx.restrictions; - ctx.loading = resolved_ctx.loading; - ctx.special_fields = resolved_ctx.special_fields; - - Ok(()) - } - async fn handle_disconnect(&mut self) -> Result<(), Error> { self.connect_state.set_active(false); self.notify().await?; @@ -1108,25 +1098,44 @@ impl SpircTask { } async fn handle_load(&mut self, cmd: SpircLoadCommand) -> Result<(), Error> { + self.connect_state.reset_context(&cmd.context_uri); + if !self.connect_state.active { self.handle_activate(); } - let mut ctx = Context { - uri: cmd.context_uri, - ..Default::default() - }; - - self.resolve_context(&mut ctx).await?; - self.connect_state.update_context(ctx)?; + if self.connect_state.player.context_uri == cmd.context_uri + && self.connect_state.context.is_some() + { + debug!("context didn't change, no resolving required") + } else { + debug!("resolving context for load command"); + self.resolve_context(cmd.context_uri.clone(), false).await?; + } self.connect_state.next_tracks.clear(); self.connect_state.player.track = MessageField::none(); - let index = cmd.playing_track_index as usize; + 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)? + } + }; + + if let Some(i) = self.connect_state.player.index.as_mut() { + i.track = index as u32; + } + self.connect_state.set_shuffle(cmd.shuffle); if cmd.shuffle { - self.connect_state.set_current_track(index, false)?; + self.connect_state.active_context = ContextType::Default; + self.connect_state.set_current_track(index)?; self.connect_state.shuffle()?; } else { self.connect_state.reset_playback_context(Some(index))?; @@ -1135,12 +1144,20 @@ impl SpircTask { self.connect_state.set_repeat_context(cmd.repeat); self.connect_state.set_repeat_track(cmd.repeat_track); - if !self.connect_state.next_tracks.is_empty() { - self.load_track(self.connect_state.player.is_playing, 0)?; + if self.connect_state.player.track.is_some() { + self.load_track(cmd.start_playing, 0)?; } else { - info!("No more tracks left in queue"); + info!("No active track, stopping"); self.handle_stop(); } + + if self.connect_state.next_tracks.is_empty() && self.session.autoplay() { + self.resolve_context.push(ResolveContext { + uri: cmd.context_uri, + autoplay: true, + }) + } + Ok(()) } @@ -1250,8 +1267,6 @@ impl SpircTask { if let Some(track_id) = self.preview_next_track() { self.player.preload(track_id); - } else { - self.handle_stop(); } } @@ -1261,9 +1276,24 @@ impl SpircTask { self.handle_preload_next_track(); } + fn conditional_preload_autoplay(&mut self, uri: String) { + let preload_autoplay = self.connect_state.next_tracks.len() < CONTEXT_FETCH_THRESHOLD + && self.session.autoplay(); + + // When in autoplay, keep topping up the playlist when it nears the end + if preload_autoplay { + debug!("Preloading autoplay context for <{}>", uri); + // resolve the next autoplay context + self.resolve_context.push(ResolveContext { + uri, + autoplay: true, + }); + } + } + fn handle_next(&mut self, track: Option) -> Result<(), Error> { let context_uri = self.connect_state.player.context_uri.to_owned(); - let mut continue_playing = self.connect_state.player.is_playing; + let continue_playing = self.connect_state.player.is_playing; let new_track_index = loop { let index = self.connect_state.next_track()?; @@ -1277,58 +1307,9 @@ impl SpircTask { } }; - let ctx = self - .connect_state - .context - .as_ref() - .ok_or(StateError::NoContext(false))?; - let context_length = ctx.tracks.len() as u32; - let context_index = ctx.index.track; - - let update_tracks = - self.autoplay_context && context_length - context_index < CONTEXT_FETCH_THRESHOLD; - - debug!( - "At context track {:?} of {:?} <{:?}> update [{}]", - context_index + 1, - context_length, - context_uri, - update_tracks, - ); - - // When in autoplay, keep topping up the playlist when it nears the end - if update_tracks { - todo!("update tracks from context: preloading"); - } - - // When not in autoplay, either start autoplay or loop back to the start - if matches!(new_track_index, Some(i) if i >= context_length) || new_track_index.is_none() { - // for some contexts there is no autoplay, such as shows and episodes - // in such cases there is no context in librespot. - if self.connect_state.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(context_uri); - self.player.set_auto_normalise_as_album(false); - todo!("update tracks from context: autoplay"); - } else { - if self.connect_state.player.options.shuffling_context { - self.connect_state.shuffle()? - } else { - self.connect_state.reset_playback_context(None)?; - } + self.conditional_preload_autoplay(context_uri.clone()); - continue_playing &= self.connect_state.player.options.repeating_context; - debug!( - "Looping back to start, repeating_context is {}", - continue_playing - ); - } - } - - if context_length > 0 { + if new_track_index.is_some() { self.load_track(continue_playing, 0) } else { info!("Not playing next track because there are no more tracks left in queue."); @@ -1395,20 +1376,6 @@ impl SpircTask { let id = SpotifyId::from_uri(&track_to_load.uri)?; self.player.load(id, start_playing, position_ms); - // todo: move into state.rs - const CONTEXT_URI_METADATA: &str = "context_uri"; - const ENTITY_URI_METADATA: &str = "entity_uri"; - if track_to_load.provider == CONTEXT_PROVIDER { - track_to_load.metadata.insert( - CONTEXT_URI_METADATA.to_string(), - self.connect_state.player.context_uri.to_owned(), - ); - track_to_load.metadata.insert( - ENTITY_URI_METADATA.to_string(), - self.connect_state.player.context_uri.to_owned(), - ); - } - self.update_state_position(position_ms); if start_playing { self.play_status = SpircPlayStatus::LoadingPlay { position_ms }; @@ -1452,13 +1419,15 @@ impl SpircTask { self.connect_state.shuffle() } else { self.connect_state.shuffle_context = None; + self.connect_state.active_context = ContextType::Default; let state = &mut self.connect_state; let current_index = match state.player.track.as_ref() { None => None, - Some(track) => state - .find_index_in_context(|c| c.uri == track.uri) - .map(Some)?, + Some(track) => { + let ctx = state.context.as_ref(); + ConnectState::find_index_in_context(ctx, |c| c.uri == track.uri).map(Some)? + } }; state.reset_playback_context(current_index) diff --git a/connect/src/state.rs b/connect/src/state.rs index e33ff7423..0ae8f1fb6 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -11,35 +11,38 @@ use librespot_protocol::connect::{ Capabilities, Device, DeviceInfo, MemberType, PutStateReason, PutStateRequest, }; use librespot_protocol::player::{ - Context, ContextIndex, ContextPlayerOptions, ContextTrack, PlayOrigin, - PlayerState, ProvidedTrack, Suppressions, TransferState, + Context, ContextIndex, ContextPlayerOptions, ContextTrack, PlayOrigin, PlayerState, + ProvidedTrack, Restrictions, Suppressions, TransferState, }; use protobuf::{EnumOrUnknown, Message, MessageField}; use rand::prelude::SliceRandom; 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; // provider used by spotify -pub(crate) const CONTEXT_PROVIDER: &str = "context"; -const QUEUE_PROVIDER: &str = "queue"; +pub const CONTEXT_PROVIDER: &str = "context"; +pub const QUEUE_PROVIDER: &str = "queue"; +pub const AUTOPLAY_PROVIDER: &str = "autoplay"; // todo: there is a separator provider which is used to realise repeat // our own provider to flag tracks as a specific states // todo: we might just need to remove tracks that are unavailable to play, will have to see how the official clients handle this provider const UNAVAILABLE_PROVIDER: &str = "unavailable"; +pub const METADATA_CONTEXT_URI: &str = "context_uri"; +pub const METADATA_ENTITY_URI: &str = "entity_uri"; + #[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. shuffle: {0}")] - NoContext(bool), + #[error("context is not available. shuffle: {0:?}")] + NoContext(ContextType), #[error("could not find track {0:?} in context of {1}")] CanNotFindTrackInContext(Option, usize), } @@ -80,6 +83,14 @@ pub struct StateContext { pub index: ContextIndex, } +#[derive(Default, Debug, Copy, Clone)] +pub enum ContextType { + #[default] + Default, + Shuffle, + Autoplay, +} + #[derive(Default, Debug)] pub struct ConnectState { pub active: bool, @@ -90,24 +101,27 @@ pub struct ConnectState { pub device: DeviceInfo, unavailable_uri: Vec, - // is only some when we're playing a queued item and have to preserve the index + /// is only some when we're playing a queued item and have to preserve the index player_index: Option, - // index: 0 based, so the first track is index 0 - // prev_track: bottom => top, aka the last track of the list is the prev track - // next_track: top => bottom, aka the first track of the list is the next track + /// index: 0 based, so the first track is index 0 + /// prev_track: bottom => top, aka the last track of the list is the prev track + /// next_track: top => bottom, aka the first track of the list is the next track pub player: PlayerState, - // we don't work directly on the lists of the player state, because - // we mostly need to push and pop at the beginning of both + /// we don't work directly on the lists of the player state, because + /// we mostly need to push and pop at the beginning of both pub prev_tracks: VecDeque, pub next_tracks: VecDeque, - // the context from which we play, is used to top up prev and next tracks - // the index is used to keep track which tracks are already loaded into next tracks + pub active_context: ContextType, + /// the context from which we play, is used to top up prev and next tracks + /// the index is used to keep track which tracks are already loaded into next tracks pub context: Option, - // a context to keep track of our shuffled context, should be only available when option.shuffling_context is true + /// a context to keep track of our shuffled context, should be only available when option.shuffling_context is true pub shuffle_context: Option, + /// a context to keep track of the autoplay context + pub autoplay_context: Option, // is set when we receive a transfer state and are loading the context asynchronously pub transfer_state: Option, @@ -163,8 +177,9 @@ impl ConnectState { }), ..Default::default() }, - prev_tracks: VecDeque::with_capacity(SPOTIFY_MAX_PREV_TRACKS_SIZE), - next_tracks: VecDeque::with_capacity(SPOTIFY_MAX_NEXT_TRACKS_SIZE), + // + 1, so that we have a buffer where we can swap elements + prev_tracks: VecDeque::with_capacity(SPOTIFY_MAX_PREV_TRACKS_SIZE + 1), + next_tracks: VecDeque::with_capacity(SPOTIFY_MAX_NEXT_TRACKS_SIZE + 1), ..Default::default() }; state.reset(); @@ -277,9 +292,12 @@ impl ConnectState { self.clear_next_tracks(); let current_uri = &self.player.track.uri; - let current_track = self.find_index_in_context(|t| &t.uri == current_uri)?; - let ctx = self.context.as_mut().ok_or(StateError::NoContext(false))?; + let ctx = self + .context + .as_mut() + .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 @@ -290,6 +308,7 @@ impl ConnectState { shuffle_context.index = ContextIndex::new(); self.shuffle_context = Some(shuffle_context); + self.active_context = ContextType::Shuffle; self.fill_up_next_tracks()?; Ok(()) @@ -297,13 +316,8 @@ impl ConnectState { // endregion - pub fn set_current_track(&mut self, index: usize, shuffle_context: bool) -> Result<(), Error> { - let context = if shuffle_context { - self.shuffle_context.as_ref() - } else { - self.context.as_ref() - } - .ok_or(StateError::NoContext(shuffle_context))?; + pub fn set_current_track(&mut self, index: usize) -> Result<(), Error> { + let context = self.get_current_context()?; let new_track = context .tracks @@ -314,7 +328,8 @@ impl ConnectState { ))?; debug!( - "set track to: {} at {index} of {} tracks", + "set track to: {} at {} of {} tracks", + index + 1, new_track.uri, context.tracks.len() ); @@ -333,7 +348,7 @@ impl ConnectState { if let Some(old_track) = old_track { // only add songs from our context to our previous tracks - if old_track.provider == CONTEXT_PROVIDER { + if old_track.provider == CONTEXT_PROVIDER || old_track.provider == AUTOPLAY_PROVIDER { // add old current track to prev tracks, while preserving a length of 10 if self.prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { self.prev_tracks.pop_front(); @@ -350,12 +365,16 @@ impl ConnectState { self.fill_up_next_tracks()?; let is_queued_track = new_track.provider == QUEUE_PROVIDER; - let update_index = if is_queued_track { + let is_autoplay = new_track.provider == AUTOPLAY_PROVIDER; + let update_index = if (is_queued_track || is_autoplay) && self.player.index.is_some() { // the index isn't send when we are a queued track, but we have to preserve it for later self.player_index = self.player.index.take(); None + } else if is_autoplay || is_queued_track { + None } else { - let new_index = self.find_index_in_context(|c| c.uri == new_track.uri); + 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) => { @@ -368,6 +387,8 @@ impl ConnectState { if let Some(update_index) = update_index { if let Some(index) = self.player.index.as_mut() { index.track = update_index + } else { + debug!("next: index can't be updated, no index available") } } @@ -387,7 +408,7 @@ impl ConnectState { let old_track = self.player.track.take(); if let Some(old_track) = old_track { - if old_track.provider == CONTEXT_PROVIDER { + if old_track.provider == CONTEXT_PROVIDER || old_track.provider == AUTOPLAY_PROVIDER { self.next_tracks.push_front(old_track); } } @@ -401,16 +422,23 @@ impl ConnectState { Some(t) => t, }; + if matches!(self.active_context, ContextType::Autoplay if new_track.provider == CONTEXT_PROVIDER) + { + // transition back to default context + self.active_context = ContextType::Default; + } + self.fill_up_next_tracks()?; self.player.track = MessageField::some(new_track); - let index = self - .player - .index - .as_mut() - .ok_or(StateError::MessageFieldNone("player.index".to_string()))?; - index.track -= 1; + if self.player.index.track <= 0 { + warn!("prev: trying to skip into negative, index update skipped") + } else if let Some(index) = self.player.index.as_mut() { + index.track -= 1; + } else { + debug!("prev: index can't be decreased, no index available") + } self.update_restrictions(); @@ -418,17 +446,28 @@ impl ConnectState { } fn update_context_index(&mut self, new_index: usize) -> Result<(), StateError> { - let context = if self.player.options.shuffling_context { - self.shuffle_context.as_mut() - } else { - self.context.as_mut() + let context = match self.active_context { + ContextType::Default => self.context.as_mut(), + ContextType::Shuffle => self.shuffle_context.as_mut(), + ContextType::Autoplay => self.autoplay_context.as_mut(), } - .ok_or(StateError::NoContext(self.player.options.shuffling_context))?; + .ok_or(StateError::NoContext(self.active_context))?; context.index.track = new_index as u32; Ok(()) } + pub fn reset_context(&mut self, new_context: &str) { + self.active_context = ContextType::Default; + + self.autoplay_context = None; + self.shuffle_context = None; + + if self.player.context_uri != new_context { + self.context = None; + } + } + pub fn reset_playback_context(&mut self, new_index: Option) -> Result<(), Error> { let new_index = new_index.unwrap_or(0); if let Some(player_index) = self.player.index.as_mut() { @@ -440,23 +479,25 @@ impl ConnectState { debug!("reset playback state to {new_index}"); if self.player.track.provider != QUEUE_PROVIDER { - self.set_current_track(new_index, self.player.options.shuffling_context)?; + self.set_current_track(new_index)?; } self.prev_tracks.clear(); - let context = self.context.as_ref().ok_or(StateError::NoContext(false))?; if new_index > 0 { - let rev_ctx = context + let context = self.get_current_context()?; + + let before_new_track = context.tracks.len() - new_index; + self.prev_tracks = context .tracks .iter() .rev() - .skip(context.tracks.len() - new_index) - .take(SPOTIFY_MAX_PREV_TRACKS_SIZE); - - for track in rev_ctx { - self.prev_tracks.push_back(track.clone()) - } + .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(); @@ -498,20 +539,23 @@ impl ConnectState { pub fn update_context(&mut self, mut context: Context) -> Result<(), Error> { debug!("context: {}, {}", context.uri, context.url); - let page = context.pages.pop().ok_or(StateError::NoContext(false))?; + let page = context + .pages + .pop() + .ok_or(StateError::NoContext(ContextType::Default))?; let tracks = page .tracks .iter() - .flat_map( - |track| match self.context_to_provided_track(track, None, None) { + .flat_map(|track| { + match self.context_to_provided_track(track, context.uri.clone(), None) { Ok(t) => Some(t), Err(_) => { error!("couldn't convert {track:#?} into ProvidedTrack"); None } - }, - ) + } + }) .collect(); self.context = Some(StateContext { @@ -538,6 +582,52 @@ impl ConnectState { Ok(()) } + pub fn update_autoplay_context(&mut self, mut context: Context) -> Result<(), Error> { + debug!( + "autoplay-context: {}, pages: {}", + context.uri, + context.pages.len() + ); + let page = context + .pages + .pop() + .ok_or(StateError::NoContext(ContextType::Autoplay))?; + debug!("autoplay-context size: {}", page.tracks.len()); + + let tracks = page + .tracks + .iter() + .flat_map(|track| { + match self.context_to_provided_track( + track, + context.uri.clone(), + Some(AUTOPLAY_PROVIDER), + ) { + Ok(t) => Some(t), + Err(_) => { + error!("couldn't convert {track:#?} into ProvidedTrack"); + None + } + } + }) + .collect::>(); + + // add the tracks to the context if we already have an autoplay context + if let Some(autoplay_context) = self.autoplay_context.as_mut() { + for track in tracks { + autoplay_context.tracks.push(track) + } + } else { + self.autoplay_context = Some(StateContext { + tracks, + metadata: page.metadata, + index: ContextIndex::new(), + }) + } + + Ok(()) + } + pub fn try_get_current_track_from_transfer( &self, transfer: &TransferState, @@ -551,7 +641,7 @@ impl ConnectState { self.context_to_provided_track( track, - None, + transfer.current_session.context.uri.clone(), transfer.queue.is_playing_queue.then_some(QUEUE_PROVIDER), ) } @@ -562,35 +652,45 @@ impl ConnectState { Some(track) => track.clone(), }; + let ctx = self.get_current_context().ok(); + let current_index = - self.find_index_in_context(|c| c.uri == track.uri || c.uid == track.uid)?; + Self::find_index_in_context(ctx, |c| c.uri == track.uri || c.uid == track.uid); + + debug!( + "current {:?} index is {current_index:?}", + self.active_context + ); + let current_index = current_index.ok(); if self.player.track.is_none() { self.player.track = MessageField::some(track); } - if let Some(index) = self.player.index.as_mut() { - index.track = current_index as u32; - } else { - self.player.index = MessageField::some(ContextIndex { - page: 0, - track: current_index as u32, - ..Default::default() - }) + if let Some(current_index) = current_index { + if let Some(index) = self.player.index.as_mut() { + index.track = current_index as u32; + } else { + self.player.index = MessageField::some(ContextIndex { + page: 0, + track: current_index as u32, + ..Default::default() + }) + } } debug!( - "setting up next and prev: index is at {current_index} while shuffle {}", + "setting up next and prev: index is at {current_index:?} while shuffle {}", self.player.options.shuffling_context ); if self.player.options.shuffling_context { - self.set_current_track(current_index, false)?; + self.set_current_track(current_index.unwrap_or_default())?; self.set_shuffle(true); self.shuffle()?; } else { // todo: it seems like, if we play a queued track and transfer we will reset that queued track... - self.reset_playback_context(Some(current_index))?; + self.reset_playback_context(current_index)?; } self.update_restrictions(); @@ -620,6 +720,11 @@ impl ConnectState { pub fn update_restrictions(&mut self) { const NO_PREV: &str = "no previous tracks"; const NO_NEXT: &str = "no next tracks"; + const AUTOPLAY: &str = "autoplay"; // also seen as: endless_context + + if self.player.restrictions.is_none() { + self.player.restrictions = MessageField::some(Restrictions::new()) + } if let Some(restrictions) = self.player.restrictions.as_mut() { if self.prev_tracks.is_empty() { @@ -637,6 +742,18 @@ impl ConnectState { restrictions.disallow_peeking_next_reasons.clear(); restrictions.disallow_skipping_next_reasons.clear(); } + + if self.player.track.provider == AUTOPLAY_PROVIDER { + 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 { + restrictions.disallow_toggling_shuffle_reasons.clear(); + restrictions + .disallow_toggling_repeat_context_reasons + .clear(); + restrictions.disallow_toggling_repeat_track_reasons.clear(); + } } } @@ -655,21 +772,32 @@ impl ConnectState { } } - fn fill_up_next_tracks(&mut self) -> Result<(), StateError> { - let ctx = if self.player.options.shuffling_context { - self.shuffle_context.as_ref() - } else { - self.context.as_ref() + fn get_current_context(&self) -> Result<&StateContext, StateError> { + match self.active_context { + ContextType::Default => self.context.as_ref(), + ContextType::Shuffle => self.shuffle_context.as_ref(), + ContextType::Autoplay => self.autoplay_context.as_ref(), } - .ok_or(StateError::NoContext(self.player.options.shuffling_context))?; + .ok_or(StateError::NoContext(self.active_context)) + } + pub fn fill_up_next_tracks(&mut self) -> Result<(), StateError> { + let ctx = self.get_current_context()?; let mut new_index = ctx.index.track as usize; + while self.next_tracks.len() < SPOTIFY_MAX_NEXT_TRACKS_SIZE { + let ctx = self.get_current_context()?; let track = match ctx.tracks.get(new_index) { - None => { - // todo: what do we do if we can't fill up anymore? autoplay? - break; + None if self.autoplay_context.is_some() => { + // transitional to autoplay as active context + self.active_context = ContextType::Autoplay; + + match self.get_current_context()?.tracks.get(new_index) { + None => break, + Some(ct) => ct.clone(), + } } + None => break, Some(ct) => ct.clone(), }; @@ -686,10 +814,12 @@ impl ConnectState { } pub fn find_index_in_context bool>( - &self, + context: Option<&StateContext>, f: F, ) -> Result { - let ctx = self.context.as_ref().ok_or(StateError::NoContext(false))?; + let ctx = context + .as_ref() + .ok_or(StateError::NoContext(ContextType::Default))?; ctx.tracks .iter() @@ -707,7 +837,7 @@ impl ConnectState { pub fn context_to_provided_track( &self, ctx_track: &ContextTrack, - metadata: Option>, + context_uri: String, provider: Option<&str>, ) -> Result { let provider = if self.unavailable_uri.contains(&ctx_track.uri) { @@ -726,7 +856,9 @@ impl ConnectState { return Err(Error::unavailable("track not available")); }?; - let mut metadata = metadata.unwrap_or_default(); + let mut metadata = HashMap::new(); + metadata.insert(METADATA_CONTEXT_URI.to_string(), context_uri.to_string()); + metadata.insert(METADATA_ENTITY_URI.to_string(), context_uri.to_string()); if !ctx_track.metadata.is_empty() { for (k, v) in &ctx_track.metadata { @@ -769,6 +901,11 @@ impl ConnectState { player_state.next_tracks = self.next_tracks.clone().into(); player_state.prev_tracks = self.prev_tracks.clone().into(); + if let Some(context_uri) = player_state.track.metadata.get(METADATA_CONTEXT_URI) { + player_state.context_uri = context_uri.to_owned(); + player_state.context_url = format!("context://{context_uri}"); + } + let is_active = self.active; let device = MessageField::some(Device { device_info: MessageField::some(self.device.clone()), diff --git a/core/src/spclient.rs b/core/src/spclient.rs index 501ba5b4f..87aa9ebee 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -32,7 +32,7 @@ use hyper::{ HeaderMap, Method, Request, }; use hyper_util::client::legacy::ResponseFuture; -use librespot_protocol::player::Context; +use librespot_protocol::{autoplay_context_request::AutoplayContextRequest, player::Context}; use protobuf::{Enum, Message, MessageFull}; use rand::RngCore; use sha1::{Digest, Sha1}; @@ -805,6 +805,25 @@ impl SpClient { Ok(ctx) } + pub async fn get_autoplay_context( + &self, + context_request: &AutoplayContextRequest, + ) -> Result { + let res = self + .request_with_protobuf( + &Method::POST, + "/context-resolve/v1/autoplay", + None, + context_request, + ) + .await?; + + let ctx_json = String::from_utf8(res.to_vec())?; + let ctx = protobuf_json_mapping::parse_from_str::(&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(); diff --git a/protocol/build.rs b/protocol/build.rs index c5b3df572..73bdd523f 100644 --- a/protocol/build.rs +++ b/protocol/build.rs @@ -30,6 +30,7 @@ fn compile() { proto_dir.join("spotify/clienttoken/v0/clienttoken_http.proto"), proto_dir.join("storage-resolve.proto"), proto_dir.join("user_attributes.proto"), + proto_dir.join("autoplay_context_request.proto"), // TODO: remove these legacy protobufs when we are on the new API completely proto_dir.join("authentication.proto"), proto_dir.join("canvaz.proto"), From 606dc7f21c785725e6685be1e3b063fbb176594d Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 27 Oct 2024 12:56:29 +0100 Subject: [PATCH 047/138] connect: handle repeat context/track --- connect/src/spirc.rs | 96 +++++++++++++++++++------ connect/src/state.rs | 104 +++++++++++++++++++++++++--- core/src/dealer/protocol/request.rs | 17 +++++ 3 files changed, 183 insertions(+), 34 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index ceb68fdab..9e17cfe29 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -11,10 +11,12 @@ use crate::{ }; use futures_util::{FutureExt, Stream, StreamExt}; use librespot_core::dealer::manager::{Reply, RequestReply}; -use librespot_core::dealer::protocol::{PayloadValue, RequestCommand, SetQueueCommand, SkipTo}; +use librespot_core::dealer::protocol::{ + PayloadValue, RequestCommand, SetOptionsCommand, SetQueueCommand, SkipTo, +}; use librespot_protocol::autoplay_context_request::AutoplayContextRequest; use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason, SetVolumeCommand}; -use librespot_protocol::player::{ProvidedTrack, TransferState}; +use librespot_protocol::player::TransferState; use protobuf::{Message, MessageField}; use std::{ future::Future, @@ -463,6 +465,7 @@ impl SpircTask { self.connect_state.fill_up_next_tracks()?; self.connect_state.update_restrictions(); + self.connect_state.player.queue_revision = self.connect_state.new_queue_revision(); self.conditional_preload_autoplay(self.connect_state.player.context_uri.clone()); @@ -929,8 +932,12 @@ impl SpircTask { self.handle_set_queue(set_queue); self.notify().await.map(|_| Reply::Success)? } + RequestCommand::SetOptions(set_options) => { + self.handle_set_options(set_options)?; + self.notify().await.map(|_| Reply::Success)? + } RequestCommand::SkipNext(skip_next) => { - self.handle_next(skip_next.track)?; + self.handle_next(skip_next.track.map(|t| t.uri))?; self.notify().await.map(|_| Reply::Success)? } RequestCommand::SkipPrev(_) => { @@ -953,7 +960,7 @@ impl SpircTask { fn handle_transfer(&mut self, mut transfer: TransferState) -> Result<(), Error> { self.connect_state - .reset_context(&transfer.current_session.context.uri); + .reset_context(Some(&transfer.current_session.context.uri)); let mut ctx_uri = transfer.current_session.context.uri.to_owned(); let autoplay = ctx_uri.contains("station"); @@ -1098,7 +1105,7 @@ impl SpircTask { } async fn handle_load(&mut self, cmd: SpircLoadCommand) -> Result<(), Error> { - self.connect_state.reset_context(&cmd.context_uri); + self.connect_state.reset_context(Some(&cmd.context_uri)); if !self.connect_state.active { self.handle_activate(); @@ -1245,8 +1252,13 @@ impl SpircTask { } fn preview_next_track(&mut self) -> Option { - let next = self.connect_state.next_tracks.front()?; - SpotifyId::from_uri(&next.uri).ok() + let next = if self.connect_state.player.options.repeating_track { + &self.connect_state.player.track.uri + } else { + &self.connect_state.next_tracks.front()?.uri + }; + + SpotifyId::from_uri(next).ok() } fn handle_preload_next_track(&mut self) { @@ -1291,25 +1303,31 @@ impl SpircTask { } } - fn handle_next(&mut self, track: Option) -> Result<(), Error> { - let context_uri = self.connect_state.player.context_uri.to_owned(); - let continue_playing = self.connect_state.player.is_playing; + fn handle_next(&mut self, track_uri: Option) -> Result<(), Error> { + let player = &self.connect_state.player; + let context_uri = player.context_uri.to_owned(); + let continue_playing = player.is_playing; - let new_track_index = loop { - let index = self.connect_state.next_track()?; + let mut has_next_track = + matches!(track_uri, Some(ref track_uri) if &player.track.uri == track_uri); - if track.is_some() - && matches!(track, Some(ref track) if self.connect_state.player.track.uri != track.uri) - { - continue; - } else { - break index; - } + if !has_next_track { + has_next_track = loop { + let index = self.connect_state.next_track()?; + + if track_uri.is_some() + && matches!(track_uri, Some(ref track_uri) if &self.connect_state.player.track.uri != track_uri) + { + continue; + } else { + break index.is_some(); + } + }; }; self.conditional_preload_autoplay(context_uri.clone()); - if new_track_index.is_some() { + 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."); @@ -1348,7 +1366,14 @@ impl SpircTask { } async fn handle_end_of_track(&mut self) -> Result<(), Error> { - self.handle_next(None)?; + let next_track = self + .connect_state + .player + .options + .repeating_track + .then(|| self.connect_state.player.track.uri.clone()); + + self.handle_next(next_track)?; self.notify().await } @@ -1418,8 +1443,7 @@ impl SpircTask { if shuffle { self.connect_state.shuffle() } else { - self.connect_state.shuffle_context = None; - self.connect_state.active_context = ContextType::Default; + self.connect_state.reset_context(None); let state = &mut self.connect_state; let current_index = match state.player.track.as_ref() { @@ -1439,6 +1463,32 @@ impl SpircTask { self.connect_state.prev_tracks = set_queue_command.prev_tracks.into(); self.connect_state.player.queue_revision = self.connect_state.new_queue_revision(); } + + fn handle_set_options(&mut self, set_options: SetOptionsCommand) -> Result<(), Error> { + let state = &mut self.connect_state; + + if state.player.options.repeating_context != set_options.repeating_context { + state.set_repeat_context(set_options.repeating_context); + + if state.player.options.repeating_context { + state.set_shuffle(false); + state.reset_context(None); + + let ctx = state.context.as_ref(); + let current_track = + ConnectState::find_index_in_context(ctx, |t| state.player.track.uri == t.uri)?; + state.reset_playback_context(Some(current_track))?; + } else { + state.update_restrictions(); + } + } + + // doesn't need any state updates, because it should only change how the current song is played + self.connect_state + .set_repeat_track(set_options.repeating_track); + + Ok(()) + } } impl Drop for SpircTask { diff --git a/connect/src/state.rs b/connect/src/state.rs index 0ae8f1fb6..c334c9dd8 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -26,7 +26,8 @@ const SPOTIFY_MAX_NEXT_TRACKS_SIZE: usize = 80; pub const CONTEXT_PROVIDER: &str = "context"; pub const QUEUE_PROVIDER: &str = "queue"; pub const AUTOPLAY_PROVIDER: &str = "autoplay"; -// todo: there is a separator provider which is used to realise repeat + +pub const DELIMITER_IDENTIFIER: &str = "delimiter"; // our own provider to flag tracks as a specific states // todo: we might just need to remove tracks that are unavailable to play, will have to see how the official clients handle this provider @@ -45,6 +46,8 @@ pub enum StateError { 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 }, } impl From for Error { @@ -153,14 +156,12 @@ impl ConnectState { is_observable: true, is_controllable: true, + supports_gzip_pushes: true, supports_logout: cfg.zeroconf_enabled, supported_types: vec!["audio/episode".into(), "audio/track".into()], supports_playlist_v2: true, supports_transfer_command: true, supports_command_request: true, - supports_gzip_pushes: true, - - // todo: not handled yet, repeat missing supports_set_options_command: true, is_voice_enabled: false, @@ -288,6 +289,19 @@ impl ConnectState { } pub fn shuffle(&mut self) -> Result<(), Error> { + if let Some(reason) = self + .player + .restrictions + .disallow_toggling_shuffle_reasons + .first() + { + return Err(StateError::CurrentlyDisallowed { + action: "shuffle".to_string(), + reason: reason.clone(), + } + .into()); + } + self.prev_tracks.clear(); self.clear_next_tracks(); @@ -344,6 +358,11 @@ impl ConnectState { /// 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, StateError> { + // when we skip in repeat track, we don't repeat the current track anymore + if self.player.options.repeating_track { + self.set_repeat_track(false); + } + let old_track = self.player.track.take(); if let Some(old_track) = old_track { @@ -351,12 +370,25 @@ impl ConnectState { if old_track.provider == CONTEXT_PROVIDER || old_track.provider == AUTOPLAY_PROVIDER { // add old current track to prev tracks, while preserving a length of 10 if self.prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { - self.prev_tracks.pop_front(); + _ = self.prev_tracks.pop_front(); } self.prev_tracks.push_back(old_track); } } + // handle possible delimiter + if matches!(self.next_tracks.front(), Some(next) if next.uid.starts_with(DELIMITER_IDENTIFIER)) + { + let delimiter = self + .next_tracks + .pop_front() + .expect("item that was prechecked"); + if self.prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { + _ = self.prev_tracks.pop_front(); + } + self.prev_tracks.push_back(delimiter) + } + let new_track = match self.next_tracks.pop_front() { None => return Ok(None), Some(t) => t, @@ -413,6 +445,19 @@ impl ConnectState { } } + // handle possible delimiter + if matches!(self.prev_tracks.back(), Some(prev) if prev.uid.starts_with(DELIMITER_IDENTIFIER)) + { + let delimiter = self + .prev_tracks + .pop_back() + .expect("item that was prechecked"); + if self.next_tracks.len() >= SPOTIFY_MAX_NEXT_TRACKS_SIZE { + _ = self.next_tracks.pop_back(); + } + self.next_tracks.push_front(delimiter) + } + while self.next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE { let _ = self.next_tracks.pop_back(); } @@ -457,15 +502,20 @@ impl ConnectState { Ok(()) } - pub fn reset_context(&mut self, new_context: &str) { + pub fn reset_context(&mut self, new_context: Option<&str>) { self.active_context = ContextType::Default; self.autoplay_context = None; self.shuffle_context = None; - if self.player.context_uri != new_context { + if matches!(new_context, Some(ctx) if self.player.context_uri != ctx) { self.context = None; + } else if let Some(ctx) = self.context.as_mut() { + ctx.index.track = 0; + ctx.index.page = 0; } + + self.update_restrictions() } pub fn reset_playback_context(&mut self, new_index: Option) -> Result<(), Error> { @@ -720,7 +770,8 @@ impl ConnectState { pub fn update_restrictions(&mut self) { const NO_PREV: &str = "no previous tracks"; const NO_NEXT: &str = "no next tracks"; - const AUTOPLAY: &str = "autoplay"; // also seen as: endless_context + const AUTOPLAY: &str = "autoplay"; + const ENDLESS_CONTEXT: &str = "endless_context"; if self.player.restrictions.is_none() { self.player.restrictions = MessageField::some(Restrictions::new()) @@ -747,6 +798,8 @@ impl ConnectState { 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 self.player.options.repeating_context { + restrictions.disallow_toggling_shuffle_reasons = vec![ENDLESS_CONTEXT.to_string()] } else { restrictions.disallow_toggling_shuffle_reasons.clear(); restrictions @@ -784,24 +837,36 @@ impl ConnectState { pub fn fill_up_next_tracks(&mut self) -> Result<(), StateError> { let ctx = self.get_current_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_current_context()?; let track = match ctx.tracks.get(new_index) { + None if self.player.options.repeating_context => { + let delimiter = Self::delimiter(iteration.into()); + iteration += 1; + new_index = 0; + delimiter + } None if self.autoplay_context.is_some() => { // transitional to autoplay as active context self.active_context = ContextType::Autoplay; match self.get_current_context()?.tracks.get(new_index) { None => break, - Some(ct) => ct.clone(), + Some(ct) => { + new_index += 1; + ct.clone() + } } } None => break, - Some(ct) => ct.clone(), + Some(ct) => { + new_index += 1; + ct.clone() + } }; - new_index += 1; self.next_tracks.push_back(track); } @@ -834,6 +899,23 @@ impl ConnectState { } } + fn delimiter(iteration: i64) -> ProvidedTrack { + const HIDDEN: &str = "hidden"; + const ITERATION: &str = "iteration"; + + let mut metadata = HashMap::new(); + metadata.insert(HIDDEN.to_string(), true.to_string()); + metadata.insert(ITERATION.to_string(), iteration.to_string()); + + ProvidedTrack { + uri: format!("spotify:{DELIMITER_IDENTIFIER}"), + uid: format!("{DELIMITER_IDENTIFIER}{iteration}"), + provider: CONTEXT_PROVIDER.to_string(), + metadata, + ..Default::default() + } + } + pub fn context_to_provided_track( &self, ctx_track: &ContextTrack, diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index a9ecb5a82..7359f6355 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -27,6 +27,7 @@ pub enum RequestCommand { SetShufflingContext(SetShufflingCommand), AddToQueue(AddToQueueCommand), SetQueue(SetQueueCommand), + SetOptions(SetOptionsCommand), // commands that don't send any context (at least not usually...) SkipPrev(GenericCommand), Resume(GenericCommand), @@ -48,6 +49,7 @@ impl Display for RequestCommand { RequestCommand::SetShufflingContext(_) => "set_shuffling_context", RequestCommand::AddToQueue(_) => "add_to_queue", RequestCommand::SetQueue(_) => "set_queue", + RequestCommand::SetOptions(_) => "set_options", RequestCommand::SkipNext(_) => "skip_next", RequestCommand::SkipPrev(_) => "skip_prev", RequestCommand::Resume(_) => "resume", @@ -126,6 +128,14 @@ pub struct SetQueueCommand { pub logging_params: LoggingParams, } +#[derive(Clone, Debug, Deserialize)] +pub struct SetOptionsCommand { + pub repeating_context: bool, + pub repeating_track: bool, + pub options: Option, + pub logging_params: LoggingParams, +} + #[derive(Clone, Debug, Deserialize)] pub struct GenericCommand { pub logging_params: LoggingParams, @@ -153,6 +163,13 @@ pub struct PlayOptions { 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, From 28169590177ada88aaeac9b43d7cbc49ed428c09 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 27 Oct 2024 21:49:01 +0100 Subject: [PATCH 048/138] connect: some quick fixes - found self-named uid in collection after reconnecting --- connect/src/state.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/connect/src/state.rs b/connect/src/state.rs index c334c9dd8..e11504ca2 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -708,15 +708,17 @@ impl ConnectState { Self::find_index_in_context(ctx, |c| c.uri == track.uri || c.uid == track.uid); debug!( - "current {:?} index is {current_index:?}", - self.active_context + "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() ); - let current_index = current_index.ok(); if self.player.track.is_none() { self.player.track = MessageField::some(track); } + let current_index = current_index.ok(); if let Some(current_index) = current_index { if let Some(index) = self.player.index.as_mut() { index.track = current_index as u32; @@ -951,7 +953,7 @@ impl ConnectState { let uid = if !ctx_track.uid.is_empty() { ctx_track.uid.clone() } else { - String::from_utf8(id.to_raw().to_vec()).unwrap_or_else(|_| "unknown".to_string()) + String::from_utf8(id.to_raw().to_vec()).unwrap_or_else(|_| String::new()) }; Ok(ProvidedTrack { From dde7102fc70e502455a4b2461ee7b08c275961ab Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Mon, 28 Oct 2024 23:31:17 +0100 Subject: [PATCH 049/138] connect: handle add_to_queue via set_queue --- connect/src/spirc.rs | 23 ++++++++++++++++++++--- connect/src/state.rs | 9 +++------ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 9e17cfe29..77fd4dcdf 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,4 +1,7 @@ -use crate::state::{ConnectState, ConnectStateConfig, ContextType, AUTOPLAY_PROVIDER}; +use crate::state::{ + ConnectState, ConnectStateConfig, ContextType, AUTOPLAY_PROVIDER, METADATA_IS_QUEUED, + QUEUE_PROVIDER, +}; use crate::{ core::{authentication::Credentials, session::UserAttributes, Error, Session, SpotifyId}, playback::{ @@ -1114,7 +1117,10 @@ impl SpircTask { if self.connect_state.player.context_uri == cmd.context_uri && self.connect_state.context.is_some() { - debug!("context didn't change, no resolving required") + debug!( + "context <{}> didn't change, no resolving required", + self.connect_state.player.context_uri + ) } else { debug!("resolving context for load command"); self.resolve_context(cmd.context_uri.clone(), false).await?; @@ -1458,7 +1464,18 @@ impl SpircTask { } } - fn handle_set_queue(&mut self, set_queue_command: SetQueueCommand) { + fn handle_set_queue(&mut self, mut set_queue_command: SetQueueCommand) { + // 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 + set_queue_command + .next_tracks + .iter_mut() + .filter(|t| t.metadata.contains_key(METADATA_IS_QUEUED)) + .for_each(|t| { + t.provider = QUEUE_PROVIDER.to_string(); + }); + self.connect_state.next_tracks = set_queue_command.next_tracks.into(); self.connect_state.prev_tracks = set_queue_command.prev_tracks.into(); self.connect_state.player.queue_revision = self.connect_state.new_queue_revision(); diff --git a/connect/src/state.rs b/connect/src/state.rs index e11504ca2..ca32501a5 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -35,6 +35,7 @@ const UNAVAILABLE_PROVIDER: &str = "unavailable"; pub const METADATA_CONTEXT_URI: &str = "context_uri"; pub const METADATA_ENTITY_URI: &str = "entity_uri"; +pub const METADATA_IS_QUEUED: &str = "is_queued"; #[derive(Debug, Error)] pub enum StateError { @@ -558,13 +559,11 @@ impl ConnectState { } pub fn add_to_queue(&mut self, mut track: ProvidedTrack, rev_update: bool) { - const IS_QUEUED: &str = "is_queued"; - track.provider = QUEUE_PROVIDER.to_string(); - if !track.metadata.contains_key(IS_QUEUED) { + if !track.metadata.contains_key(METADATA_IS_QUEUED) { track .metadata - .insert(IS_QUEUED.to_string(), true.to_string()); + .insert(METADATA_IS_QUEUED.to_string(), true.to_string()); } if let Some(next_not_queued_track) = self @@ -934,8 +933,6 @@ impl ConnectState { SpotifyId::from_uri(&ctx_track.uri) } else if !ctx_track.gid.is_empty() { SpotifyId::from_raw(&ctx_track.gid) - } else if !ctx_track.uid.is_empty() { - SpotifyId::from_raw(ctx_track.uid.as_bytes()) } else { return Err(Error::unavailable("track not available")); }?; From c7d95a8ad0524119a7bc54af25597df359ef22d3 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 29 Oct 2024 00:11:23 +0100 Subject: [PATCH 050/138] fix clippy warnings --- connect/src/state.rs | 2 +- core/src/deserialize_with.rs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/connect/src/state.rs b/connect/src/state.rs index ca32501a5..63f5cbb49 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -478,7 +478,7 @@ impl ConnectState { self.player.track = MessageField::some(new_track); - if self.player.index.track <= 0 { + if self.player.index.track == 0 { warn!("prev: trying to skip into negative, index update skipped") } else if let Some(index) = self.player.index.as_mut() { index.track -= 1; diff --git a/core/src/deserialize_with.rs b/core/src/deserialize_with.rs index afe5b14cb..30089cd72 100644 --- a/core/src/deserialize_with.rs +++ b/core/src/deserialize_with.rs @@ -54,9 +54,7 @@ where use serde::de::Error; let v: Value = Deserialize::deserialize(de)?; - parse_value_to_msg(&v) - .map(Some) - .map_err(|why| Error::custom(why)) + parse_value_to_msg(&v).map(Some).map_err(Error::custom) } pub fn vec_json_proto<'de, T, D>(de: D) -> Result, D::Error> From f1c9cf64962dbbf9c7fc09466f3396d196bcea0d Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 29 Oct 2024 00:30:48 +0100 Subject: [PATCH 051/138] fix check errors, fix/update example --- core/src/deserialize_with.rs | 8 -------- examples/play_connect.rs | 19 ++++++------------- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/core/src/deserialize_with.rs b/core/src/deserialize_with.rs index 30089cd72..11687f9bc 100644 --- a/core/src/deserialize_with.rs +++ b/core/src/deserialize_with.rs @@ -21,8 +21,6 @@ where T: MessageFull, D: Deserializer<'de>, { - use serde::de::Error; - let v: String = Deserialize::deserialize(de)?; let bytes = BASE64_STANDARD .decode(v) @@ -36,8 +34,6 @@ where T: MessageFull, D: Deserializer<'de>, { - use serde::de::Error; - let v: Value = Deserialize::deserialize(de)?; parse_value_to_msg(&v).map_err(|why| { warn!("deserialize_json_proto: {v}"); @@ -51,8 +47,6 @@ where T: MessageFull, D: Deserializer<'de>, { - use serde::de::Error; - let v: Value = Deserialize::deserialize(de)?; parse_value_to_msg(&v).map(Some).map_err(Error::custom) } @@ -62,8 +56,6 @@ where T: MessageFull, D: Deserializer<'de>, { - use serde::de::Error; - let v: Value = Deserialize::deserialize(de)?; let array = match v { Value::Array(array) => array, diff --git a/examples/play_connect.rs b/examples/play_connect.rs index c46464fba..9bdcf9a9c 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 {}", @@ -89,8 +81,9 @@ async fn main() { start_playing: true, 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(); }); From f5290a35ff7ff6a56b4720a69804d450dca54518 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 29 Oct 2024 00:43:38 +0100 Subject: [PATCH 052/138] fix 1.75 specific error --- connect/src/state.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/connect/src/state.rs b/connect/src/state.rs index 63f5cbb49..4672bd7f5 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -1,5 +1,5 @@ -use std::collections::{HashMap, VecDeque}; -use std::hash::{DefaultHasher, Hasher}; +use std::collections::{hash_map::DefaultHasher, HashMap, VecDeque}; +use std::hash::Hasher; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use crate::spirc::SpircPlayStatus; From eebc8f2103471ed7e348dba644a22d3ca48e8dc8 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 29 Oct 2024 23:26:57 +0100 Subject: [PATCH 053/138] connect: position update improvements --- connect/src/spirc.rs | 37 +++++++++++++++++++++++++++++++------ connect/src/state.rs | 11 +++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index d83d36e43..c27bd0cc0 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -449,6 +449,14 @@ impl SpircTask { _ = async { sleep(Duration::from_millis(VOLUME_UPDATE_DELAY_MS)).await }, if self.update_volume => { self.update_volume = false; + // 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}") + } + + self.connect_state.update_position_in_relation(self.now_ms()); info!("delayed volume update for all devices: volume is now {}", self.connect_state.device.volume); if let Err(why) = self.connect_state.update_state(&self.session, PutStateReason::VOLUME_CHANGED).await { error!("error updating connect state for volume update: {why}") @@ -458,9 +466,12 @@ impl SpircTask { } } - if let Err(why) = self.notify().await { - warn!("last notify before shutdown couldn't be send: {why}") + if !self.shutdown { + if let Err(why) = self.notify().await { + warn!("notify before unexpected shutdown couldn't be send: {why}") + } } + self.session.dealer().close().await; } @@ -1042,10 +1053,12 @@ impl SpircTask { // update position if the track continued playing let position = if transfer.playback.is_paused { - state.player.position_as_of_timestamp - } else { + 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 }; if self.connect_state.context.is_some() { @@ -1080,10 +1093,17 @@ impl SpircTask { } async fn handle_disconnect(&mut self) -> Result<(), Error> { - self.connect_state.set_active(false); - self.notify().await?; self.handle_stop(); + self.play_status = SpircPlayStatus::Stopped {}; + self.connect_state + .update_position_in_relation(self.now_ms()); + self.notify().await?; + + self.connect_state + .update_state(&self.session, PutStateReason::BECAME_INACTIVE) + .await?; + self.player .emit_session_disconnected_event(self.session.connection_id(), self.session.username()); @@ -1435,6 +1455,11 @@ impl SpircTask { async fn notify(&mut self) -> Result<(), Error> { self.connect_state.set_status(&self.play_status); + + if self.connect_state.player.is_playing { + self.connect_state.update_position_in_relation(self.now_ms()); + } + self.connect_state .update_state(&self.session, PutStateReason::PLAYER_STATE_CHANGED) .await diff --git a/connect/src/state.rs b/connect/src/state.rs index 4672bd7f5..7100f8c67 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -962,6 +962,17 @@ impl ConnectState { }) } + pub fn update_position_in_relation(&mut self, timestamp: i64) { + let diff = timestamp - self.player.timestamp; + self.player.position_as_of_timestamp += diff; + + debug!( + "update position to {} at {timestamp}", + self.player.position_as_of_timestamp + ); + self.player.timestamp = timestamp; + } + // todo: i would like to refrain from copying the next and prev track lists... will have to see what we can come up with /// Updates the connect state for the connect session /// From 8c31fd00156e20f0ebf72023d52cf6a7ce1d818d Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 29 Oct 2024 22:43:45 +0100 Subject: [PATCH 054/138] connect: handle unavailable --- connect/src/spirc.rs | 16 +++++++--- connect/src/state.rs | 72 +++++++++++++++++++++++++++----------------- 2 files changed, 55 insertions(+), 33 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index c27bd0cc0..369eacf98 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -747,8 +747,11 @@ impl SpircTask { Ok(()) } PlayerEvent::Unavailable { track_id, .. } => { - self.handle_unavailable(track_id); - Ok(()) + self.handle_unavailable(track_id)?; + if self.connect_state.player.track.uri == track_id.to_uri()? { + self.handle_next(None)?; + } + self.notify().await } _ => Ok(()), } @@ -1324,9 +1327,11 @@ impl SpircTask { } // Mark unavailable tracks so we can skip them later - fn handle_unavailable(&mut self, track_id: SpotifyId) { - self.connect_state.mark_all_as_unavailable(track_id); + fn handle_unavailable(&mut self, track_id: SpotifyId) -> Result<(), Error> { + self.connect_state.mark_unavailable(track_id)?; self.handle_preload_next_track(); + + Ok(()) } fn conditional_preload_autoplay(&mut self, uri: String) { @@ -1457,7 +1462,8 @@ impl SpircTask { self.connect_state.set_status(&self.play_status); if self.connect_state.player.is_playing { - self.connect_state.update_position_in_relation(self.now_ms()); + self.connect_state + .update_position_in_relation(self.now_ms()); } self.connect_state diff --git a/connect/src/state.rs b/connect/src/state.rs index 7100f8c67..a680ac609 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -31,7 +31,9 @@ pub const DELIMITER_IDENTIFIER: &str = "delimiter"; // our own provider to flag tracks as a specific states // todo: we might just need to remove tracks that are unavailable to play, will have to see how the official clients handle this provider -const UNAVAILABLE_PROVIDER: &str = "unavailable"; +// it seems like spotify just knows that the track isn't available, currently i didn't found +// a solution to do the same, so we stay with the old solution for now +pub const UNAVAILABLE_PROVIDER: &str = "unavailable"; pub const METADATA_CONTEXT_URI: &str = "context_uri"; pub const METADATA_ENTITY_URI: &str = "entity_uri"; @@ -236,12 +238,12 @@ impl ConnectState { ); if let Some(restrictions) = self.player.restrictions.as_mut() { - if self.player.is_playing && !self.player.is_paused { + if self.player.is_playing { restrictions.disallow_pausing_reasons.clear(); restrictions.disallow_resuming_reasons = vec!["not_paused".to_string()] } - if self.player.is_paused && !self.player.is_playing { + if self.player.is_paused { restrictions.disallow_resuming_reasons.clear(); restrictions.disallow_pausing_reasons = vec!["not_playing".to_string()] } @@ -377,20 +379,19 @@ impl ConnectState { } } - // handle possible delimiter - if matches!(self.next_tracks.front(), Some(next) if next.uid.starts_with(DELIMITER_IDENTIFIER)) - { - let delimiter = self - .next_tracks - .pop_front() - .expect("item that was prechecked"); - if self.prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { - _ = self.prev_tracks.pop_front(); + let new_track = match self.next_tracks.pop_front() { + Some(next) if next.uid.starts_with(DELIMITER_IDENTIFIER) => { + if self.prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { + _ = self.prev_tracks.pop_front(); + } + self.prev_tracks.push_back(next); + self.next_tracks.pop_front() } - self.prev_tracks.push_back(delimiter) - } + Some(next) if next.provider == UNAVAILABLE_PROVIDER => self.next_tracks.pop_front(), + other => other, + }; - let new_track = match self.next_tracks.pop_front() { + let new_track = match new_track { None => return Ok(None), Some(t) => t, }; @@ -749,23 +750,34 @@ impl ConnectState { Ok(()) } - // todo: for some reason, after we run once into an unavailable track, - // a whole batch is marked as unavailable... have to look into that and see why and even how... - pub fn mark_all_as_unavailable(&mut self, id: SpotifyId) { - let id = match id.to_uri() { - Ok(uri) => uri, - Err(_) => return, - }; + pub fn mark_unavailable(&mut self, id: SpotifyId) -> Result<(), Error> { + let uri = id.to_uri()?; + + debug!("marking {uri} as unavailable"); for next_track in &mut self.next_tracks { - Self::mark_as_unavailable_for_match(next_track, &id) + Self::mark_as_unavailable_for_match(next_track, &uri) } for prev_track in &mut self.prev_tracks { - Self::mark_as_unavailable_for_match(prev_track, &id) + Self::mark_as_unavailable_for_match(prev_track, &uri) } - self.unavailable_uri.push(id); + if self.player.track.uri != uri { + while let Some(pos) = self.next_tracks.iter().position(|t| t.uri == uri) { + _ = self.next_tracks.remove(pos); + } + + while let Some(pos) = self.prev_tracks.iter().position(|t| t.uri == uri) { + _ = self.prev_tracks.remove(pos); + } + + self.unavailable_uri.push(uri); + self.fill_up_next_tracks()?; + self.player.queue_revision = self.new_queue_revision(); + } + + Ok(()) } pub fn update_restrictions(&mut self) { @@ -862,6 +874,10 @@ impl ConnectState { } } None => break, + Some(ct) if ct.provider == UNAVAILABLE_PROVIDER => { + new_index += 1; + continue; + } Some(ct) => { new_index += 1; ct.clone() @@ -893,9 +909,9 @@ impl ConnectState { .ok_or(StateError::CanNotFindTrackInContext(None, ctx.tracks.len())) } - fn mark_as_unavailable_for_match(track: &mut ProvidedTrack, id: &str) { - debug!("Marked <{}:{}> as unavailable", track.provider, track.uri); - if track.uri == id { + fn mark_as_unavailable_for_match(track: &mut ProvidedTrack, uri: &str) { + if track.uri == uri { + debug!("Marked <{}:{}> as unavailable", track.provider, track.uri); track.provider = UNAVAILABLE_PROVIDER.to_string(); } } From 8a3e7258330662c076402ef0ba3082ae05a13e65 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Wed, 30 Oct 2024 19:36:59 +0100 Subject: [PATCH 055/138] connect: fix incorrect status handling for desktop and mobile --- connect/src/state.rs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/connect/src/state.rs b/connect/src/state.rs index a680ac609..c1f8c835c 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -223,14 +223,19 @@ impl ConnectState { | SpircPlayStatus::Paused { .. } | SpircPlayStatus::Stopped ); - self.player.is_buffering = matches!( - status, - SpircPlayStatus::LoadingPause { .. } | SpircPlayStatus::LoadingPlay { .. } - ); - self.player.is_playing = matches!( - status, - SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::Playing { .. } - ); + + // desktop and mobile want 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 + self.player.is_buffering = self.player.is_paused + || matches!( + status, + SpircPlayStatus::LoadingPause { .. } | SpircPlayStatus::LoadingPlay { .. } + ); + self.player.is_playing = self.player.is_paused + || matches!( + status, + SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::Playing { .. } + ); debug!( "updated connect play status playing: {}, paused: {}, buffering: {}", From fddc22c6343bf60707be5a64ddc320e07780e19d Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Thu, 31 Oct 2024 16:00:31 +0100 Subject: [PATCH 056/138] core: fix dealer reconnect - actually acquire new token - use login5 token retrieval --- core/src/dealer/manager.rs | 29 ++++++++++----------------- core/src/dealer/mod.rs | 41 ++++++++++++++++++++++++-------------- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/core/src/dealer/manager.rs b/core/src/dealer/manager.rs index 65989c2b9..43ce13e17 100644 --- a/core/src/dealer/manager.rs +++ b/core/src/dealer/manager.rs @@ -5,10 +5,10 @@ use thiserror::Error; use tokio::sync::mpsc; use url::Url; -use crate::dealer::{ - Builder, Dealer, Request, RequestHandler, Responder, Response, Subscription, WsError, +use super::{ + Builder, Dealer, GetUrlResult, Request, RequestHandler, Responder, Response, Subscription, }; -use crate::Error; +use crate::{Error, Session}; component! { DealerManager: DealerManagerInner { @@ -22,7 +22,7 @@ enum DealerError { #[error("Builder wasn't available")] BuilderNotAvailable, #[error("Websocket couldn't be started because: {0}")] - LaunchFailure(WsError), + LaunchFailure(Error), #[error("Failed to set dealer")] CouldNotSetDealer, } @@ -77,17 +77,10 @@ impl RequestHandler for DealerRequestHandler { } impl DealerManager { - async fn get_url(&self) -> Result { - let session = self.session(); - + async fn get_url(session: Session) -> GetUrlResult { let (host, port) = session.apresolver().resolve("dealer").await?; - let token = session - .token_provider() - .get_token("streaming") - .await? - .access_token; + 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) } @@ -121,13 +114,13 @@ impl DealerManager { } pub async fn start(&self) -> Result<(), Error> { - let url = self.get_url().await?; debug!("Launching dealer"); - let get_url = move || { - let url = url.clone(); - async move { url } - }; + 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()) diff --git a/core/src/dealer/mod.rs b/core/src/dealer/mod.rs index bf78e3dcc..578e87f3c 100644 --- a/core/src/dealer/mod.rs +++ b/core/src/dealer/mod.rs @@ -29,10 +29,11 @@ use tokio_tungstenite::tungstenite; use tungstenite::error::UrlError; use url::Url; -use self::maps::*; -use crate::dealer::protocol::{ - Message, MessageOrRequest, Request, WebsocketMessage, WebsocketRequest, +use self::{ + maps::*, + protocol::{Message, MessageOrRequest, Request, WebsocketMessage, WebsocketRequest}, }; + use crate::{ socket, util::{keep_flushing, CancelOnDrop, TimeoutOnDrop}, @@ -41,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); @@ -271,20 +279,20 @@ impl Builder { 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. @@ -389,7 +397,7 @@ impl DealerShared { struct Dealer { shared: Arc, - handle: TimeoutOnDrop<()>, + handle: TimeoutOnDrop>, } impl Dealer { @@ -434,7 +442,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); @@ -588,8 +596,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)); @@ -625,7 +634,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)), @@ -641,4 +650,6 @@ async fn run( let tasks = tasks.0.into_iter().chain(tasks.1); let _ = join_all(tasks).await; + + Ok(()) } From f80382270adb6a51594258c866961c2f89f01f0b Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Thu, 31 Oct 2024 16:25:02 +0100 Subject: [PATCH 057/138] connect: split state into multiple files --- connect/src/spirc.rs | 15 +- connect/src/state.rs | 610 +----------------------------- connect/src/state/consts.rs | 18 + connect/src/state/context.rs | 212 +++++++++++ connect/src/state/options.rs | 75 ++++ connect/src/state/restrictions.rs | 60 +++ connect/src/state/tracks.rs | 252 ++++++++++++ 7 files changed, 643 insertions(+), 599 deletions(-) create mode 100644 connect/src/state/consts.rs create mode 100644 connect/src/state/context.rs create mode 100644 connect/src/state/options.rs create mode 100644 connect/src/state/restrictions.rs create mode 100644 connect/src/state/tracks.rs diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 369eacf98..3c0b7f921 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,7 +1,6 @@ -use crate::state::{ - ConnectState, ConnectStateConfig, ContextType, AUTOPLAY_PROVIDER, METADATA_IS_QUEUED, - QUEUE_PROVIDER, -}; +use crate::state::consts::{METADATA_IS_QUEUED, PROVIDER_AUTOPLAY, PROVIDER_QUEUE}; +use crate::state::context::ContextType; +use crate::state::{ConnectState, ConnectStateConfig}; use crate::{ core::{authentication::Credentials, session::UserAttributes, Error, Session, SpotifyId}, playback::{ @@ -514,11 +513,11 @@ impl SpircTask { .connect_state .prev_tracks .iter() - .flat_map(|t| (t.provider == AUTOPLAY_PROVIDER).then_some(t.uri.clone())) + .flat_map(|t| (t.provider == PROVIDER_AUTOPLAY).then_some(t.uri.clone())) .collect::>(); let current = &self.connect_state.player.track; - if current.provider == AUTOPLAY_PROVIDER { + if current.provider == PROVIDER_AUTOPLAY { previous_tracks.push(current.uri.clone()); } @@ -1080,7 +1079,7 @@ impl SpircTask { } if self.connect_state.autoplay_context.is_none() - && (self.connect_state.player.track.provider == AUTOPLAY_PROVIDER || autoplay) + && (self.connect_state.player.track.provider == PROVIDER_AUTOPLAY || autoplay) { debug!("currently in autoplay context, async resolving autoplay for {ctx_uri}"); self.resolve_context.push(ResolveContext { @@ -1519,7 +1518,7 @@ impl SpircTask { .iter_mut() .filter(|t| t.metadata.contains_key(METADATA_IS_QUEUED)) .for_each(|t| { - t.provider = QUEUE_PROVIDER.to_string(); + t.provider = PROVIDER_QUEUE.to_string(); }); self.connect_state.next_tracks = set_queue_command.next_tracks.into(); diff --git a/connect/src/state.rs b/connect/src/state.rs index c1f8c835c..4dbaaf9b9 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -1,8 +1,19 @@ -use std::collections::{hash_map::DefaultHasher, HashMap, VecDeque}; +pub(super) mod consts; +pub(super) mod context; +mod options; +mod restrictions; +mod tracks; + +use std::collections::{hash_map::DefaultHasher, VecDeque}; use std::hash::Hasher; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use crate::spirc::SpircPlayStatus; +use crate::state::consts::{ + METADATA_CONTEXT_URI, METADATA_IS_QUEUED, PROVIDER_AUTOPLAY, PROVIDER_CONTEXT, PROVIDER_QUEUE, + UNAVAILABLE_PROVIDER, +}; +use crate::state::context::{ContextType, StateContext}; use librespot_core::config::DeviceType; use librespot_core::dealer::protocol::Request; use librespot_core::spclient::SpClientResult; @@ -11,34 +22,16 @@ use librespot_protocol::connect::{ Capabilities, Device, DeviceInfo, MemberType, PutStateReason, PutStateRequest, }; use librespot_protocol::player::{ - Context, ContextIndex, ContextPlayerOptions, ContextTrack, PlayOrigin, PlayerState, - ProvidedTrack, Restrictions, Suppressions, TransferState, + ContextIndex, ContextPlayerOptions, PlayOrigin, PlayerState, ProvidedTrack, Suppressions, + TransferState, }; use protobuf::{EnumOrUnknown, Message, MessageField}; -use rand::prelude::SliceRandom; 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; -// provider used by spotify -pub const CONTEXT_PROVIDER: &str = "context"; -pub const QUEUE_PROVIDER: &str = "queue"; -pub const AUTOPLAY_PROVIDER: &str = "autoplay"; - -pub const DELIMITER_IDENTIFIER: &str = "delimiter"; - -// our own provider to flag tracks as a specific states -// todo: we might just need to remove tracks that are unavailable to play, will have to see how the official clients handle this provider -// it seems like spotify just knows that the track isn't available, currently i didn't found -// a solution to do the same, so we stay with the old solution for now -pub const UNAVAILABLE_PROVIDER: &str = "unavailable"; - -pub const METADATA_CONTEXT_URI: &str = "context_uri"; -pub const METADATA_ENTITY_URI: &str = "entity_uri"; -pub const METADATA_IS_QUEUED: &str = "is_queued"; - #[derive(Debug, Error)] pub enum StateError { #[error("the current track couldn't be resolved from the transfer state")] @@ -82,21 +75,6 @@ impl Default for ConnectStateConfig { } } -#[derive(Debug, Clone)] -pub struct StateContext { - pub tracks: Vec, - pub metadata: HashMap, - pub index: ContextIndex, -} - -#[derive(Default, Debug, Copy, Clone)] -pub enum ContextType { - #[default] - Default, - Shuffle, - Autoplay, -} - #[derive(Default, Debug)] pub struct ConnectState { pub active: bool, @@ -242,17 +220,7 @@ impl ConnectState { self.player.is_playing, self.player.is_paused, self.player.is_buffering ); - if let Some(restrictions) = self.player.restrictions.as_mut() { - if self.player.is_playing { - restrictions.disallow_pausing_reasons.clear(); - restrictions.disallow_resuming_reasons = vec!["not_paused".to_string()] - } - - if self.player.is_paused { - restrictions.disallow_resuming_reasons.clear(); - restrictions.disallow_pausing_reasons = vec!["not_playing".to_string()] - } - } + self.update_restrictions() } // todo: is there maybe a better or more efficient way to calculate the hash? @@ -267,264 +235,6 @@ impl ConnectState { hasher.finish().to_string() } - // region options (shuffle, repeat) - - fn add_options_if_empty(&mut self) { - if self.player.options.is_none() { - self.player.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.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.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.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() - { - return Err(StateError::CurrentlyDisallowed { - action: "shuffle".to_string(), - reason: reason.clone(), - } - .into()); - } - - self.prev_tracks.clear(); - self.clear_next_tracks(); - - let current_uri = &self.player.track.uri; - - let ctx = self - .context - .as_mut() - .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.active_context = ContextType::Shuffle; - self.fill_up_next_tracks()?; - - Ok(()) - } - - // endregion - - pub fn set_current_track(&mut self, index: usize) -> Result<(), Error> { - let context = self.get_current_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 + 1, - new_track.uri, - context.tracks.len() - ); - - self.player.track = MessageField::some(new_track.clone()); - - 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, StateError> { - // when we skip in repeat track, we don't repeat the current track anymore - if self.player.options.repeating_track { - self.set_repeat_track(false); - } - - let old_track = self.player.track.take(); - - if let Some(old_track) = old_track { - // only add songs from our context to our previous tracks - if old_track.provider == CONTEXT_PROVIDER || old_track.provider == AUTOPLAY_PROVIDER { - // add old current track to prev tracks, while preserving a length of 10 - if self.prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { - _ = self.prev_tracks.pop_front(); - } - self.prev_tracks.push_back(old_track); - } - } - - let new_track = match self.next_tracks.pop_front() { - Some(next) if next.uid.starts_with(DELIMITER_IDENTIFIER) => { - if self.prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { - _ = self.prev_tracks.pop_front(); - } - self.prev_tracks.push_back(next); - self.next_tracks.pop_front() - } - Some(next) if next.provider == UNAVAILABLE_PROVIDER => self.next_tracks.pop_front(), - other => other, - }; - - let new_track = match new_track { - None => return Ok(None), - Some(t) => t, - }; - - self.fill_up_next_tracks()?; - - let is_queued_track = new_track.provider == QUEUE_PROVIDER; - let is_autoplay = new_track.provider == AUTOPLAY_PROVIDER; - let update_index = if (is_queued_track || is_autoplay) && self.player.index.is_some() { - // the index isn't send when we are a queued track, but we have to preserve it for later - self.player_index = self.player.index.take(); - None - } else if is_autoplay || is_queued_track { - 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 { - if let Some(index) = self.player.index.as_mut() { - index.track = update_index - } else { - debug!("next: index can't be updated, no index available") - } - } - - self.player.track = MessageField::some(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>, StateError> { - let old_track = self.player.track.take(); - - if let Some(old_track) = old_track { - if old_track.provider == CONTEXT_PROVIDER || old_track.provider == AUTOPLAY_PROVIDER { - self.next_tracks.push_front(old_track); - } - } - - // handle possible delimiter - if matches!(self.prev_tracks.back(), Some(prev) if prev.uid.starts_with(DELIMITER_IDENTIFIER)) - { - let delimiter = self - .prev_tracks - .pop_back() - .expect("item that was prechecked"); - if self.next_tracks.len() >= SPOTIFY_MAX_NEXT_TRACKS_SIZE { - _ = self.next_tracks.pop_back(); - } - self.next_tracks.push_front(delimiter) - } - - while self.next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE { - let _ = self.next_tracks.pop_back(); - } - - let new_track = match self.prev_tracks.pop_back() { - None => return Ok(None), - Some(t) => t, - }; - - if matches!(self.active_context, ContextType::Autoplay if new_track.provider == CONTEXT_PROVIDER) - { - // transition back to default context - self.active_context = ContextType::Default; - } - - self.fill_up_next_tracks()?; - - self.player.track = MessageField::some(new_track); - - if self.player.index.track == 0 { - warn!("prev: trying to skip into negative, index update skipped") - } else if let Some(index) = self.player.index.as_mut() { - index.track -= 1; - } else { - debug!("prev: index can't be decreased, no index available") - } - - self.update_restrictions(); - - Ok(Some(&self.player.track)) - } - - fn update_context_index(&mut self, new_index: usize) -> Result<(), StateError> { - let context = match self.active_context { - ContextType::Default => self.context.as_mut(), - ContextType::Shuffle => self.shuffle_context.as_mut(), - ContextType::Autoplay => self.autoplay_context.as_mut(), - } - .ok_or(StateError::NoContext(self.active_context))?; - - context.index.track = new_index as u32; - Ok(()) - } - - pub fn reset_context(&mut self, new_context: Option<&str>) { - self.active_context = ContextType::Default; - - self.autoplay_context = None; - self.shuffle_context = None; - - if matches!(new_context, Some(ctx) if self.player.context_uri != ctx) { - self.context = None; - } else if let Some(ctx) = self.context.as_mut() { - ctx.index.track = 0; - ctx.index.page = 0; - } - - self.update_restrictions() - } - pub fn reset_playback_context(&mut self, new_index: Option) -> Result<(), Error> { let new_index = new_index.unwrap_or(0); if let Some(player_index) = self.player.index.as_mut() { @@ -535,7 +245,7 @@ impl ConnectState { debug!("reset playback state to {new_index}"); - if self.player.track.provider != QUEUE_PROVIDER { + if self.player.track.provider != PROVIDER_QUEUE { self.set_current_track(new_index)?; } @@ -565,7 +275,7 @@ impl ConnectState { } pub fn add_to_queue(&mut self, mut track: ProvidedTrack, rev_update: bool) { - track.provider = QUEUE_PROVIDER.to_string(); + track.provider = PROVIDER_QUEUE.to_string(); if !track.metadata.contains_key(METADATA_IS_QUEUED) { track .metadata @@ -575,7 +285,7 @@ impl ConnectState { if let Some(next_not_queued_track) = self .next_tracks .iter() - .position(|track| track.provider != QUEUE_PROVIDER) + .position(|track| track.provider != PROVIDER_QUEUE) { self.next_tracks.insert(next_not_queued_track, track); } else { @@ -592,97 +302,6 @@ impl ConnectState { self.update_restrictions(); } - pub fn update_context(&mut self, mut context: Context) -> Result<(), Error> { - debug!("context: {}, {}", context.uri, context.url); - let page = context - .pages - .pop() - .ok_or(StateError::NoContext(ContextType::Default))?; - - let tracks = page - .tracks - .iter() - .flat_map(|track| { - match self.context_to_provided_track(track, context.uri.clone(), None) { - Ok(t) => Some(t), - Err(_) => { - error!("couldn't convert {track:#?} into ProvidedTrack"); - None - } - } - }) - .collect(); - - self.context = Some(StateContext { - tracks, - metadata: page.metadata, - index: ContextIndex::new(), - }); - - self.player.context_url = format!("context://{}", context.uri); - self.player.context_uri = context.uri; - - if context.restrictions.is_some() { - self.player.context_restrictions = context.restrictions; - } - - if !context.metadata.is_empty() { - self.player.context_metadata = context.metadata; - } - - if let Some(transfer_state) = self.transfer_state.take() { - self.setup_current_state(transfer_state)? - } - - Ok(()) - } - - pub fn update_autoplay_context(&mut self, mut context: Context) -> Result<(), Error> { - debug!( - "autoplay-context: {}, pages: {}", - context.uri, - context.pages.len() - ); - let page = context - .pages - .pop() - .ok_or(StateError::NoContext(ContextType::Autoplay))?; - debug!("autoplay-context size: {}", page.tracks.len()); - - let tracks = page - .tracks - .iter() - .flat_map(|track| { - match self.context_to_provided_track( - track, - context.uri.clone(), - Some(AUTOPLAY_PROVIDER), - ) { - Ok(t) => Some(t), - Err(_) => { - error!("couldn't convert {track:#?} into ProvidedTrack"); - None - } - } - }) - .collect::>(); - - // add the tracks to the context if we already have an autoplay context - if let Some(autoplay_context) = self.autoplay_context.as_mut() { - for track in tracks { - autoplay_context.tracks.push(track) - } - } else { - self.autoplay_context = Some(StateContext { - tracks, - metadata: page.metadata, - index: ContextIndex::new(), - }) - } - - Ok(()) - } - pub fn try_get_current_track_from_transfer( &self, transfer: &TransferState, @@ -697,7 +316,7 @@ impl ConnectState { self.context_to_provided_track( track, transfer.current_session.context.uri.clone(), - transfer.queue.is_playing_queue.then_some(QUEUE_PROVIDER), + transfer.queue.is_playing_queue.then_some(PROVIDER_QUEUE), ) } @@ -785,135 +404,6 @@ impl ConnectState { Ok(()) } - pub fn update_restrictions(&mut self) { - const NO_PREV: &str = "no previous tracks"; - const NO_NEXT: &str = "no next tracks"; - const AUTOPLAY: &str = "autoplay"; - const ENDLESS_CONTEXT: &str = "endless_context"; - - if self.player.restrictions.is_none() { - self.player.restrictions = MessageField::some(Restrictions::new()) - } - - if let Some(restrictions) = self.player.restrictions.as_mut() { - if self.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 self.next_tracks.is_empty() { - restrictions.disallow_peeking_next_reasons = vec![NO_NEXT.to_string()]; - restrictions.disallow_skipping_next_reasons = vec![NO_NEXT.to_string()]; - } else { - restrictions.disallow_peeking_next_reasons.clear(); - restrictions.disallow_skipping_next_reasons.clear(); - } - - if self.player.track.provider == AUTOPLAY_PROVIDER { - 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 self.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(); - } - } - } - - fn clear_next_tracks(&mut self) { - // 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.provider != QUEUE_PROVIDER); - - if let Some((non_queued_track, _)) = first_non_queued_track { - while self.next_tracks.len() > non_queued_track && self.next_tracks.pop_back().is_some() - { - } - } - } - - fn get_current_context(&self) -> Result<&StateContext, StateError> { - match self.active_context { - ContextType::Default => self.context.as_ref(), - ContextType::Shuffle => self.shuffle_context.as_ref(), - ContextType::Autoplay => self.autoplay_context.as_ref(), - } - .ok_or(StateError::NoContext(self.active_context)) - } - - pub fn fill_up_next_tracks(&mut self) -> Result<(), StateError> { - let ctx = self.get_current_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_current_context()?; - let track = match ctx.tracks.get(new_index) { - None if self.player.options.repeating_context => { - let delimiter = Self::delimiter(iteration.into()); - iteration += 1; - new_index = 0; - delimiter - } - None if self.autoplay_context.is_some() => { - // transitional to autoplay as active context - self.active_context = ContextType::Autoplay; - - match self.get_current_context()?.tracks.get(new_index) { - None => break, - Some(ct) => { - new_index += 1; - ct.clone() - } - } - } - None => break, - Some(ct) if ct.provider == UNAVAILABLE_PROVIDER => { - new_index += 1; - continue; - } - Some(ct) => { - new_index += 1; - ct.clone() - } - }; - - self.next_tracks.push_back(track); - } - - self.update_context_index(new_index)?; - - // the web-player needs a revision update, otherwise the queue isn't updated in the ui - self.player.queue_revision = self.new_queue_revision(); - - Ok(()) - } - - 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())) - } - fn mark_as_unavailable_for_match(track: &mut ProvidedTrack, uri: &str) { if track.uri == uri { debug!("Marked <{}:{}> as unavailable", track.provider, track.uri); @@ -921,68 +411,6 @@ impl ConnectState { } } - fn delimiter(iteration: i64) -> ProvidedTrack { - const HIDDEN: &str = "hidden"; - const ITERATION: &str = "iteration"; - - let mut metadata = HashMap::new(); - metadata.insert(HIDDEN.to_string(), true.to_string()); - metadata.insert(ITERATION.to_string(), iteration.to_string()); - - ProvidedTrack { - uri: format!("spotify:{DELIMITER_IDENTIFIER}"), - uid: format!("{DELIMITER_IDENTIFIER}{iteration}"), - provider: CONTEXT_PROVIDER.to_string(), - metadata, - ..Default::default() - } - } - - pub fn context_to_provided_track( - &self, - ctx_track: &ContextTrack, - context_uri: String, - provider: Option<&str>, - ) -> Result { - let provider = if self.unavailable_uri.contains(&ctx_track.uri) { - UNAVAILABLE_PROVIDER - } else { - provider.unwrap_or(CONTEXT_PROVIDER) - }; - - let id = if !ctx_track.uri.is_empty() { - SpotifyId::from_uri(&ctx_track.uri) - } else if !ctx_track.gid.is_empty() { - SpotifyId::from_raw(&ctx_track.gid) - } else { - return Err(Error::unavailable("track not available")); - }?; - - let mut metadata = HashMap::new(); - metadata.insert(METADATA_CONTEXT_URI.to_string(), context_uri.to_string()); - metadata.insert(METADATA_ENTITY_URI.to_string(), context_uri.to_string()); - - if !ctx_track.metadata.is_empty() { - for (k, v) in &ctx_track.metadata { - metadata.insert(k.to_string(), v.to_string()); - } - } - - let uid = if !ctx_track.uid.is_empty() { - ctx_track.uid.clone() - } else { - String::from_utf8(id.to_raw().to_vec()).unwrap_or_else(|_| String::new()) - }; - - Ok(ProvidedTrack { - uri: id.to_uri()?.replace("unknown", "track"), - uid, - metadata, - provider: provider.to_string(), - ..Default::default() - }) - } - pub fn update_position_in_relation(&mut self, timestamp: i64) { let diff = timestamp - self.player.timestamp; self.player.position_as_of_timestamp += diff; diff --git a/connect/src/state/consts.rs b/connect/src/state/consts.rs new file mode 100644 index 000000000..2e3768565 --- /dev/null +++ b/connect/src/state/consts.rs @@ -0,0 +1,18 @@ +// providers used by spotify +pub const PROVIDER_CONTEXT: &str = "context"; +pub const PROVIDER_QUEUE: &str = "queue"; +pub const PROVIDER_AUTOPLAY: &str = "autoplay"; + +// custom providers, used to identify certain states that we can't handle preemptively, yet +// todo: we might just need to remove tracks that are unavailable to play, will have to see how the official clients handle this provider +// it seems like spotify just knows that the track isn't available, currently i didn't found +// a solution to do the same, so we stay with the old solution for now +pub const UNAVAILABLE_PROVIDER: &str = "unavailable"; + +// identifier used as part of the uid +pub const IDENTIFIER_DELIMITER: &str = "delimiter"; + +// metadata entries +pub const METADATA_CONTEXT_URI: &str = "context_uri"; +pub const METADATA_ENTITY_URI: &str = "entity_uri"; +pub const METADATA_IS_QUEUED: &str = "is_queued"; diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs new file mode 100644 index 000000000..4d7f8244a --- /dev/null +++ b/connect/src/state/context.rs @@ -0,0 +1,212 @@ +use crate::state::consts::{METADATA_ENTITY_URI, UNAVAILABLE_PROVIDER}; +use crate::state::{ + ConnectState, StateError, METADATA_CONTEXT_URI, PROVIDER_AUTOPLAY, PROVIDER_CONTEXT, +}; +use librespot_core::{Error, SpotifyId}; +use librespot_protocol::player::{Context, ContextIndex, ContextTrack, ProvidedTrack}; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct StateContext { + pub tracks: Vec, + pub metadata: HashMap, + pub index: ContextIndex, +} + +#[derive(Default, Debug, Copy, Clone)] +pub enum ContextType { + #[default] + Default, + Shuffle, + Autoplay, +} + +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_current_context(&self) -> Result<&StateContext, StateError> { + match self.active_context { + ContextType::Default => self.context.as_ref(), + ContextType::Shuffle => self.shuffle_context.as_ref(), + ContextType::Autoplay => self.autoplay_context.as_ref(), + } + .ok_or(StateError::NoContext(self.active_context)) + } + + pub fn reset_context(&mut self, new_context: Option<&str>) { + self.active_context = ContextType::Default; + + self.autoplay_context = None; + self.shuffle_context = None; + + if matches!(new_context, Some(ctx) if self.player.context_uri != ctx) { + self.context = None; + } else if let Some(ctx) = self.context.as_mut() { + ctx.index.track = 0; + ctx.index.page = 0; + } + + self.update_restrictions() + } + + pub fn update_context(&mut self, mut context: Context) -> Result<(), Error> { + debug!("context: {}, {}", context.uri, context.url); + let page = context + .pages + .pop() + .ok_or(StateError::NoContext(ContextType::Default))?; + + let tracks = page + .tracks + .iter() + .flat_map(|track| { + match self.context_to_provided_track(track, context.uri.clone(), None) { + Ok(t) => Some(t), + Err(_) => { + error!("couldn't convert {track:#?} into ProvidedTrack"); + None + } + } + }) + .collect(); + + self.context = Some(StateContext { + tracks, + metadata: page.metadata, + index: ContextIndex::new(), + }); + + self.player.context_url = format!("context://{}", context.uri); + self.player.context_uri = context.uri; + + if context.restrictions.is_some() { + self.player.context_restrictions = context.restrictions; + } + + if !context.metadata.is_empty() { + self.player.context_metadata = context.metadata; + } + + if let Some(transfer_state) = self.transfer_state.take() { + self.setup_current_state(transfer_state)? + } + + Ok(()) + } + + pub fn update_autoplay_context(&mut self, mut context: Context) -> Result<(), Error> { + debug!( + "autoplay-context: {}, pages: {}", + context.uri, + context.pages.len() + ); + let page = context + .pages + .pop() + .ok_or(StateError::NoContext(ContextType::Autoplay))?; + debug!("autoplay-context size: {}", page.tracks.len()); + + let tracks = page + .tracks + .iter() + .flat_map(|track| { + match self.context_to_provided_track( + track, + context.uri.clone(), + Some(PROVIDER_AUTOPLAY), + ) { + Ok(t) => Some(t), + Err(_) => { + error!("couldn't convert {track:#?} into ProvidedTrack"); + None + } + } + }) + .collect::>(); + + // add the tracks to the context if we already have an autoplay context + if let Some(autoplay_context) = self.autoplay_context.as_mut() { + for track in tracks { + autoplay_context.tracks.push(track) + } + } else { + self.autoplay_context = Some(StateContext { + tracks, + metadata: page.metadata, + index: ContextIndex::new(), + }) + } + + Ok(()) + } + + pub(super) fn update_context_index(&mut self, new_index: usize) -> Result<(), StateError> { + let context = match self.active_context { + ContextType::Default => self.context.as_mut(), + ContextType::Shuffle => self.shuffle_context.as_mut(), + ContextType::Autoplay => self.autoplay_context.as_mut(), + } + .ok_or(StateError::NoContext(self.active_context))?; + + context.index.track = new_index as u32; + Ok(()) + } + + pub fn context_to_provided_track( + &self, + ctx_track: &ContextTrack, + context_uri: String, + provider: Option<&str>, + ) -> Result { + let provider = if self.unavailable_uri.contains(&ctx_track.uri) { + UNAVAILABLE_PROVIDER + } else { + provider.unwrap_or(PROVIDER_CONTEXT) + }; + + let id = if !ctx_track.uri.is_empty() { + SpotifyId::from_uri(&ctx_track.uri) + } else if !ctx_track.gid.is_empty() { + SpotifyId::from_raw(&ctx_track.gid) + } else { + return Err(Error::unavailable("track not available")); + }?; + + let mut metadata = HashMap::new(); + metadata.insert(METADATA_CONTEXT_URI.to_string(), context_uri.to_string()); + metadata.insert(METADATA_ENTITY_URI.to_string(), context_uri.to_string()); + + if !ctx_track.metadata.is_empty() { + for (k, v) in &ctx_track.metadata { + metadata.insert(k.to_string(), v.to_string()); + } + } + + let uid = if !ctx_track.uid.is_empty() { + ctx_track.uid.clone() + } else { + // todo: this will never work, it is sadly not as simple :/ + String::from_utf8(id.to_raw().to_vec()).unwrap_or_else(|_| String::new()) + }; + + Ok(ProvidedTrack { + uri: id.to_uri()?.replace("unknown", "track"), + uid, + metadata, + provider: provider.to_string(), + ..Default::default() + }) + } +} diff --git a/connect/src/state/options.rs b/connect/src/state/options.rs new file mode 100644 index 000000000..4bc6ae031 --- /dev/null +++ b/connect/src/state/options.rs @@ -0,0 +1,75 @@ +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.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.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.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.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() + { + return Err(StateError::CurrentlyDisallowed { + action: "shuffle".to_string(), + reason: reason.clone(), + } + .into()); + } + + self.prev_tracks.clear(); + self.clear_next_tracks(); + + let current_uri = &self.player.track.uri; + + let ctx = self + .context + .as_mut() + .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.active_context = ContextType::Shuffle; + self.fill_up_next_tracks()?; + + Ok(()) + } +} diff --git a/connect/src/state/restrictions.rs b/connect/src/state/restrictions.rs new file mode 100644 index 000000000..da3c7fd73 --- /dev/null +++ b/connect/src/state/restrictions.rs @@ -0,0 +1,60 @@ +use crate::state::{ConnectState, PROVIDER_AUTOPLAY}; +use librespot_protocol::player::Restrictions; +use protobuf::MessageField; + +impl ConnectState { + pub fn update_restrictions(&mut self) { + const NO_PREV: &str = "no previous tracks"; + const NO_NEXT: &str = "no next tracks"; + const AUTOPLAY: &str = "autoplay"; + const ENDLESS_CONTEXT: &str = "endless_context"; + + if let Some(restrictions) = self.player.restrictions.as_mut() { + if self.player.is_playing { + restrictions.disallow_pausing_reasons.clear(); + restrictions.disallow_resuming_reasons = vec!["not_paused".to_string()] + } + + if self.player.is_paused { + restrictions.disallow_resuming_reasons.clear(); + restrictions.disallow_pausing_reasons = vec!["not_playing".to_string()] + } + } + + if self.player.restrictions.is_none() { + self.player.restrictions = MessageField::some(Restrictions::new()) + } + + if let Some(restrictions) = self.player.restrictions.as_mut() { + if self.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 self.next_tracks.is_empty() { + restrictions.disallow_peeking_next_reasons = vec![NO_NEXT.to_string()]; + restrictions.disallow_skipping_next_reasons = vec![NO_NEXT.to_string()]; + } else { + restrictions.disallow_peeking_next_reasons.clear(); + restrictions.disallow_skipping_next_reasons.clear(); + } + + if self.player.track.provider == PROVIDER_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 self.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 000000000..bc24d425a --- /dev/null +++ b/connect/src/state/tracks.rs @@ -0,0 +1,252 @@ +use crate::state::consts::{ + IDENTIFIER_DELIMITER, PROVIDER_AUTOPLAY, PROVIDER_CONTEXT, PROVIDER_QUEUE, UNAVAILABLE_PROVIDER, +}; +use crate::state::context::ContextType; +use crate::state::{ + ConnectState, StateError, SPOTIFY_MAX_NEXT_TRACKS_SIZE, SPOTIFY_MAX_PREV_TRACKS_SIZE, +}; +use librespot_core::Error; +use librespot_protocol::player::ProvidedTrack; +use protobuf::MessageField; +use std::collections::HashMap; + +impl ConnectState { + fn new_delimiter(iteration: i64) -> ProvidedTrack { + const HIDDEN: &str = "hidden"; + const ITERATION: &str = "iteration"; + + let mut metadata = HashMap::new(); + metadata.insert(HIDDEN.to_string(), true.to_string()); + metadata.insert(ITERATION.to_string(), iteration.to_string()); + + ProvidedTrack { + uri: format!("spotify:{IDENTIFIER_DELIMITER}"), + uid: format!("{IDENTIFIER_DELIMITER}{iteration}"), + provider: PROVIDER_CONTEXT.to_string(), + metadata, + ..Default::default() + } + } + + pub fn set_current_track(&mut self, index: usize) -> Result<(), Error> { + let context = self.get_current_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 + 1, + new_track.uri, + context.tracks.len() + ); + + self.player.track = MessageField::some(new_track.clone()); + + 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, StateError> { + // when we skip in repeat track, we don't repeat the current track anymore + if self.player.options.repeating_track { + self.set_repeat_track(false); + } + + let old_track = self.player.track.take(); + + if let Some(old_track) = old_track { + // only add songs from our context to our previous tracks + if old_track.provider == PROVIDER_CONTEXT || old_track.provider == PROVIDER_AUTOPLAY { + // add old current track to prev tracks, while preserving a length of 10 + if self.prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { + _ = self.prev_tracks.pop_front(); + } + self.prev_tracks.push_back(old_track); + } + } + + let new_track = match self.next_tracks.pop_front() { + Some(next) if next.uid.starts_with(IDENTIFIER_DELIMITER) => { + if self.prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { + _ = self.prev_tracks.pop_front(); + } + self.prev_tracks.push_back(next); + self.next_tracks.pop_front() + } + Some(next) if next.provider == UNAVAILABLE_PROVIDER => self.next_tracks.pop_front(), + other => other, + }; + + let new_track = match new_track { + None => return Ok(None), + Some(t) => t, + }; + + self.fill_up_next_tracks()?; + + let is_queued_track = new_track.provider == PROVIDER_QUEUE; + let is_autoplay = new_track.provider == PROVIDER_AUTOPLAY; + let update_index = if (is_queued_track || is_autoplay) && self.player.index.is_some() { + // the index isn't send when we are a queued track, but we have to preserve it for later + self.player_index = self.player.index.take(); + None + } else if is_autoplay || is_queued_track { + 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 { + if let Some(index) = self.player.index.as_mut() { + index.track = update_index + } else { + debug!("next: index can't be updated, no index available") + } + } + + self.player.track = MessageField::some(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>, StateError> { + let old_track = self.player.track.take(); + + if let Some(old_track) = old_track { + if old_track.provider == PROVIDER_CONTEXT || old_track.provider == PROVIDER_AUTOPLAY { + self.next_tracks.push_front(old_track); + } + } + + // handle possible delimiter + if matches!(self.prev_tracks.back(), Some(prev) if prev.uid.starts_with(IDENTIFIER_DELIMITER)) + { + let delimiter = self + .prev_tracks + .pop_back() + .expect("item that was prechecked"); + if self.next_tracks.len() >= SPOTIFY_MAX_NEXT_TRACKS_SIZE { + _ = self.next_tracks.pop_back(); + } + self.next_tracks.push_front(delimiter) + } + + while self.next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE { + let _ = self.next_tracks.pop_back(); + } + + let new_track = match self.prev_tracks.pop_back() { + None => return Ok(None), + Some(t) => t, + }; + + if matches!(self.active_context, ContextType::Autoplay if new_track.provider == PROVIDER_CONTEXT) + { + // transition back to default context + self.active_context = ContextType::Default; + } + + self.fill_up_next_tracks()?; + + self.player.track = MessageField::some(new_track); + + if self.player.index.track == 0 { + warn!("prev: trying to skip into negative, index update skipped") + } else if let Some(index) = self.player.index.as_mut() { + index.track -= 1; + } else { + debug!("prev: index can't be decreased, no index available") + } + + self.update_restrictions(); + + Ok(Some(&self.player.track)) + } + + pub(super) fn clear_next_tracks(&mut self) { + // 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.provider != PROVIDER_QUEUE); + + if let Some((non_queued_track, _)) = first_non_queued_track { + while self.next_tracks.len() > non_queued_track && self.next_tracks.pop_back().is_some() + { + } + } + } + + pub fn fill_up_next_tracks(&mut self) -> Result<(), StateError> { + let ctx = self.get_current_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_current_context()?; + let track = match ctx.tracks.get(new_index) { + None if self.player.options.repeating_context => { + let delimiter = Self::new_delimiter(iteration.into()); + iteration += 1; + new_index = 0; + delimiter + } + None if self.autoplay_context.is_some() => { + // transitional to autoplay as active context + self.active_context = ContextType::Autoplay; + + match self.get_current_context()?.tracks.get(new_index) { + None => break, + Some(ct) => { + new_index += 1; + ct.clone() + } + } + } + None => break, + Some(ct) if ct.provider == UNAVAILABLE_PROVIDER => { + new_index += 1; + continue; + } + Some(ct) => { + new_index += 1; + ct.clone() + } + }; + + self.next_tracks.push_back(track); + } + + self.update_context_index(new_index)?; + + // the web-player needs a revision update, otherwise the queue isn't updated in the ui + self.player.queue_revision = self.new_queue_revision(); + + Ok(()) + } +} From 13e692bf1f7c0813f41abc6855c3fc84a8878638 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Thu, 31 Oct 2024 17:46:41 +0100 Subject: [PATCH 058/138] connect: encapsulate provider logic --- connect/src/spirc.rs | 13 +++--- connect/src/state.rs | 21 +++++----- connect/src/state/consts.rs | 11 ------ connect/src/state/context.rs | 15 ++++--- connect/src/state/provider.rs | 66 +++++++++++++++++++++++++++++++ connect/src/state/restrictions.rs | 5 ++- connect/src/state/tracks.rs | 27 ++++++------- 7 files changed, 103 insertions(+), 55 deletions(-) create mode 100644 connect/src/state/provider.rs diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 3c0b7f921..62d2305cb 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,5 +1,6 @@ -use crate::state::consts::{METADATA_IS_QUEUED, PROVIDER_AUTOPLAY, PROVIDER_QUEUE}; +use crate::state::consts::METADATA_IS_QUEUED; use crate::state::context::ContextType; +use crate::state::provider::{IsProvider, Provider}; use crate::state::{ConnectState, ConnectStateConfig}; use crate::{ core::{authentication::Credentials, session::UserAttributes, Error, Session, SpotifyId}, @@ -513,11 +514,11 @@ impl SpircTask { .connect_state .prev_tracks .iter() - .flat_map(|t| (t.provider == PROVIDER_AUTOPLAY).then_some(t.uri.clone())) + .flat_map(|t| t.is_autoplay().then_some(t.uri.clone())) .collect::>(); let current = &self.connect_state.player.track; - if current.provider == PROVIDER_AUTOPLAY { + if current.is_autoplay() { previous_tracks.push(current.uri.clone()); } @@ -1079,7 +1080,7 @@ impl SpircTask { } if self.connect_state.autoplay_context.is_none() - && (self.connect_state.player.track.provider == PROVIDER_AUTOPLAY || autoplay) + && (self.connect_state.player.track.is_autoplay() || autoplay) { debug!("currently in autoplay context, async resolving autoplay for {ctx_uri}"); self.resolve_context.push(ResolveContext { @@ -1517,9 +1518,7 @@ impl SpircTask { .next_tracks .iter_mut() .filter(|t| t.metadata.contains_key(METADATA_IS_QUEUED)) - .for_each(|t| { - t.provider = PROVIDER_QUEUE.to_string(); - }); + .for_each(|t| t.set_provider(Provider::Queue)); self.connect_state.next_tracks = set_queue_command.next_tracks.into(); self.connect_state.prev_tracks = set_queue_command.prev_tracks.into(); diff --git a/connect/src/state.rs b/connect/src/state.rs index 4dbaaf9b9..c32e9e011 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -1,6 +1,7 @@ pub(super) mod consts; pub(super) mod context; mod options; +pub(super) mod provider; mod restrictions; mod tracks; @@ -9,11 +10,9 @@ use std::hash::Hasher; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use crate::spirc::SpircPlayStatus; -use crate::state::consts::{ - METADATA_CONTEXT_URI, METADATA_IS_QUEUED, PROVIDER_AUTOPLAY, PROVIDER_CONTEXT, PROVIDER_QUEUE, - UNAVAILABLE_PROVIDER, -}; +use crate::state::consts::{METADATA_CONTEXT_URI, METADATA_IS_QUEUED}; use crate::state::context::{ContextType, StateContext}; +use crate::state::provider::{IsProvider, Provider}; use librespot_core::config::DeviceType; use librespot_core::dealer::protocol::Request; use librespot_core::spclient::SpClientResult; @@ -245,7 +244,7 @@ impl ConnectState { debug!("reset playback state to {new_index}"); - if self.player.track.provider != PROVIDER_QUEUE { + if !self.player.track.is_queue() { self.set_current_track(new_index)?; } @@ -275,17 +274,15 @@ impl ConnectState { } pub fn add_to_queue(&mut self, mut track: ProvidedTrack, rev_update: bool) { - track.provider = PROVIDER_QUEUE.to_string(); + track.set_provider(Provider::Queue); if !track.metadata.contains_key(METADATA_IS_QUEUED) { track .metadata .insert(METADATA_IS_QUEUED.to_string(), true.to_string()); } - if let Some(next_not_queued_track) = self - .next_tracks - .iter() - .position(|track| track.provider != PROVIDER_QUEUE) + if let Some(next_not_queued_track) = + self.next_tracks.iter().position(|track| !track.is_queue()) { self.next_tracks.insert(next_not_queued_track, track); } else { @@ -316,7 +313,7 @@ impl ConnectState { self.context_to_provided_track( track, transfer.current_session.context.uri.clone(), - transfer.queue.is_playing_queue.then_some(PROVIDER_QUEUE), + transfer.queue.is_playing_queue.then_some(Provider::Queue), ) } @@ -407,7 +404,7 @@ impl ConnectState { fn mark_as_unavailable_for_match(track: &mut ProvidedTrack, uri: &str) { if track.uri == uri { debug!("Marked <{}:{}> as unavailable", track.provider, track.uri); - track.provider = UNAVAILABLE_PROVIDER.to_string(); + track.set_provider(Provider::Unavailable); } } diff --git a/connect/src/state/consts.rs b/connect/src/state/consts.rs index 2e3768565..8830755e6 100644 --- a/connect/src/state/consts.rs +++ b/connect/src/state/consts.rs @@ -1,14 +1,3 @@ -// providers used by spotify -pub const PROVIDER_CONTEXT: &str = "context"; -pub const PROVIDER_QUEUE: &str = "queue"; -pub const PROVIDER_AUTOPLAY: &str = "autoplay"; - -// custom providers, used to identify certain states that we can't handle preemptively, yet -// todo: we might just need to remove tracks that are unavailable to play, will have to see how the official clients handle this provider -// it seems like spotify just knows that the track isn't available, currently i didn't found -// a solution to do the same, so we stay with the old solution for now -pub const UNAVAILABLE_PROVIDER: &str = "unavailable"; - // identifier used as part of the uid pub const IDENTIFIER_DELIMITER: &str = "delimiter"; diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 4d7f8244a..b9aac0b64 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -1,7 +1,6 @@ -use crate::state::consts::{METADATA_ENTITY_URI, UNAVAILABLE_PROVIDER}; -use crate::state::{ - ConnectState, StateError, METADATA_CONTEXT_URI, PROVIDER_AUTOPLAY, PROVIDER_CONTEXT, -}; +use crate::state::consts::METADATA_ENTITY_URI; +use crate::state::provider::Provider; +use crate::state::{ConnectState, StateError, METADATA_CONTEXT_URI}; use librespot_core::{Error, SpotifyId}; use librespot_protocol::player::{Context, ContextIndex, ContextTrack, ProvidedTrack}; use std::collections::HashMap; @@ -125,7 +124,7 @@ impl ConnectState { match self.context_to_provided_track( track, context.uri.clone(), - Some(PROVIDER_AUTOPLAY), + Some(Provider::Autoplay), ) { Ok(t) => Some(t), Err(_) => { @@ -168,12 +167,12 @@ impl ConnectState { &self, ctx_track: &ContextTrack, context_uri: String, - provider: Option<&str>, + provider: Option, ) -> Result { let provider = if self.unavailable_uri.contains(&ctx_track.uri) { - UNAVAILABLE_PROVIDER + Provider::Unavailable } else { - provider.unwrap_or(PROVIDER_CONTEXT) + provider.unwrap_or(Provider::Context) }; let id = if !ctx_track.uri.is_empty() { diff --git a/connect/src/state/provider.rs b/connect/src/state/provider.rs new file mode 100644 index 000000000..8869e15d9 --- /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 +// todo: we might just need to remove tracks that are unavailable to play, will have to see how the official clients handle this provider +// it seems like spotify just knows that the track isn't available, currently i didn't found +// a solution to do the same, so we stay with the old solution for now +const PROVIDER_UNAVAILABLE: &str = "unavailable"; + +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 index da3c7fd73..953139a9e 100644 --- a/connect/src/state/restrictions.rs +++ b/connect/src/state/restrictions.rs @@ -1,4 +1,5 @@ -use crate::state::{ConnectState, PROVIDER_AUTOPLAY}; +use crate::state::provider::IsProvider; +use crate::state::ConnectState; use librespot_protocol::player::Restrictions; use protobuf::MessageField; @@ -42,7 +43,7 @@ impl ConnectState { restrictions.disallow_skipping_next_reasons.clear(); } - if self.player.track.provider == PROVIDER_AUTOPLAY { + if self.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()]; diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index bc24d425a..87501e534 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -1,7 +1,6 @@ -use crate::state::consts::{ - IDENTIFIER_DELIMITER, PROVIDER_AUTOPLAY, PROVIDER_CONTEXT, PROVIDER_QUEUE, UNAVAILABLE_PROVIDER, -}; +use crate::state::consts::IDENTIFIER_DELIMITER; use crate::state::context::ContextType; +use crate::state::provider::{IsProvider, Provider}; use crate::state::{ ConnectState, StateError, SPOTIFY_MAX_NEXT_TRACKS_SIZE, SPOTIFY_MAX_PREV_TRACKS_SIZE, }; @@ -22,7 +21,7 @@ impl ConnectState { ProvidedTrack { uri: format!("spotify:{IDENTIFIER_DELIMITER}"), uid: format!("{IDENTIFIER_DELIMITER}{iteration}"), - provider: PROVIDER_CONTEXT.to_string(), + provider: Provider::Context.to_string(), metadata, ..Default::default() } @@ -65,7 +64,7 @@ impl ConnectState { if let Some(old_track) = old_track { // only add songs from our context to our previous tracks - if old_track.provider == PROVIDER_CONTEXT || old_track.provider == PROVIDER_AUTOPLAY { + if old_track.is_context() || old_track.is_autoplay() { // add old current track to prev tracks, while preserving a length of 10 if self.prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { _ = self.prev_tracks.pop_front(); @@ -82,7 +81,7 @@ impl ConnectState { self.prev_tracks.push_back(next); self.next_tracks.pop_front() } - Some(next) if next.provider == UNAVAILABLE_PROVIDER => self.next_tracks.pop_front(), + Some(next) if next.is_unavailable() => self.next_tracks.pop_front(), other => other, }; @@ -93,13 +92,12 @@ impl ConnectState { self.fill_up_next_tracks()?; - let is_queued_track = new_track.provider == PROVIDER_QUEUE; - let is_autoplay = new_track.provider == PROVIDER_AUTOPLAY; - let update_index = if (is_queued_track || is_autoplay) && self.player.index.is_some() { + let is_queue_or_autoplay = new_track.is_queue() || new_track.is_autoplay(); + let update_index = if is_queue_or_autoplay && self.player.index.is_some() { // the index isn't send when we are a queued track, but we have to preserve it for later self.player_index = self.player.index.take(); None - } else if is_autoplay || is_queued_track { + } else if is_queue_or_autoplay { None } else { let ctx = self.context.as_ref(); @@ -137,7 +135,7 @@ impl ConnectState { let old_track = self.player.track.take(); if let Some(old_track) = old_track { - if old_track.provider == PROVIDER_CONTEXT || old_track.provider == PROVIDER_AUTOPLAY { + if old_track.is_context() || old_track.is_autoplay() { self.next_tracks.push_front(old_track); } } @@ -164,8 +162,7 @@ impl ConnectState { Some(t) => t, }; - if matches!(self.active_context, ContextType::Autoplay if new_track.provider == PROVIDER_CONTEXT) - { + if matches!(self.active_context, ContextType::Autoplay if new_track.is_context()) { // transition back to default context self.active_context = ContextType::Default; } @@ -193,7 +190,7 @@ impl ConnectState { .next_tracks .iter() .enumerate() - .find(|(_, track)| track.provider != PROVIDER_QUEUE); + .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.pop_back().is_some() @@ -229,7 +226,7 @@ impl ConnectState { } } None => break, - Some(ct) if ct.provider == UNAVAILABLE_PROVIDER => { + Some(ct) if ct.is_unavailable() => { new_index += 1; continue; } From 2e93607f9f088ac024bf2aeffbfed11b8848ce05 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Thu, 31 Oct 2024 18:18:01 +0100 Subject: [PATCH 059/138] connect: remove public access to next and prev tracks --- connect/src/spirc.rs | 52 +++++++----------------------- connect/src/state.rs | 8 ++--- connect/src/state/options.rs | 2 +- connect/src/state/tracks.rs | 62 +++++++++++++++++++++++++++++++++--- 4 files changed, 75 insertions(+), 49 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 62d2305cb..8e99a7b6e 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,6 +1,5 @@ -use crate::state::consts::METADATA_IS_QUEUED; use crate::state::context::ContextType; -use crate::state::provider::{IsProvider, Provider}; +use crate::state::provider::IsProvider; use crate::state::{ConnectState, ConnectStateConfig}; use crate::{ core::{authentication::Credentials, session::UserAttributes, Error, Session, SpotifyId}, @@ -510,17 +509,7 @@ impl SpircTask { .into()); } - let mut previous_tracks = self - .connect_state - .prev_tracks - .iter() - .flat_map(|t| t.is_autoplay().then_some(t.uri.clone())) - .collect::>(); - - let current = &self.connect_state.player.track; - if current.is_autoplay() { - previous_tracks.push(current.uri.clone()); - } + let previous_tracks = self.connect_state.prev_autoplay_track_uris(); debug!( "loading autoplay context {context_uri} with {} previous tracks", @@ -994,7 +983,7 @@ impl SpircTask { self.connect_state .reset_context(Some(&transfer.current_session.context.uri)); - let mut ctx_uri = transfer.current_session.context.uri.to_owned(); + let mut ctx_uri = transfer.current_session.context.uri.clone(); let autoplay = ctx_uri.contains("station"); if autoplay { @@ -1164,7 +1153,7 @@ impl SpircTask { self.resolve_context(cmd.context_uri.clone(), false).await?; } - self.connect_state.next_tracks.clear(); + self.connect_state.clear_next_tracks(false); self.connect_state.player.track = MessageField::none(); let index = match cmd.playing_track { @@ -1202,7 +1191,7 @@ impl SpircTask { self.handle_stop(); } - if self.connect_state.next_tracks.is_empty() && self.session.autoplay() { + if !self.connect_state.has_next_tracks(None) && self.session.autoplay() { self.resolve_context.push(ResolveContext { uri: cmd.context_uri, autoplay: true, @@ -1295,16 +1284,6 @@ impl SpircTask { }; } - fn preview_next_track(&mut self) -> Option { - let next = if self.connect_state.player.options.repeating_track { - &self.connect_state.player.track.uri - } else { - &self.connect_state.next_tracks.front()?.uri - }; - - SpotifyId::from_uri(next).ok() - } - fn handle_preload_next_track(&mut self) { // Requests the player thread to preload the next track match self.play_status { @@ -1321,7 +1300,7 @@ 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); } } @@ -1335,7 +1314,9 @@ impl SpircTask { } fn conditional_preload_autoplay(&mut self, uri: String) { - let preload_autoplay = self.connect_state.next_tracks.len() < CONTEXT_FETCH_THRESHOLD + let preload_autoplay = self + .connect_state + .has_next_tracks(Some(CONTEXT_FETCH_THRESHOLD)) && self.session.autoplay(); // When in autoplay, keep topping up the playlist when it nears the end @@ -1510,18 +1491,9 @@ impl SpircTask { } } - fn handle_set_queue(&mut self, mut set_queue_command: SetQueueCommand) { - // 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 - set_queue_command - .next_tracks - .iter_mut() - .filter(|t| t.metadata.contains_key(METADATA_IS_QUEUED)) - .for_each(|t| t.set_provider(Provider::Queue)); - - self.connect_state.next_tracks = set_queue_command.next_tracks.into(); - self.connect_state.prev_tracks = set_queue_command.prev_tracks.into(); + fn handle_set_queue(&mut self, set_queue: SetQueueCommand) { + self.connect_state.set_next_tracks(set_queue.next_tracks); + self.connect_state.set_prev_tracks(set_queue.prev_tracks); self.connect_state.player.queue_revision = self.connect_state.new_queue_revision(); } diff --git a/connect/src/state.rs b/connect/src/state.rs index c32e9e011..3a33b867a 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -1,4 +1,4 @@ -pub(super) mod consts; +mod consts; pub(super) mod context; mod options; pub(super) mod provider; @@ -94,8 +94,8 @@ pub struct ConnectState { /// we don't work directly on the lists of the player state, because /// we mostly need to push and pop at the beginning of both - pub prev_tracks: VecDeque, - pub next_tracks: VecDeque, + prev_tracks: VecDeque, + next_tracks: VecDeque, pub active_context: ContextType, /// the context from which we play, is used to top up prev and next tracks @@ -266,7 +266,7 @@ impl ConnectState { debug!("has {} prev tracks", self.prev_tracks.len()) } - self.clear_next_tracks(); + self.clear_next_tracks(true); self.fill_up_next_tracks()?; self.update_restrictions(); diff --git a/connect/src/state/options.rs b/connect/src/state/options.rs index 4bc6ae031..3856be31a 100644 --- a/connect/src/state/options.rs +++ b/connect/src/state/options.rs @@ -48,7 +48,7 @@ impl ConnectState { } self.prev_tracks.clear(); - self.clear_next_tracks(); + self.clear_next_tracks(true); let current_uri = &self.player.track.uri; diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index 87501e534..c395f7d87 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -1,13 +1,13 @@ -use crate::state::consts::IDENTIFIER_DELIMITER; +use crate::state::consts::{IDENTIFIER_DELIMITER, METADATA_IS_QUEUED}; use crate::state::context::ContextType; use crate::state::provider::{IsProvider, Provider}; use crate::state::{ ConnectState, StateError, SPOTIFY_MAX_NEXT_TRACKS_SIZE, SPOTIFY_MAX_PREV_TRACKS_SIZE, }; -use librespot_core::Error; +use librespot_core::{Error, SpotifyId}; use librespot_protocol::player::ProvidedTrack; use protobuf::MessageField; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; impl ConnectState { fn new_delimiter(iteration: i64) -> ProvidedTrack { @@ -184,7 +184,28 @@ impl ConnectState { Ok(Some(&self.player.track)) } - pub(super) fn clear_next_tracks(&mut self) { + 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.metadata.contains_key(METADATA_IS_QUEUED)) + .for_each(|t| t.set_provider(Provider::Queue)); + + self.next_tracks = tracks.into(); + } + + pub fn set_prev_tracks(&mut self, tracks: impl Into>) { + self.prev_tracks = tracks.into(); + } + + pub fn clear_next_tracks(&mut self, keep_queued: bool) { + if !keep_queued { + self.next_tracks.clear(); + return; + } + // respect queued track and don't throw them out of our next played tracks let first_non_queued_track = self .next_tracks @@ -246,4 +267,37 @@ impl ConnectState { Ok(()) } + + pub fn preview_next_track(&mut self) -> Option { + let next = if self.player.options.repeating_track { + &self.player.track.uri + } else { + &self.next_tracks.front()?.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::>(); + + let current = &self.player.track; + if current.is_autoplay() { + prev.push(current.uri.clone()); + } + + prev + } } From d5b8a616d4192f152639cfd724c819360e07dceb Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Thu, 31 Oct 2024 20:46:34 +0100 Subject: [PATCH 060/138] connect: remove public access to player --- connect/src/spirc.rs | 222 ++++++++++++++++------------------- connect/src/state.rs | 69 +++++++++-- connect/src/state/context.rs | 35 +++--- connect/src/state/options.rs | 12 ++ connect/src/state/tracks.rs | 27 ++++- 5 files changed, 219 insertions(+), 146 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 8e99a7b6e..e39a65579 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -18,7 +18,7 @@ use librespot_core::dealer::protocol::{ }; use librespot_protocol::autoplay_context_request::AutoplayContextRequest; use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason, SetVolumeCommand}; -use librespot_protocol::player::TransferState; +use librespot_protocol::player::{Context, TransferState}; use protobuf::{Message, MessageField}; use std::{ future::Future, @@ -481,9 +481,9 @@ impl SpircTask { self.connect_state.fill_up_next_tracks()?; self.connect_state.update_restrictions(); - self.connect_state.player.queue_revision = self.connect_state.new_queue_revision(); + self.connect_state.update_queue_revision(); - self.conditional_preload_autoplay(self.connect_state.player.context_uri.clone()); + self.conditional_preload_autoplay(self.connect_state.context_uri().clone()); self.notify().await } @@ -539,11 +539,6 @@ impl SpircTask { dur.as_millis() as i64 + 1000 * self.session.time_delta() } - fn update_state_position(&mut self, position_ms: u32) { - self.connect_state.player.position_as_of_timestamp = position_ms.into(); - self.connect_state.player.timestamp = self.now_ms(); - } - async fn handle_command(&mut self, cmd: SpircCommand) -> Result<(), Error> { if matches!(cmd, SpircCommand::Shutdown) { trace!("Received SpircCommand::Shutdown"); @@ -631,7 +626,7 @@ impl SpircTask { async fn handle_player_event(&mut self, event: PlayerEvent) -> Result<(), Error> { if let PlayerEvent::TrackChanged { audio_item } = event { - self.connect_state.player.duration = audio_item.duration_ms.into(); + self.connect_state.update_duration(audio_item.duration_ms); return Ok(()); } @@ -651,15 +646,17 @@ impl SpircTask { PlayerEvent::Loading { .. } => { match self.play_status { SpircPlayStatus::LoadingPlay { position_ms } => { - self.update_state_position(position_ms); + self.connect_state + .update_position(position_ms, self.now_ms()); trace!("==> kPlayStatusPlay"); } SpircPlayStatus::LoadingPause { position_ms } => { - self.update_state_position(position_ms); + self.connect_state + .update_position(position_ms, self.now_ms()); trace!("==> kPlayStatusPause"); } _ => { - self.update_state_position(0); + self.connect_state.update_position(0, self.now_ms()); trace!("==> kPlayStatusLoading"); } } @@ -677,7 +674,8 @@ 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.connect_state + .update_position(position_ms, self.now_ms()); self.notify().await } else { Ok(()) @@ -685,7 +683,8 @@ impl SpircTask { } SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::LoadingPause { .. } => { - 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, @@ -702,7 +701,8 @@ impl SpircTask { trace!("==> kPlayStatusPause"); match self.play_status { SpircPlayStatus::Paused { .. } | SpircPlayStatus::Playing { .. } => { - 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, @@ -711,7 +711,8 @@ impl SpircTask { } SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::LoadingPause { .. } => { - 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, @@ -737,7 +738,7 @@ impl SpircTask { } PlayerEvent::Unavailable { track_id, .. } => { self.handle_unavailable(track_id)?; - if self.connect_state.player.track.uri == track_id.to_uri()? { + if self.connect_state.current_track(|t| &t.uri) == &track_id.to_uri()? { self.handle_next(None)?; } self.notify().await @@ -915,19 +916,42 @@ impl SpircTask { Reply::Failure } RequestCommand::Play(play) => { + let shuffle = play + .options + .player_option_overrides + .as_ref() + .map(|o| o.shuffling_context) + .unwrap_or_else(|| self.connect_state.shuffling_context()); + let repeat = play + .options + .player_option_overrides + .as_ref() + .map(|o| o.repeating_context) + .unwrap_or_else(|| self.connect_state.repeat_context()); + let repeat_track = play + .options + .player_option_overrides + .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, playing_track: play.options.skip_to.into(), - shuffle: self.connect_state.player.options.shuffling_context, - repeat: self.connect_state.player.options.repeating_context, - repeat_track: self.connect_state.player.options.repeating_track, + shuffle, + repeat, + repeat_track, }) .await?; - self.connect_state.player.context_uri = play.context.uri; - self.connect_state.player.context_url = play.context.url; - self.connect_state.player.play_origin = MessageField::some(play.play_origin); + self.connect_state.update_context(Context { + uri: play.context.uri, + url: play.context.url, + ..Default::default() + })?; + + self.connect_state.set_origin(play.play_origin); self.notify().await.map(|_| Reply::Success)? } @@ -1001,47 +1025,7 @@ impl SpircTask { let state = &mut self.connect_state; state.set_active(true); - state.player.is_buffering = false; - - if let Some(options) = transfer.options.take() { - state.player.options = MessageField::some(options); - } - state.player.is_paused = transfer.playback.is_paused; - state.player.is_playing = !transfer.playback.is_paused; - - if transfer.playback.playback_speed != 0. { - state.player.playback_speed = transfer.playback.playback_speed - } else { - state.player.playback_speed = 1.; - } - - state.player.play_origin = transfer.current_session.play_origin.clone(); - - if let Some(suppressions) = transfer.current_session.suppressions.as_ref() { - state.player.suppressions = MessageField::some(suppressions.clone()); - } - - if let Some(context) = transfer.current_session.context.as_ref() { - state.player.context_uri = context.uri.clone(); - state.player.context_url = context.url.clone(); - state.player.context_restrictions = context.restrictions.clone(); - } - - for (key, value) in &transfer.current_session.context.metadata { - state - .player - .context_metadata - .insert(key.clone(), value.clone()); - } - - if let Some(context) = &state.context { - for (key, value) in &context.metadata { - state - .player - .context_metadata - .insert(key.clone(), value.clone()); - } - } + state.transfer(&mut transfer); // update position if the track continued playing let position = if transfer.playback.is_paused { @@ -1053,6 +1037,8 @@ impl SpircTask { 0 }; + let is_playing = !transfer.playback.is_paused; + if self.connect_state.context.is_some() { self.connect_state.setup_current_state(transfer)?; } else { @@ -1064,12 +1050,12 @@ impl SpircTask { Err(why) => warn!("{why}"), Ok(track) => { debug!("initial track found"); - self.connect_state.player.track = MessageField::some(track) + self.connect_state.set_track(track) } } if self.connect_state.autoplay_context.is_none() - && (self.connect_state.player.track.is_autoplay() || autoplay) + && (self.connect_state.current_track(|t| t.is_autoplay()) || autoplay) { debug!("currently in autoplay context, async resolving autoplay for {ctx_uri}"); self.resolve_context.push(ResolveContext { @@ -1081,7 +1067,7 @@ impl SpircTask { self.connect_state.transfer_state = Some(transfer); } - self.load_track(self.connect_state.player.is_playing, position.try_into()?) + self.load_track(is_playing, position.try_into()?) } async fn handle_disconnect(&mut self) -> Result<(), Error> { @@ -1126,12 +1112,13 @@ impl SpircTask { self.player .emit_filter_explicit_content_changed_event(self.session.filter_explicit_content()); - let options = &self.connect_state.player.options; self.player - .emit_shuffle_changed_event(options.shuffling_context); + .emit_shuffle_changed_event(self.connect_state.shuffling_context()); - self.player - .emit_repeat_changed_event(options.repeating_context, options.repeating_track); + self.player.emit_repeat_changed_event( + self.connect_state.repeat_context(), + self.connect_state.repeat_track(), + ); } async fn handle_load(&mut self, cmd: SpircLoadCommand) -> Result<(), Error> { @@ -1141,12 +1128,12 @@ impl SpircTask { self.handle_activate(); } - if self.connect_state.player.context_uri == cmd.context_uri + if self.connect_state.context_uri() == &cmd.context_uri && self.connect_state.context.is_some() { debug!( "context <{}> didn't change, no resolving required", - self.connect_state.player.context_uri + self.connect_state.context_uri() ) } else { debug!("resolving context for load command"); @@ -1154,7 +1141,6 @@ impl SpircTask { } self.connect_state.clear_next_tracks(false); - self.connect_state.player.track = MessageField::none(); let index = match cmd.playing_track { PlayingTrack::Index(i) => i as usize, @@ -1168,10 +1154,6 @@ impl SpircTask { } }; - if let Some(i) = self.connect_state.player.index.as_mut() { - i.track = index as u32; - } - self.connect_state.set_shuffle(cmd.shuffle); if cmd.shuffle { self.connect_state.active_context = ContextType::Default; @@ -1184,7 +1166,7 @@ impl SpircTask { self.connect_state.set_repeat_context(cmd.repeat); self.connect_state.set_repeat_track(cmd.repeat_track); - if self.connect_state.player.track.is_some() { + if self.connect_state.current_track(MessageField::is_some) { self.load_track(cmd.start_playing, 0)?; } else { info!("No active track, stopping"); @@ -1208,7 +1190,8 @@ impl SpircTask { preloading_of_next_track_triggered, } => { self.player.play(); - 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, @@ -1247,7 +1230,8 @@ impl SpircTask { } => { self.player.pause(); 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, @@ -1262,7 +1246,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 { @@ -1330,20 +1315,21 @@ impl SpircTask { } } + fn is_playing(&self) -> bool { + matches!(self.play_status, SpircPlayStatus::Playing { .. }) + } + fn handle_next(&mut self, track_uri: Option) -> Result<(), Error> { - let player = &self.connect_state.player; - let context_uri = player.context_uri.to_owned(); - let continue_playing = player.is_playing; + let continue_playing = self.is_playing(); - let mut has_next_track = - matches!(track_uri, Some(ref track_uri) if &player.track.uri == track_uri); + let mut has_next_track = matches!(track_uri, Some(ref track_uri) if self.connect_state.current_track(|t| &t.uri) == track_uri); if !has_next_track { has_next_track = loop { let index = self.connect_state.next_track()?; if track_uri.is_some() - && matches!(track_uri, Some(ref track_uri) if &self.connect_state.player.track.uri != track_uri) + && matches!(track_uri, Some(ref track_uri) if self.connect_state.current_track(|t| &t.uri) != track_uri) { continue; } else { @@ -1352,7 +1338,7 @@ impl SpircTask { }; }; - self.conditional_preload_autoplay(context_uri.clone()); + self.conditional_preload_autoplay(self.connect_state.context_uri().clone()); if has_next_track { self.load_track(continue_playing, 0) @@ -1371,11 +1357,11 @@ impl SpircTask { if self.position() < 3000 { let new_track_index = self.connect_state.prev_track()?; - if new_track_index.is_none() && self.connect_state.player.options.repeating_context { + if new_track_index.is_none() && self.connect_state.repeat_context() { self.connect_state.reset_playback_context(None)? } - self.load_track(self.connect_state.player.is_playing, 0) + self.load_track(self.is_playing(), 0) } else { self.handle_seek(0); Ok(()) @@ -1395,10 +1381,8 @@ impl SpircTask { async fn handle_end_of_track(&mut self) -> Result<(), Error> { let next_track = self .connect_state - .player - .options - .repeating_track - .then(|| self.connect_state.player.track.uri.clone()); + .repeat_track() + .then(|| self.connect_state.current_track(|t| t.uri.clone())); self.handle_next(next_track)?; self.notify().await @@ -1417,18 +1401,17 @@ impl SpircTask { } fn load_track(&mut self, start_playing: bool, position_ms: u32) -> Result<(), Error> { - let track_to_load = match self.connect_state.player.track.as_mut() { - None => { - self.handle_stop(); - return Ok(()); - } - Some(track) => track, - }; + if self.connect_state.current_track(MessageField::is_none) { + self.handle_stop(); + return Ok(()); + } - let id = SpotifyId::from_uri(&track_to_load.uri)?; + 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.update_state_position(position_ms); + self.connect_state + .update_position(position_ms, self.now_ms()); if start_playing { self.play_status = SpircPlayStatus::LoadingPlay { position_ms }; } else { @@ -1442,7 +1425,7 @@ impl SpircTask { async fn notify(&mut self) -> Result<(), Error> { self.connect_state.set_status(&self.play_status); - if self.connect_state.player.is_playing { + if self.is_playing() { self.connect_state .update_position_in_relation(self.now_ms()); } @@ -1479,37 +1462,40 @@ impl SpircTask { self.connect_state.reset_context(None); let state = &mut self.connect_state; - let current_index = match state.player.track.as_ref() { - None => None, - Some(track) => { - let ctx = state.context.as_ref(); - ConnectState::find_index_in_context(ctx, |c| c.uri == track.uri).map(Some)? - } - }; - state.reset_playback_context(current_index) + if state.current_track(MessageField::is_none) { + return Ok(()); + } + + let ctx = state.context.as_ref(); + let current_index = ConnectState::find_index_in_context(ctx, |c| { + state.current_track(|t| c.uri == t.uri) + })?; + + state.reset_playback_context(Some(current_index)) } } fn handle_set_queue(&mut self, set_queue: SetQueueCommand) { self.connect_state.set_next_tracks(set_queue.next_tracks); self.connect_state.set_prev_tracks(set_queue.prev_tracks); - self.connect_state.player.queue_revision = self.connect_state.new_queue_revision(); + self.connect_state.update_queue_revision(); } fn handle_set_options(&mut self, set_options: SetOptionsCommand) -> Result<(), Error> { let state = &mut self.connect_state; - if state.player.options.repeating_context != set_options.repeating_context { + if state.repeat_context() != set_options.repeating_context { state.set_repeat_context(set_options.repeating_context); - if state.player.options.repeating_context { + if state.repeat_context() { state.set_shuffle(false); state.reset_context(None); let ctx = state.context.as_ref(); - let current_track = - ConnectState::find_index_in_context(ctx, |t| state.player.track.uri == t.uri)?; + let current_track = ConnectState::find_index_in_context(ctx, |t| { + t.uri == state.current_track(|t| t.uri.as_ref()) + })?; state.reset_playback_context(Some(current_track))?; } else { state.update_restrictions(); diff --git a/connect/src/state.rs b/connect/src/state.rs index 3a33b867a..1f1e440c9 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -90,7 +90,7 @@ pub struct ConnectState { /// index: 0 based, so the first track is index 0 /// prev_track: bottom => top, aka the last track of the list is the prev track /// next_track: top => bottom, aka the first track of the list is the next track - pub player: PlayerState, + player: PlayerState, /// we don't work directly on the lists of the player state, because /// we mostly need to push and pop at the beginning of both @@ -222,8 +222,17 @@ impl ConnectState { self.update_restrictions() } + pub fn update_position(&mut self, position_ms: u32, timestamp: i64) { + self.player.position_as_of_timestamp = position_ms.into(); + self.player.timestamp = timestamp; + } + + pub fn update_duration(&mut self, duration: u32) { + self.player.duration = duration.into() + } + // todo: is there maybe a better or more efficient way to calculate the hash? - pub fn new_queue_revision(&self) -> String { + pub fn update_queue_revision(&mut self) { let mut hasher = DefaultHasher::new(); for track in &self.next_tracks { if let Ok(bytes) = track.write_to_bytes() { @@ -231,7 +240,7 @@ impl ConnectState { } } - hasher.finish().to_string() + self.player.queue_revision = hasher.finish().to_string() } pub fn reset_playback_context(&mut self, new_index: Option) -> Result<(), Error> { @@ -294,7 +303,7 @@ impl ConnectState { } if rev_update { - self.player.queue_revision = self.new_queue_revision(); + self.update_queue_revision(); } self.update_restrictions(); } @@ -317,6 +326,48 @@ impl ConnectState { ) } + pub fn transfer(&mut self, transfer: &mut TransferState) { + self.player.is_buffering = false; + + if let Some(options) = transfer.options.take() { + self.player.options = MessageField::some(options); + } + self.player.is_paused = transfer.playback.is_paused; + self.player.is_playing = !transfer.playback.is_paused; + + if transfer.playback.playback_speed != 0. { + self.player.playback_speed = transfer.playback.playback_speed + } else { + self.player.playback_speed = 1.; + } + + self.player.play_origin = transfer.current_session.play_origin.clone(); + + if let Some(suppressions) = transfer.current_session.suppressions.as_ref() { + self.player.suppressions = MessageField::some(suppressions.clone()); + } + + if let Some(context) = transfer.current_session.context.as_ref() { + self.player.context_uri = context.uri.clone(); + self.player.context_url = context.url.clone(); + self.player.context_restrictions = context.restrictions.clone(); + } + + for (key, value) in &transfer.current_session.context.metadata { + self.player + .context_metadata + .insert(key.clone(), value.clone()); + } + + if let Some(context) = &self.context { + for (key, value) in &context.metadata { + self.player + .context_metadata + .insert(key.clone(), value.clone()); + } + } + } + pub fn setup_current_state(&mut self, transfer: TransferState) -> Result<(), Error> { let track = match self.player.track.as_ref() { None => self.try_get_current_track_from_transfer(&transfer)?, @@ -354,10 +405,10 @@ impl ConnectState { debug!( "setting up next and prev: index is at {current_index:?} while shuffle {}", - self.player.options.shuffling_context + self.shuffling_context() ); - if self.player.options.shuffling_context { + if self.shuffling_context() { self.set_current_track(current_index.unwrap_or_default())?; self.set_shuffle(true); self.shuffle()?; @@ -395,7 +446,7 @@ impl ConnectState { self.unavailable_uri.push(uri); self.fill_up_next_tracks()?; - self.player.queue_revision = self.new_queue_revision(); + self.update_queue_revision(); } Ok(()) @@ -419,6 +470,10 @@ impl ConnectState { self.player.timestamp = timestamp; } + pub fn set_origin(&mut self, origin: PlayOrigin) { + self.player.play_origin = MessageField::some(origin) + } + // todo: i would like to refrain from copying the next and prev track lists... will have to see what we can come up with /// Updates the connect state for the connect session /// diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index b9aac0b64..e3682db2a 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -44,6 +44,10 @@ impl ConnectState { .ok_or(StateError::NoContext(self.active_context)) } + pub fn context_uri(&self) -> &String { + &self.player.context_uri + } + pub fn reset_context(&mut self, new_context: Option<&str>) { self.active_context = ContextType::Default; @@ -62,10 +66,22 @@ impl ConnectState { pub fn update_context(&mut self, mut context: Context) -> Result<(), Error> { debug!("context: {}, {}", context.uri, context.url); - let page = context - .pages - .pop() - .ok_or(StateError::NoContext(ContextType::Default))?; + + self.player.context_url = format!("context://{}", context.uri); + self.player.context_uri = context.uri.clone(); + + if context.restrictions.is_some() { + self.player.context_restrictions = context.restrictions; + } + + if !context.metadata.is_empty() { + self.player.context_metadata = context.metadata; + } + + let page = match context.pages.pop() { + None => return Ok(()), + Some(page) => page, + }; let tracks = page .tracks @@ -87,17 +103,6 @@ impl ConnectState { index: ContextIndex::new(), }); - self.player.context_url = format!("context://{}", context.uri); - self.player.context_uri = context.uri; - - if context.restrictions.is_some() { - self.player.context_restrictions = context.restrictions; - } - - if !context.metadata.is_empty() { - self.player.context_metadata = context.metadata; - } - if let Some(transfer_state) = self.transfer_state.take() { self.setup_current_state(transfer_state)? } diff --git a/connect/src/state/options.rs b/connect/src/state/options.rs index 3856be31a..7a32a2531 100644 --- a/connect/src/state/options.rs +++ b/connect/src/state/options.rs @@ -72,4 +72,16 @@ impl ConnectState { 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/tracks.rs b/connect/src/state/tracks.rs index c395f7d87..fddb4736d 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -9,7 +9,7 @@ use librespot_protocol::player::ProvidedTrack; use protobuf::MessageField; use std::collections::{HashMap, VecDeque}; -impl ConnectState { +impl<'ct> ConnectState { fn new_delimiter(iteration: i64) -> ProvidedTrack { const HIDDEN: &str = "hidden"; const ITERATION: &str = "iteration"; @@ -47,6 +47,10 @@ impl ConnectState { self.player.track = MessageField::some(new_track.clone()); + if let Some(player_index) = self.player.index.as_mut() { + player_index.track = index as u32; + } + Ok(()) } @@ -56,7 +60,7 @@ impl ConnectState { /// to prev tracks and fills up the next tracks from the current context pub fn next_track(&mut self) -> Result, StateError> { // when we skip in repeat track, we don't repeat the current track anymore - if self.player.options.repeating_track { + if self.repeat_track() { self.set_repeat_track(false); } @@ -184,6 +188,17 @@ impl ConnectState { Ok(Some(&self.player.track)) } + 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.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 @@ -228,7 +243,7 @@ impl ConnectState { while self.next_tracks.len() < SPOTIFY_MAX_NEXT_TRACKS_SIZE { let ctx = self.get_current_context()?; let track = match ctx.tracks.get(new_index) { - None if self.player.options.repeating_context => { + None if self.repeat_context() => { let delimiter = Self::new_delimiter(iteration.into()); iteration += 1; new_index = 0; @@ -263,13 +278,13 @@ impl ConnectState { self.update_context_index(new_index)?; // the web-player needs a revision update, otherwise the queue isn't updated in the ui - self.player.queue_revision = self.new_queue_revision(); + self.update_queue_revision(); Ok(()) } pub fn preview_next_track(&mut self) -> Option { - let next = if self.player.options.repeating_track { + let next = if self.repeat_track() { &self.player.track.uri } else { &self.next_tracks.front()?.uri @@ -280,7 +295,7 @@ impl ConnectState { pub fn has_next_tracks(&self, min: Option) -> bool { if let Some(min) = min { - self.next_tracks.len() >= min + self.next_tracks.len() <= min } else { !self.next_tracks.is_empty() } From ace9ca920314aa8079262c3f3ab775c157be7848 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Thu, 31 Oct 2024 21:37:48 +0100 Subject: [PATCH 061/138] connect: move state only commands into own file --- connect/src/spirc.rs | 91 +++++++------------------------------ connect/src/state.rs | 14 +++--- connect/src/state/handle.rs | 56 +++++++++++++++++++++++ 3 files changed, 80 insertions(+), 81 deletions(-) create mode 100644 connect/src/state/handle.rs diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index e39a65579..115aab448 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -13,9 +13,7 @@ use crate::{ }; use futures_util::{FutureExt, Stream, StreamExt}; use librespot_core::dealer::manager::{Reply, RequestReply}; -use librespot_core::dealer::protocol::{ - PayloadValue, RequestCommand, SetOptionsCommand, SetQueueCommand, SkipTo, -}; +use librespot_core::dealer::protocol::{PayloadValue, RequestCommand, SkipTo}; use librespot_protocol::autoplay_context_request::AutoplayContextRequest; use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason, SetVolumeCommand}; use librespot_protocol::player::{Context, TransferState}; @@ -455,7 +453,6 @@ impl SpircTask { error!("error updating connect state for volume update: {why}") } - self.connect_state.update_position_in_relation(self.now_ms()); info!("delayed volume update for all devices: volume is now {}", self.connect_state.device.volume); if let Err(why) = self.connect_state.update_state(&self.session, PutStateReason::VOLUME_CHANGED).await { error!("error updating connect state for volume update: {why}") @@ -483,7 +480,7 @@ impl SpircTask { self.connect_state.update_restrictions(); self.connect_state.update_queue_revision(); - self.conditional_preload_autoplay(self.connect_state.context_uri().clone()); + self.preload_autoplay_when_required(self.connect_state.context_uri().clone()); self.notify().await } @@ -584,7 +581,7 @@ impl SpircTask { self.notify().await } SpircCommand::Shuffle(shuffle) => { - self.handle_shuffle(shuffle)?; + self.connect_state.handle_shuffle(shuffle)?; self.notify().await } SpircCommand::Repeat(repeat) => { @@ -781,6 +778,9 @@ impl SpircTask { } }; + // todo: handle received pages from transfer, important to not always shuffle the first 10 pages + // also important when the dealer is restarted, currently we shuffle again, index should be changed... + // maybe lookup current track of actual player if let Some(cluster) = response { if !cluster.transfer_data.is_empty() { if let Ok(transfer_state) = TransferState::parse_from_bytes(&cluster.transfer_data) @@ -966,7 +966,7 @@ impl SpircTask { self.notify().await.map(|_| Reply::Success)? } RequestCommand::SetShufflingContext(shuffle) => { - self.handle_shuffle(shuffle.value)?; + self.connect_state.handle_shuffle(shuffle.value)?; self.notify().await.map(|_| Reply::Success)? } RequestCommand::AddToQueue(add_to_queue) => { @@ -974,11 +974,11 @@ impl SpircTask { self.notify().await.map(|_| Reply::Success)? } RequestCommand::SetQueue(set_queue) => { - self.handle_set_queue(set_queue); + self.connect_state.handle_set_queue(set_queue); self.notify().await.map(|_| Reply::Success)? } RequestCommand::SetOptions(set_options) => { - self.handle_set_options(set_options)?; + self.connect_state.handle_set_options(set_options)?; self.notify().await.map(|_| Reply::Success)? } RequestCommand::SkipNext(skip_next) => { @@ -1043,10 +1043,7 @@ impl SpircTask { self.connect_state.setup_current_state(transfer)?; } else { debug!("trying to find initial track"); - match self - .connect_state - .try_get_current_track_from_transfer(&transfer) - { + match self.connect_state.current_track_from_transfer(&transfer) { Err(why) => warn!("{why}"), Ok(track) => { debug!("initial track found"); @@ -1298,7 +1295,7 @@ impl SpircTask { Ok(()) } - fn conditional_preload_autoplay(&mut self, uri: String) { + fn preload_autoplay_when_required(&mut self, uri: String) { let preload_autoplay = self .connect_state .has_next_tracks(Some(CONTEXT_FETCH_THRESHOLD)) @@ -1322,15 +1319,15 @@ impl SpircTask { fn handle_next(&mut self, track_uri: Option) -> Result<(), Error> { let continue_playing = self.is_playing(); - let mut has_next_track = matches!(track_uri, Some(ref track_uri) if self.connect_state.current_track(|t| &t.uri) == track_uri); + 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()?; - if track_uri.is_some() - && matches!(track_uri, Some(ref track_uri) if self.connect_state.current_track(|t| &t.uri) != track_uri) - { + 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(); @@ -1338,7 +1335,7 @@ impl SpircTask { }; }; - self.conditional_preload_autoplay(self.connect_state.context_uri().clone()); + self.preload_autoplay_when_required(self.connect_state.context_uri().clone()); if has_next_track { self.load_track(continue_playing, 0) @@ -1452,62 +1449,6 @@ impl SpircTask { } } } - - fn handle_shuffle(&mut self, shuffle: bool) -> Result<(), Error> { - self.connect_state.set_shuffle(shuffle); - - if shuffle { - self.connect_state.shuffle() - } else { - self.connect_state.reset_context(None); - - let state = &mut self.connect_state; - - if state.current_track(MessageField::is_none) { - return Ok(()); - } - - let ctx = state.context.as_ref(); - let current_index = ConnectState::find_index_in_context(ctx, |c| { - state.current_track(|t| c.uri == t.uri) - })?; - - state.reset_playback_context(Some(current_index)) - } - } - - fn handle_set_queue(&mut self, set_queue: SetQueueCommand) { - self.connect_state.set_next_tracks(set_queue.next_tracks); - self.connect_state.set_prev_tracks(set_queue.prev_tracks); - self.connect_state.update_queue_revision(); - } - - fn handle_set_options(&mut self, set_options: SetOptionsCommand) -> Result<(), Error> { - let state = &mut self.connect_state; - - if state.repeat_context() != set_options.repeating_context { - state.set_repeat_context(set_options.repeating_context); - - if state.repeat_context() { - state.set_shuffle(false); - state.reset_context(None); - - let ctx = state.context.as_ref(); - let current_track = ConnectState::find_index_in_context(ctx, |t| { - t.uri == state.current_track(|t| t.uri.as_ref()) - })?; - state.reset_playback_context(Some(current_track))?; - } else { - state.update_restrictions(); - } - } - - // doesn't need any state updates, because it should only change how the current song is played - self.connect_state - .set_repeat_track(set_options.repeating_track); - - Ok(()) - } } impl Drop for SpircTask { diff --git a/connect/src/state.rs b/connect/src/state.rs index 1f1e440c9..102deeac5 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -1,19 +1,17 @@ mod consts; pub(super) mod context; +mod handle; mod options; pub(super) mod provider; mod restrictions; mod tracks; -use std::collections::{hash_map::DefaultHasher, VecDeque}; -use std::hash::Hasher; -use std::time::{Instant, SystemTime, UNIX_EPOCH}; - use crate::spirc::SpircPlayStatus; use crate::state::consts::{METADATA_CONTEXT_URI, METADATA_IS_QUEUED}; use crate::state::context::{ContextType, StateContext}; use crate::state::provider::{IsProvider, Provider}; use librespot_core::config::DeviceType; +use librespot_core::date::Date; use librespot_core::dealer::protocol::Request; use librespot_core::spclient::SpClientResult; use librespot_core::{version, Error, Session, SpotifyId}; @@ -24,7 +22,11 @@ use librespot_protocol::player::{ ContextIndex, ContextPlayerOptions, PlayOrigin, PlayerState, ProvidedTrack, Suppressions, TransferState, }; +use log::LevelFilter; use protobuf::{EnumOrUnknown, Message, MessageField}; +use std::collections::{hash_map::DefaultHasher, VecDeque}; +use std::hash::Hasher; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use thiserror::Error; // these limitations are essential, otherwise to many tracks will overload the web-player @@ -308,7 +310,7 @@ impl ConnectState { self.update_restrictions(); } - pub fn try_get_current_track_from_transfer( + pub fn current_track_from_transfer( &self, transfer: &TransferState, ) -> Result { @@ -370,7 +372,7 @@ impl ConnectState { pub fn setup_current_state(&mut self, transfer: TransferState) -> Result<(), Error> { let track = match self.player.track.as_ref() { - None => self.try_get_current_track_from_transfer(&transfer)?, + None => self.current_track_from_transfer(&transfer)?, Some(track) => track.clone(), }; diff --git a/connect/src/state/handle.rs b/connect/src/state/handle.rs new file mode 100644 index 000000000..1fbde3a7c --- /dev/null +++ b/connect/src/state/handle.rs @@ -0,0 +1,56 @@ +use crate::state::ConnectState; +use librespot_core::dealer::protocol::{SetOptionsCommand, SetQueueCommand}; +use librespot_core::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(None); + + 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_context(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_options(&mut self, set_options: SetOptionsCommand) -> Result<(), Error> { + if self.repeat_context() != set_options.repeating_context { + self.set_repeat_context(set_options.repeating_context); + + if self.repeat_context() { + self.set_shuffle(false); + self.reset_context(None); + + let ctx = self.context.as_ref(); + let current_track = ConnectState::find_index_in_context(ctx, |t| { + t.uri == self.current_track(|t| t.uri.as_ref()) + })?; + self.reset_playback_context(Some(current_track))?; + } else { + self.update_restrictions(); + } + } + + // doesn't need any state updates, because it should only change how the current song is played + self.set_repeat_track(set_options.repeating_track); + + Ok(()) + } +} From 37d25398a07be1e5b938d7eda2ef240696554e46 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Thu, 31 Oct 2024 21:37:56 +0100 Subject: [PATCH 062/138] connect: improve logging --- connect/src/state.rs | 15 +++++++++++---- core/src/dealer/mod.rs | 2 +- core/src/dealer/protocol.rs | 5 +++++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/connect/src/state.rs b/connect/src/state.rs index 102deeac5..54968f35a 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -465,10 +465,17 @@ impl ConnectState { let diff = timestamp - self.player.timestamp; self.player.position_as_of_timestamp += diff; - debug!( - "update position to {} at {timestamp}", - self.player.position_as_of_timestamp - ); + if log::max_level() >= LevelFilter::Debug { + let pos = Duration::from_millis(self.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}"); + } + self.player.timestamp = timestamp; } diff --git a/core/src/dealer/mod.rs b/core/src/dealer/mod.rs index 578e87f3c..5657e0e7b 100644 --- a/core/src/dealer/mod.rs +++ b/core/src/dealer/mod.rs @@ -344,7 +344,7 @@ impl DealerShared { request: WebsocketRequest, send_tx: &mpsc::UnboundedSender, ) { - debug!("dealer request {}", &request.message_ident); + trace!("dealer request {}", &request.message_ident); let payload_request = match request.handle_payload() { Ok(payload) => payload, diff --git a/core/src/dealer/protocol.rs b/core/src/dealer/protocol.rs index de6c558e1..7db5bcae4 100644 --- a/core/src/dealer/protocol.rs +++ b/core/src/dealer/protocol.rs @@ -9,6 +9,7 @@ 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; @@ -121,6 +122,10 @@ impl WebsocketRequest { let payload = handle_transfer_encoding(&self.headers, payload_bytes)?; let payload = String::from_utf8(payload)?; + if log::max_level() >= LevelFilter::Trace { + trace!("{:#?}", serde_json::from_str::(&payload)); + } + serde_json::from_str(&payload) .map_err(ProtocolError::Deserialization) .map_err(Into::into) From d5e487ba49f387efdeb61f45cff71942edbe2dd6 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Thu, 31 Oct 2024 23:15:27 +0100 Subject: [PATCH 063/138] connect: handle transferred queue again --- connect/src/spirc.rs | 3 ++- connect/src/state.rs | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 115aab448..5c04fdf21 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1320,7 +1320,8 @@ impl SpircTask { 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); + 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 { diff --git a/connect/src/state.rs b/connect/src/state.rs index 54968f35a..685504c89 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -410,6 +410,16 @@ impl ConnectState { self.shuffling_context() ); + for track in &transfer.queue.tracks { + if let Ok(queued_track) = self.context_to_provided_track( + track, + self.context_uri().clone(), + 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); From 8468717187f5f7ea2038bcfbdcbdbe2ba152e0a5 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Thu, 31 Oct 2024 23:32:25 +0100 Subject: [PATCH 064/138] connect: fix all-features specific error --- connect/src/state/handle.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/connect/src/state/handle.rs b/connect/src/state/handle.rs index 1fbde3a7c..fcc3d1b15 100644 --- a/connect/src/state/handle.rs +++ b/connect/src/state/handle.rs @@ -40,7 +40,7 @@ impl ConnectState { let ctx = self.context.as_ref(); let current_track = ConnectState::find_index_in_context(ctx, |t| { - t.uri == self.current_track(|t| t.uri.as_ref()) + self.current_track(|t| &t.uri) == &t.uri })?; self.reset_playback_context(Some(current_track))?; } else { From 2b5f2a318449dc3330005c0b51d79ef0b1fae6f2 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Wed, 6 Nov 2024 13:44:05 +0100 Subject: [PATCH 065/138] connect: extract transfer handling into own file --- connect/src/state.rs | 125 +------------------------------- connect/src/state/transfer.rs | 131 ++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 124 deletions(-) create mode 100644 connect/src/state/transfer.rs diff --git a/connect/src/state.rs b/connect/src/state.rs index 685504c89..ff67f7e0a 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -5,6 +5,7 @@ mod options; pub(super) mod provider; mod restrictions; mod tracks; +mod transfer; use crate::spirc::SpircPlayStatus; use crate::state::consts::{METADATA_CONTEXT_URI, METADATA_IS_QUEUED}; @@ -310,130 +311,6 @@ impl ConnectState { self.update_restrictions(); } - 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, - transfer.current_session.context.uri.clone(), - transfer.queue.is_playing_queue.then_some(Provider::Queue), - ) - } - - pub fn transfer(&mut self, transfer: &mut TransferState) { - self.player.is_buffering = false; - - if let Some(options) = transfer.options.take() { - self.player.options = MessageField::some(options); - } - self.player.is_paused = transfer.playback.is_paused; - self.player.is_playing = !transfer.playback.is_paused; - - if transfer.playback.playback_speed != 0. { - self.player.playback_speed = transfer.playback.playback_speed - } else { - self.player.playback_speed = 1.; - } - - self.player.play_origin = transfer.current_session.play_origin.clone(); - - if let Some(suppressions) = transfer.current_session.suppressions.as_ref() { - self.player.suppressions = MessageField::some(suppressions.clone()); - } - - if let Some(context) = transfer.current_session.context.as_ref() { - self.player.context_uri = context.uri.clone(); - self.player.context_url = context.url.clone(); - self.player.context_restrictions = context.restrictions.clone(); - } - - for (key, value) in &transfer.current_session.context.metadata { - self.player - .context_metadata - .insert(key.clone(), value.clone()); - } - - if let Some(context) = &self.context { - for (key, value) in &context.metadata { - self.player - .context_metadata - .insert(key.clone(), value.clone()); - } - } - } - - pub fn setup_current_state(&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 ctx = self.get_current_context().ok(); - - let current_index = - 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.player.track = MessageField::some(track); - } - - let current_index = current_index.ok(); - if let Some(current_index) = current_index { - if let Some(index) = self.player.index.as_mut() { - index.track = current_index as u32; - } else { - self.player.index = MessageField::some(ContextIndex { - page: 0, - track: current_index as u32, - ..Default::default() - }) - } - } - - debug!( - "setting up next and prev: index is at {current_index:?} while shuffle {}", - self.shuffling_context() - ); - - for track in &transfer.queue.tracks { - if let Ok(queued_track) = self.context_to_provided_track( - track, - self.context_uri().clone(), - 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 { - // todo: it seems like, if we play a queued track and transfer we will reset that queued track... - self.reset_playback_context(current_index)?; - } - - self.update_restrictions(); - - Ok(()) - } - pub fn mark_unavailable(&mut self, id: SpotifyId) -> Result<(), Error> { let uri = id.to_uri()?; diff --git a/connect/src/state/transfer.rs b/connect/src/state/transfer.rs new file mode 100644 index 000000000..4b61c0b78 --- /dev/null +++ b/connect/src/state/transfer.rs @@ -0,0 +1,131 @@ +use crate::state::provider::Provider; +use crate::state::{ConnectState, StateError}; +use librespot_core::Error; +use librespot_protocol::player::{ContextIndex, 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, + transfer.current_session.context.uri.clone(), + transfer.queue.is_playing_queue.then_some(Provider::Queue), + ) + } + + pub fn transfer(&mut self, transfer: &mut TransferState) { + self.player.is_buffering = false; + + if let Some(options) = transfer.options.take() { + self.player.options = MessageField::some(options); + } + self.player.is_paused = transfer.playback.is_paused; + self.player.is_playing = !transfer.playback.is_paused; + + if transfer.playback.playback_speed != 0. { + self.player.playback_speed = transfer.playback.playback_speed + } else { + self.player.playback_speed = 1.; + } + + self.player.play_origin = transfer.current_session.play_origin.clone(); + + if let Some(suppressions) = transfer.current_session.suppressions.as_ref() { + self.player.suppressions = MessageField::some(suppressions.clone()); + } + + if let Some(context) = transfer.current_session.context.as_ref() { + self.player.context_uri = context.uri.clone(); + self.player.context_url = context.url.clone(); + self.player.context_restrictions = context.restrictions.clone(); + } + + for (key, value) in &transfer.current_session.context.metadata { + self.player + .context_metadata + .insert(key.clone(), value.clone()); + } + + if let Some(context) = &self.context { + for (key, value) in &context.metadata { + self.player + .context_metadata + .insert(key.clone(), value.clone()); + } + } + } + + pub fn setup_current_state(&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 ctx = self.get_current_context().ok(); + + let current_index = + 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.player.track = MessageField::some(track); + } + + let current_index = current_index.ok(); + if let Some(current_index) = current_index { + if let Some(index) = self.player.index.as_mut() { + index.track = current_index as u32; + } else { + self.player.index = MessageField::some(ContextIndex { + page: 0, + track: current_index as u32, + ..Default::default() + }) + } + } + + debug!( + "setting up next and prev: index is at {current_index:?} while shuffle {}", + self.shuffling_context() + ); + + for track in &transfer.queue.tracks { + if let Ok(queued_track) = self.context_to_provided_track( + track, + self.context_uri().clone(), + 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 { + // todo: it seems like, if we play a queued track and transfer we will reset that queued track... + self.reset_playback_context(current_index)?; + } + + self.update_restrictions(); + + Ok(()) + } +} From 168743df32c4709e36edee1fd06689aba4b01baf Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Wed, 6 Nov 2024 13:47:54 +0100 Subject: [PATCH 066/138] connect: remove old context model --- connect/src/context.rs | 96 ------------------------------------------ connect/src/lib.rs | 1 - 2 files changed, 97 deletions(-) delete mode 100644 connect/src/context.rs diff --git a/connect/src/context.rs b/connect/src/context.rs deleted file mode 100644 index aac0eae4a..000000000 --- a/connect/src/context.rs +++ /dev/null @@ -1,96 +0,0 @@ -// TODO : move to metadata - -use crate::core::deserialize_with::{bool_from_string, vec_json_proto}; - -use librespot_protocol::player::{ContextPage, ContextTrack, ProvidedTrack}; -use serde::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 = "vec_json_proto")] - 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 = "vec_json_proto")] - 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, -} - -impl From for ContextPage { - fn from(value: PageContext) -> Self { - Self { - next_page_url: value.next_page_url, - tracks: value - .tracks - .into_iter() - .map(|track| ContextTrack { - uri: track.uri, - metadata: track.metadata, - ..Default::default() - }) - .collect(), - loading: false, - ..Default::default() - } - } -} diff --git a/connect/src/lib.rs b/connect/src/lib.rs index 4a80f37bd..6253b8eef 100644 --- a/connect/src/lib.rs +++ b/connect/src/lib.rs @@ -5,6 +5,5 @@ use librespot_core as core; use librespot_playback as playback; use librespot_protocol as protocol; -pub mod context; pub mod spirc; pub mod state; From 229aeb2c172d230584065a4128c178c8007de077 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Wed, 6 Nov 2024 14:36:24 +0100 Subject: [PATCH 067/138] connect: handle more transfer cases correctly --- connect/src/spirc.rs | 17 ++++++++++++++--- connect/src/state.rs | 11 +++++------ connect/src/state/context.rs | 4 ---- connect/src/state/tracks.rs | 6 +++--- connect/src/state/transfer.rs | 25 +++++++++++++++++++------ 5 files changed, 41 insertions(+), 22 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 5c04fdf21..95094d68f 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -106,6 +106,9 @@ struct SpircTask { shutdown: bool, session: Session, resolve_context: Vec, + // is set when we receive a transfer state and are loading the context asynchronously + pub transfer_state: Option, + update_volume: bool, spirc_id: usize, @@ -294,6 +297,7 @@ impl Spirc { session, resolve_context: Vec::new(), + transfer_state: None, update_volume: false, spirc_id, @@ -476,6 +480,11 @@ impl SpircTask { self.resolve_context(resolve.uri, resolve.autoplay).await?; } + if let Some(transfer_state) = self.transfer_state.take() { + self.connect_state + .setup_state_from_transfer(transfer_state)? + } + self.connect_state.fill_up_next_tracks()?; self.connect_state.update_restrictions(); self.connect_state.update_queue_revision(); @@ -1025,7 +1034,7 @@ impl SpircTask { let state = &mut self.connect_state; state.set_active(true); - state.transfer(&mut transfer); + state.handle_initial_transfer(&mut transfer); // update position if the track continued playing let position = if transfer.playback.is_paused { @@ -1040,7 +1049,7 @@ impl SpircTask { let is_playing = !transfer.playback.is_paused; if self.connect_state.context.is_some() { - self.connect_state.setup_current_state(transfer)?; + self.connect_state.setup_state_from_transfer(transfer)?; } else { debug!("trying to find initial track"); match self.connect_state.current_track_from_transfer(&transfer) { @@ -1061,7 +1070,7 @@ impl SpircTask { }) } - self.connect_state.transfer_state = Some(transfer); + self.transfer_state = Some(transfer); } self.load_track(is_playing, position.try_into()?) @@ -1157,6 +1166,8 @@ impl SpircTask { self.connect_state.set_current_track(index)?; self.connect_state.shuffle()?; } else { + // set manually, so that we overwrite a possible current queue track + self.connect_state.set_current_track(index)?; self.connect_state.reset_playback_context(Some(index))?; } diff --git a/connect/src/state.rs b/connect/src/state.rs index ff67f7e0a..1d5ba4280 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -21,7 +21,6 @@ use librespot_protocol::connect::{ }; use librespot_protocol::player::{ ContextIndex, ContextPlayerOptions, PlayOrigin, PlayerState, ProvidedTrack, Suppressions, - TransferState, }; use log::LevelFilter; use protobuf::{EnumOrUnknown, Message, MessageField}; @@ -109,9 +108,6 @@ pub struct ConnectState { /// a context to keep track of the autoplay context pub autoplay_context: Option, - // is set when we receive a transfer state and are loading the context asynchronously - pub transfer_state: Option, - pub last_command: Option, } @@ -326,11 +322,11 @@ impl ConnectState { if self.player.track.uri != uri { while let Some(pos) = self.next_tracks.iter().position(|t| t.uri == uri) { - _ = self.next_tracks.remove(pos); + let _ = self.next_tracks.remove(pos); } while let Some(pos) = self.prev_tracks.iter().position(|t| t.uri == uri) { - _ = self.prev_tracks.remove(pos); + let _ = self.prev_tracks.remove(pos); } self.unavailable_uri.push(uri); @@ -376,6 +372,9 @@ impl ConnectState { /// Prepares a [PutStateRequest] from the current connect state pub async fn update_state(&self, session: &Session, reason: PutStateReason) -> SpClientResult { if matches!(reason, PutStateReason::BECAME_INACTIVE) { + // todo: when another device takes over, and we currently play a queued item, + // for some reason the other client thinks we still have the currently playing track + // in our queue, figure out why it behave like it does return session.spclient().put_connect_state_inactive(false).await; } diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index e3682db2a..be8c912ed 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -103,10 +103,6 @@ impl ConnectState { index: ContextIndex::new(), }); - if let Some(transfer_state) = self.transfer_state.take() { - self.setup_current_state(transfer_state)? - } - Ok(()) } diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index fddb4736d..7683ddf18 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -71,7 +71,7 @@ impl<'ct> ConnectState { if old_track.is_context() || old_track.is_autoplay() { // add old current track to prev tracks, while preserving a length of 10 if self.prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { - _ = self.prev_tracks.pop_front(); + let _ = self.prev_tracks.pop_front(); } self.prev_tracks.push_back(old_track); } @@ -80,7 +80,7 @@ impl<'ct> ConnectState { let new_track = match self.next_tracks.pop_front() { Some(next) if next.uid.starts_with(IDENTIFIER_DELIMITER) => { if self.prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { - _ = self.prev_tracks.pop_front(); + let _ = self.prev_tracks.pop_front(); } self.prev_tracks.push_back(next); self.next_tracks.pop_front() @@ -152,7 +152,7 @@ impl<'ct> ConnectState { .pop_back() .expect("item that was prechecked"); if self.next_tracks.len() >= SPOTIFY_MAX_NEXT_TRACKS_SIZE { - _ = self.next_tracks.pop_back(); + let _ = self.next_tracks.pop_back(); } self.next_tracks.push_front(delimiter) } diff --git a/connect/src/state/transfer.rs b/connect/src/state/transfer.rs index 4b61c0b78..416a4e28f 100644 --- a/connect/src/state/transfer.rs +++ b/connect/src/state/transfer.rs @@ -1,4 +1,4 @@ -use crate::state::provider::Provider; +use crate::state::provider::{IsProvider, Provider}; use crate::state::{ConnectState, StateError}; use librespot_core::Error; use librespot_protocol::player::{ContextIndex, ProvidedTrack, TransferState}; @@ -23,7 +23,7 @@ impl ConnectState { ) } - pub fn transfer(&mut self, transfer: &mut TransferState) { + pub fn handle_initial_transfer(&mut self, transfer: &mut TransferState) { self.player.is_buffering = false; if let Some(options) = transfer.options.take() { @@ -63,9 +63,12 @@ impl ConnectState { .insert(key.clone(), value.clone()); } } + + self.prev_tracks.clear(); + self.clear_next_tracks(false); } - pub fn setup_current_state(&mut self, transfer: TransferState) -> Result<(), Error> { + pub fn setup_state_from_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(), @@ -73,8 +76,12 @@ impl ConnectState { let ctx = self.get_current_context().ok(); - let current_index = - Self::find_index_in_context(ctx, |c| c.uri == track.uri || c.uid == track.uid); + 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", @@ -105,7 +112,13 @@ impl ConnectState { self.shuffling_context() ); - for track in &transfer.queue.tracks { + 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, self.context_uri().clone(), From 7d51ed98a8a6704a2205665fe3794b702dd1aec8 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Wed, 6 Nov 2024 20:03:10 +0100 Subject: [PATCH 068/138] connect: do auth_token pre-acquiring earlier --- connect/src/spirc.rs | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 95094d68f..ab7bcdcdd 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -269,7 +269,9 @@ impl Spirc { // Connect *after* all message listeners are registered session.connect(credentials, true).await?; - session.dealer().start().await?; + + // 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(); @@ -362,6 +364,11 @@ impl Spirc { impl SpircTask { async fn run(mut self) { + 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(); @@ -763,18 +770,6 @@ impl SpircTask { trace!("Received connection ID update: {:?}", connection_id); self.session.set_connection_id(&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}"); - } - } - let response = match self .connect_state .update_state(&self.session, PutStateReason::NEW_DEVICE) From 8a21a4444b84d6dde2a77d78633441ad8ce1b382 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Wed, 6 Nov 2024 23:25:09 +0100 Subject: [PATCH 069/138] connect: handle play with skip_to by uid --- connect/src/spirc.rs | 46 ++++++++++++++++++------------------ connect/src/state/context.rs | 44 +++++++++++++++++++++++----------- 2 files changed, 53 insertions(+), 37 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index ab7bcdcdd..d65c78cda 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -617,7 +617,7 @@ impl SpircTask { self.notify().await } SpircCommand::Load(command) => { - self.handle_load(command).await?; + self.handle_load(command, None).await?; self.notify().await } _ => Ok(()), @@ -939,22 +939,19 @@ impl SpircTask { .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, - playing_track: play.options.skip_to.into(), - shuffle, - repeat, - repeat_track, - }) + self.handle_load( + SpircLoadCommand { + context_uri: play.context.uri.clone(), + start_playing: true, + playing_track: play.options.skip_to.into(), + shuffle, + repeat, + repeat_track, + }, + Some(play.context), + ) .await?; - self.connect_state.update_context(Context { - uri: play.context.uri, - url: play.context.url, - ..Default::default() - })?; - self.connect_state.set_origin(play.play_origin); self.notify().await.map(|_| Reply::Success)? @@ -1122,25 +1119,28 @@ impl SpircTask { ); } - async fn handle_load(&mut self, cmd: SpircLoadCommand) -> Result<(), Error> { + async fn handle_load( + &mut self, + cmd: SpircLoadCommand, + context: Option, + ) -> Result<(), Error> { self.connect_state.reset_context(Some(&cmd.context_uri)); if !self.connect_state.active { self.handle_activate(); } - if self.connect_state.context_uri() == &cmd.context_uri - && self.connect_state.context.is_some() - { - debug!( - "context <{}> didn't change, no resolving required", - self.connect_state.context_uri() - ) + let current_context_uri = self.connect_state.context_uri(); + if current_context_uri == &cmd.context_uri && self.connect_state.context.is_some() { + debug!("context <{current_context_uri}> didn't change, no resolving required",) } else { debug!("resolving context for load command"); self.resolve_context(cmd.context_uri.clone(), false).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); let index = match cmd.playing_track { diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index be8c912ed..56eb2c49f 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -71,11 +71,12 @@ impl ConnectState { self.player.context_uri = context.uri.clone(); if context.restrictions.is_some() { + self.player.restrictions = context.restrictions.clone(); self.player.context_restrictions = context.restrictions; } - if !context.metadata.is_empty() { - self.player.context_metadata = context.metadata; + for (key, value) in context.metadata { + self.player.context_metadata.insert(key, value); } let page = match context.pages.pop() { @@ -112,6 +113,7 @@ impl ConnectState { context.uri, context.pages.len() ); + let page = context .pages .pop() @@ -152,6 +154,29 @@ impl ConnectState { Ok(()) } + pub fn merge_context(&mut self, context: Option) -> Option<()> { + let mut context = context?; + if context.uri != self.player.context_uri { + return None; + } + + let mutable_context = self.context.as_mut()?; + let page = context.pages.pop()?; + for track in page.tracks { + if track.uid.is_empty() || track.uri.is_empty() { + continue; + } + + if let Ok(position) = + Self::find_index_in_context(Some(mutable_context), |t| t.uri == track.uri) + { + mutable_context.tracks.get_mut(position)?.uid = track.uid + } + } + + Some(()) + } + pub(super) fn update_context_index(&mut self, new_index: usize) -> Result<(), StateError> { let context = match self.active_context { ContextType::Default => self.context.as_mut(), @@ -188,22 +213,13 @@ impl ConnectState { metadata.insert(METADATA_CONTEXT_URI.to_string(), context_uri.to_string()); metadata.insert(METADATA_ENTITY_URI.to_string(), context_uri.to_string()); - if !ctx_track.metadata.is_empty() { - for (k, v) in &ctx_track.metadata { - metadata.insert(k.to_string(), v.to_string()); - } + for (k, v) in &ctx_track.metadata { + metadata.insert(k.to_string(), v.to_string()); } - let uid = if !ctx_track.uid.is_empty() { - ctx_track.uid.clone() - } else { - // todo: this will never work, it is sadly not as simple :/ - String::from_utf8(id.to_raw().to_vec()).unwrap_or_else(|_| String::new()) - }; - Ok(ProvidedTrack { uri: id.to_uri()?.replace("unknown", "track"), - uid, + uid: ctx_track.uid.clone(), metadata, provider: provider.to_string(), ..Default::default() From 78494c5a467a167ab583e46913f564b6d48af0bd Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Wed, 6 Nov 2024 23:26:52 +0100 Subject: [PATCH 070/138] connect: simplified cluster update log --- connect/src/spirc.rs | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index d65c78cda..f2b2d8435 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -866,21 +866,9 @@ impl SpircTask { let reason = cluster_update.update_reason.enum_value().ok(); let device_ids = cluster_update.devices_that_changed.join(", "); - let devices = cluster_update.cluster.device.len(); - - let prev_tracks = cluster_update.cluster.player_state.prev_tracks.len(); - let next_tracks = cluster_update.cluster.player_state.next_tracks.len(); - - info!("cluster update! {reason:?} for {device_ids} from {devices} has {prev_tracks:?} previous tracks and {next_tracks} next tracks"); + debug!("cluster update: {reason:?} from {device_ids}"); if let Some(cluster) = cluster_update.cluster.take() { - // // we could transfer the player state here, which would result in less work that we - // // need to do, but it's probably better to handle it ourselves, otherwise we will - // // only notice problems after the tracks from the transferred player state have ended - // if let Some(player_state) = cluster.player_state.take() { - // state.player = player_state; - // } - let became_inactive = self.connect_state.active && cluster.active_device_id != self.session.device_id(); if became_inactive { @@ -1138,7 +1126,7 @@ impl SpircTask { self.resolve_context(cmd.context_uri.clone(), false).await?; } - // for play commands with skip by uid, the context of the command contains + // 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); From 2f10b4e6984f657e37903bc2036434e3fc317040 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 10 Nov 2024 12:46:32 +0100 Subject: [PATCH 071/138] core/connect: add remaining set value commands --- connect/src/spirc.rs | 14 ++++++++- connect/src/state/handle.rs | 49 +++++++++++++++++------------ core/src/dealer/protocol/request.rs | 8 +++-- 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index f2b2d8435..7fcab0307 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -958,6 +958,16 @@ impl SpircTask { self.connect_state.handle_shuffle(shuffle.value)?; self.notify().await.map(|_| Reply::Success)? } + RequestCommand::SetRepeatingContext(repeat_context) => { + self.connect_state + .handle_set_repeat(Some(repeat_context.value), None)?; + self.notify().await.map(|_| Reply::Success)? + } + RequestCommand::SetRepeatingTrack(repeat_track) => { + self.connect_state + .handle_set_repeat(None, Some(repeat_track.value))?; + self.notify().await.map(|_| Reply::Success)? + } RequestCommand::AddToQueue(add_to_queue) => { self.connect_state.add_to_queue(add_to_queue.track, true); self.notify().await.map(|_| Reply::Success)? @@ -967,7 +977,9 @@ impl SpircTask { self.notify().await.map(|_| Reply::Success)? } RequestCommand::SetOptions(set_options) => { - self.connect_state.handle_set_options(set_options)?; + let context = Some(set_options.repeating_context); + let track = Some(set_options.repeating_track); + self.connect_state.handle_set_repeat(context, track)?; self.notify().await.map(|_| Reply::Success)? } RequestCommand::SkipNext(skip_next) => { diff --git a/connect/src/state/handle.rs b/connect/src/state/handle.rs index fcc3d1b15..e37d6043f 100644 --- a/connect/src/state/handle.rs +++ b/connect/src/state/handle.rs @@ -1,5 +1,5 @@ use crate::state::ConnectState; -use librespot_core::dealer::protocol::{SetOptionsCommand, SetQueueCommand}; +use librespot_core::dealer::protocol::SetQueueCommand; use librespot_core::Error; use protobuf::MessageField; @@ -30,27 +30,36 @@ impl ConnectState { self.update_queue_revision(); } - pub fn handle_set_options(&mut self, set_options: SetOptionsCommand) -> Result<(), Error> { - if self.repeat_context() != set_options.repeating_context { - self.set_repeat_context(set_options.repeating_context); - - if self.repeat_context() { - self.set_shuffle(false); - self.reset_context(None); - - 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_context(Some(current_track))?; - } else { - self.update_restrictions(); - } + 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); } - // doesn't need any state updates, because it should only change how the current song is played - self.set_repeat_track(set_options.repeating_track); + if matches!(context, Some(context) if self.repeat_context() == context) { + return Ok(()); + } - Ok(()) + if let Some(context) = context { + self.set_repeat_context(context); + } + + if self.repeat_context() { + self.set_shuffle(false); + self.reset_context(None); + + 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_context(Some(current_track)) + } else { + self.update_restrictions(); + Ok(()) + } } } diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index 7359f6355..3ae27e403 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -24,7 +24,9 @@ pub enum RequestCommand { Pause(PauseCommand), SeekTo(SeekToCommand), SkipNext(SkipNextCommand), - SetShufflingContext(SetShufflingCommand), + SetShufflingContext(SetValueCommand), + SetRepeatingTrack(SetValueCommand), + SetRepeatingContext(SetValueCommand), AddToQueue(AddToQueueCommand), SetQueue(SetQueueCommand), SetOptions(SetOptionsCommand), @@ -47,6 +49,8 @@ impl Display for RequestCommand { RequestCommand::Pause(_) => "pause", RequestCommand::SeekTo(_) => "seek_to", RequestCommand::SetShufflingContext(_) => "set_shuffling_context", + RequestCommand::SetRepeatingContext(_) => "set_repeating_context", + RequestCommand::SetRepeatingTrack(_) => "set_repeating_track", RequestCommand::AddToQueue(_) => "add_to_queue", RequestCommand::SetQueue(_) => "set_queue", RequestCommand::SetOptions(_) => "set_options", @@ -104,7 +108,7 @@ pub struct SkipNextCommand { } #[derive(Clone, Debug, Deserialize)] -pub struct SetShufflingCommand { +pub struct SetValueCommand { pub value: bool, pub logging_params: LoggingParams, } From adfa444e8911bbec8162a50b6e9e6268bcd66257 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 10 Nov 2024 15:02:47 +0100 Subject: [PATCH 072/138] connect: position update workaround/fix --- connect/src/spirc.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 7fcab0307..9fe4ec1b1 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -879,6 +879,11 @@ impl SpircTask { .connect_state .update_state(&self.session, PutStateReason::BECAME_INACTIVE) .await?; + } else if self.connect_state.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?; } } From cbbe72ea04dfc51d2c8844c71a8cdcdf7f0db699 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 10 Nov 2024 15:37:23 +0100 Subject: [PATCH 073/138] connect: some queue cleanups --- connect/src/state.rs | 4 ++-- connect/src/state/context.rs | 12 +++++++----- connect/src/state/provider.rs | 9 ++++----- connect/src/state/tracks.rs | 4 ++-- connect/src/state/transfer.rs | 7 +++---- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/connect/src/state.rs b/connect/src/state.rs index 1d5ba4280..ce0301da9 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -252,7 +252,7 @@ impl ConnectState { debug!("reset playback state to {new_index}"); - if !self.player.track.is_queue() { + if !self.player.track.is_queued() { self.set_current_track(new_index)?; } @@ -290,7 +290,7 @@ impl ConnectState { } if let Some(next_not_queued_track) = - self.next_tracks.iter().position(|track| !track.is_queue()) + self.next_tracks.iter().position(|track| !track.is_queued()) { self.next_tracks.insert(next_not_queued_track, track); } else { diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 56eb2c49f..1119a507b 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -88,7 +88,7 @@ impl ConnectState { .tracks .iter() .flat_map(|track| { - match self.context_to_provided_track(track, context.uri.clone(), None) { + match self.context_to_provided_track(track, Some(&context.uri), None) { Ok(t) => Some(t), Err(_) => { error!("couldn't convert {track:#?} into ProvidedTrack"); @@ -126,7 +126,7 @@ impl ConnectState { .flat_map(|track| { match self.context_to_provided_track( track, - context.uri.clone(), + Some(&context.uri), Some(Provider::Autoplay), ) { Ok(t) => Some(t), @@ -192,7 +192,7 @@ impl ConnectState { pub fn context_to_provided_track( &self, ctx_track: &ContextTrack, - context_uri: String, + context_uri: Option<&str>, provider: Option, ) -> Result { let provider = if self.unavailable_uri.contains(&ctx_track.uri) { @@ -210,8 +210,10 @@ impl ConnectState { }?; let mut metadata = HashMap::new(); - metadata.insert(METADATA_CONTEXT_URI.to_string(), context_uri.to_string()); - metadata.insert(METADATA_ENTITY_URI.to_string(), context_uri.to_string()); + if let Some(context_uri) = context_uri { + metadata.insert(METADATA_CONTEXT_URI.to_string(), context_uri.to_string()); + metadata.insert(METADATA_ENTITY_URI.to_string(), context_uri.to_string()); + } for (k, v) in &ctx_track.metadata { metadata.insert(k.to_string(), v.to_string()); diff --git a/connect/src/state/provider.rs b/connect/src/state/provider.rs index 8869e15d9..172efd7e4 100644 --- a/connect/src/state/provider.rs +++ b/connect/src/state/provider.rs @@ -7,9 +7,8 @@ const PROVIDER_QUEUE: &str = "queue"; const PROVIDER_AUTOPLAY: &str = "autoplay"; // custom providers, used to identify certain states that we can't handle preemptively, yet -// todo: we might just need to remove tracks that are unavailable to play, will have to see how the official clients handle this provider -// it seems like spotify just knows that the track isn't available, currently i didn't found -// a solution to do the same, so we stay with the old solution for now +/// 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"; pub enum Provider { @@ -37,7 +36,7 @@ impl Display for Provider { pub trait IsProvider { fn is_autoplay(&self) -> bool; fn is_context(&self) -> bool; - fn is_queue(&self) -> bool; + fn is_queued(&self) -> bool; fn is_unavailable(&self) -> bool; fn set_provider(&mut self, provider: Provider); @@ -52,7 +51,7 @@ impl IsProvider for ProvidedTrack { self.provider == PROVIDER_CONTEXT } - fn is_queue(&self) -> bool { + fn is_queued(&self) -> bool { self.provider == PROVIDER_QUEUE } diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index 7683ddf18..ab6ece6ce 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -96,7 +96,7 @@ impl<'ct> ConnectState { self.fill_up_next_tracks()?; - let is_queue_or_autoplay = new_track.is_queue() || new_track.is_autoplay(); + let is_queue_or_autoplay = new_track.is_queued() || new_track.is_autoplay(); let update_index = if is_queue_or_autoplay && self.player.index.is_some() { // the index isn't send when we are a queued track, but we have to preserve it for later self.player_index = self.player.index.take(); @@ -226,7 +226,7 @@ impl<'ct> ConnectState { .next_tracks .iter() .enumerate() - .find(|(_, track)| !track.is_queue()); + .find(|(_, track)| !track.is_queued()); if let Some((non_queued_track, _)) = first_non_queued_track { while self.next_tracks.len() > non_queued_track && self.next_tracks.pop_back().is_some() diff --git a/connect/src/state/transfer.rs b/connect/src/state/transfer.rs index 416a4e28f..96faa4ab0 100644 --- a/connect/src/state/transfer.rs +++ b/connect/src/state/transfer.rs @@ -18,7 +18,7 @@ impl ConnectState { self.context_to_provided_track( track, - transfer.current_session.context.uri.clone(), + Some(&transfer.current_session.context.uri), transfer.queue.is_playing_queue.then_some(Provider::Queue), ) } @@ -76,7 +76,7 @@ impl ConnectState { let ctx = self.get_current_context().ok(); - let current_index = if track.is_queue() { + let current_index = if track.is_queued() { Self::find_index_in_context(ctx, |c| c.uid == transfer.current_session.current_uid) .map(|i| if i > 0 { i - 1 } else { i }) } else { @@ -121,7 +121,7 @@ impl ConnectState { if let Ok(queued_track) = self.context_to_provided_track( track, - self.context_uri().clone(), + Some(self.context_uri()), Some(Provider::Queue), ) { self.add_to_queue(queued_track, false); @@ -133,7 +133,6 @@ impl ConnectState { self.set_shuffle(true); self.shuffle()?; } else { - // todo: it seems like, if we play a queued track and transfer we will reset that queued track... self.reset_playback_context(current_index)?; } From f2406d2fcf351892850d350182d0778563d9bcfa Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 10 Nov 2024 15:58:20 +0100 Subject: [PATCH 074/138] connect: add uid to queue --- connect/src/state.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/connect/src/state.rs b/connect/src/state.rs index ce0301da9..087f21191 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -107,6 +107,8 @@ pub struct ConnectState { pub shuffle_context: Option, /// a context to keep track of the autoplay context pub autoplay_context: Option, + + pub queue_count: u64, pub last_command: Option, } @@ -168,6 +170,8 @@ impl ConnectState { pub fn reset(&mut self) { self.set_active(false); + self.queue_count = 0; + self.player = PlayerState { is_system_initiated: true, playback_speed: 1., @@ -282,6 +286,9 @@ impl ConnectState { } 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.metadata.contains_key(METADATA_IS_QUEUED) { track @@ -372,9 +379,6 @@ impl ConnectState { /// Prepares a [PutStateRequest] from the current connect state pub async fn update_state(&self, session: &Session, reason: PutStateReason) -> SpClientResult { if matches!(reason, PutStateReason::BECAME_INACTIVE) { - // todo: when another device takes over, and we currently play a queued item, - // for some reason the other client thinks we still have the currently playing track - // in our queue, figure out why it behave like it does return session.spclient().put_connect_state_inactive(false).await; } From 6b397d8396a7cf1133df05f19f8763bbfc2ef3a0 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 10 Nov 2024 16:03:50 +0100 Subject: [PATCH 075/138] connect: duration as volume delay const --- connect/src/spirc.rs | 4 ++-- connect/src/state.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 9fe4ec1b1..f356166ec 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -174,7 +174,7 @@ impl From for PlayingTrack { const CONTEXT_FETCH_THRESHOLD: usize = 2; const VOLUME_STEP_SIZE: u16 = 1024; // (u16::MAX + 1) / VOLUME_STEPS -const VOLUME_UPDATE_DELAY_MS: u64 = 2000; +const VOLUME_UPDATE_DELAY: Duration = Duration::from_secs(2); pub struct Spirc { commands: mpsc::UnboundedSender, @@ -454,7 +454,7 @@ impl SpircTask { error!("ContextError: {why}") } }, - _ = async { sleep(Duration::from_millis(VOLUME_UPDATE_DELAY_MS)).await }, if self.update_volume => { + _ = async { sleep(VOLUME_UPDATE_DELAY).await }, if self.update_volume => { self.update_volume = false; // for some reason the web-player does need two separate updates, so that the diff --git a/connect/src/state.rs b/connect/src/state.rs index 087f21191..c05e48fbf 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -107,7 +107,7 @@ pub struct ConnectState { pub shuffle_context: Option, /// a context to keep track of the autoplay context pub autoplay_context: Option, - + pub queue_count: u64, pub last_command: Option, From a83329cf83b4952a7e9d8d418e1109e97d908db1 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 16 Nov 2024 13:22:01 +0100 Subject: [PATCH 076/138] 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/src/spirc.rs | 10 +++++----- connect/src/state.rs | 19 +++++++------------ 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index f356166ec..596a9b99d 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -457,17 +457,17 @@ impl SpircTask { _ = 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.volume); + if let Err(why) = self.connect_state.update_state(&self.session, PutStateReason::VOLUME_CHANGED).await { + error!("error updating connect state for volume update: {why}") + } + // 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}") } - - info!("delayed volume update for all devices: volume is now {}", self.connect_state.device.volume); - if let Err(why) = self.connect_state.update_state(&self.session, PutStateReason::VOLUME_CHANGED).await { - error!("error updating connect state for volume update: {why}") - } }, else => break } diff --git a/connect/src/state.rs b/connect/src/state.rs index c05e48fbf..af692e2eb 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -23,9 +23,9 @@ use librespot_protocol::player::{ ContextIndex, ContextPlayerOptions, PlayOrigin, PlayerState, ProvidedTrack, Suppressions, }; use log::LevelFilter; -use protobuf::{EnumOrUnknown, Message, MessageField}; +use protobuf::{EnumOrUnknown, MessageField}; use std::collections::{hash_map::DefaultHasher, VecDeque}; -use std::hash::Hasher; +use std::hash::{Hash, Hasher}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use thiserror::Error; @@ -234,16 +234,10 @@ impl ConnectState { self.player.duration = duration.into() } - // todo: is there maybe a better or more efficient way to calculate the hash? pub fn update_queue_revision(&mut self) { - let mut hasher = DefaultHasher::new(); - for track in &self.next_tracks { - if let Ok(bytes) = track.write_to_bytes() { - hasher.write(&bytes) - } - } - - self.player.queue_revision = hasher.finish().to_string() + let mut state = DefaultHasher::new(); + self.next_tracks.iter().for_each(|t| t.uri.hash(&mut state)); + self.player.queue_revision = state.finish().to_string() } pub fn reset_playback_context(&mut self, new_index: Option) -> Result<(), Error> { @@ -373,7 +367,6 @@ impl ConnectState { self.player.play_origin = MessageField::some(origin) } - // todo: i would like to refrain from copying the next and prev track lists... will have to see what we can come up with /// Updates the connect state for the connect session /// /// Prepares a [PutStateRequest] from the current connect state @@ -389,7 +382,9 @@ impl ConnectState { let member_type = EnumOrUnknown::new(MemberType::CONNECT_STATE); let put_state_reason = EnumOrUnknown::new(reason); + // we copy the player state, which only contains the infos, not the next and prev tracks let mut player_state = self.player.clone(); + // cloning seems to be fine, because the cloned lists get dropped after the method call player_state.next_tracks = self.next_tracks.clone().into(); player_state.prev_tracks = self.prev_tracks.clone().into(); From bf43f2d6a093fae528fa370016860867884e4fe5 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 16 Nov 2024 17:03:58 +0100 Subject: [PATCH 077/138] connect: handle shuffle from set_options --- connect/src/spirc.rs | 16 +++++++++++----- connect/src/state/handle.rs | 3 ++- core/src/dealer/protocol/request.rs | 7 ++++--- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 596a9b99d..176d8596f 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -915,19 +915,19 @@ impl SpircTask { RequestCommand::Play(play) => { let shuffle = play .options - .player_option_overrides + .player_options_overrides .as_ref() .map(|o| o.shuffling_context) .unwrap_or_else(|| self.connect_state.shuffling_context()); let repeat = play .options - .player_option_overrides + .player_options_overrides .as_ref() .map(|o| o.repeating_context) .unwrap_or_else(|| self.connect_state.repeat_context()); let repeat_track = play .options - .player_option_overrides + .player_options_overrides .as_ref() .map(|o| o.repeating_track) .unwrap_or_else(|| self.connect_state.repeat_track()); @@ -982,9 +982,15 @@ impl SpircTask { self.notify().await.map(|_| Reply::Success)? } RequestCommand::SetOptions(set_options) => { - let context = Some(set_options.repeating_context); - let track = Some(set_options.repeating_track); + 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)?; + } + self.notify().await.map(|_| Reply::Success)? } RequestCommand::SkipNext(skip_next) => { diff --git a/connect/src/state/handle.rs b/connect/src/state/handle.rs index e37d6043f..4f84bb6c3 100644 --- a/connect/src/state/handle.rs +++ b/connect/src/state/handle.rs @@ -40,7 +40,8 @@ impl ConnectState { self.set_repeat_track(track); } - if matches!(context, Some(context) if self.repeat_context() == context) { + if matches!(context, Some(context) if self.repeat_context() == context) || context.is_none() + { return Ok(()); } diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index 3ae27e403..e9575f717 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -134,8 +134,9 @@ pub struct SetQueueCommand { #[derive(Clone, Debug, Deserialize)] pub struct SetOptionsCommand { - pub repeating_context: bool, - pub repeating_track: bool, + pub shuffling_context: Option, + pub repeating_context: Option, + pub repeating_track: Option, pub options: Option, pub logging_params: LoggingParams, } @@ -157,7 +158,7 @@ pub struct TransferOptions { pub struct PlayOptions { pub skip_to: SkipTo, #[serde(default, deserialize_with = "option_json_proto")] - pub player_option_overrides: Option, + pub player_options_overrides: Option, pub license: String, // mobile pub always_play_something: Option, From 1e7624fa273c1f56acd07febf2781eb95a955976 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 16 Nov 2024 17:07:03 +0100 Subject: [PATCH 078/138] connect: handle context update --- connect/src/lib.rs | 1 + connect/src/model.rs | 53 ++++++++++ connect/src/spirc.rs | 146 +++++++++++++++++++++------- connect/src/state/context.rs | 23 ++++- core/src/dealer/protocol/request.rs | 11 ++- 5 files changed, 196 insertions(+), 38 deletions(-) create mode 100644 connect/src/model.rs diff --git a/connect/src/lib.rs b/connect/src/lib.rs index 6253b8eef..3cfbbca19 100644 --- a/connect/src/lib.rs +++ b/connect/src/lib.rs @@ -5,5 +5,6 @@ use librespot_core as core; use librespot_playback as playback; use librespot_protocol as protocol; +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 000000000..b63d561bd --- /dev/null +++ b/connect/src/model.rs @@ -0,0 +1,53 @@ +use librespot_protocol::player::Context; +use std::fmt::{Display, Formatter}; + +#[derive(Debug, Clone)] +pub struct ResolveContext { + context: Context, + autoplay: bool, +} + +impl ResolveContext { + pub fn from_uri(uri: impl Into, autoplay: bool) -> Self { + Self { + context: Context { + uri: uri.into(), + ..Default::default() + }, + autoplay, + } + } + + pub fn from_context(context: Context, autoplay: bool) -> Self { + Self { context, autoplay } + } + + pub fn uri(&self) -> &str { + &self.context.uri + } + + pub fn autoplay(&self) -> bool { + self.autoplay + } +} + +impl Display for ResolveContext { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "uri: {}, autoplay: {}", self.context.uri, self.autoplay) + } +} + +impl PartialEq for ResolveContext { + fn eq(&self, other: &Self) -> bool { + let eq_autoplay = self.autoplay == other.autoplay; + let eq_context = self.context.uri == other.context.uri; + + eq_autoplay && eq_context + } +} + +impl From for Context { + fn from(value: ResolveContext) -> Self { + value.context + } +} diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 176d8596f..b25ad933f 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,3 +1,4 @@ +use crate::model::ResolveContext; use crate::state::context::ContextType; use crate::state::provider::IsProvider; use crate::state::{ConnectState, ConnectStateConfig}; @@ -17,6 +18,7 @@ use librespot_core::dealer::protocol::{PayloadValue, RequestCommand, SkipTo}; use librespot_protocol::autoplay_context_request::AutoplayContextRequest; use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason, SetVolumeCommand}; use librespot_protocol::player::{Context, TransferState}; +use librespot_protocol::playlist4_external::PlaylistModificationInfo; use protobuf::{Message, MessageField}; use std::{ future::Future, @@ -76,12 +78,6 @@ pub(crate) enum SpircPlayStatus { }, } -#[derive(Debug)] -pub struct ResolveContext { - uri: String, - autoplay: bool, -} - type BoxedStream = Pin + Send>>; struct SpircTask { @@ -96,6 +92,7 @@ struct SpircTask { connection_id_update: BoxedStream>, connect_state_update: BoxedStream>, connect_state_volume_update: BoxedStream>, + playlist_update: BoxedStream>, connect_state_command: BoxedStream, user_attributes_update: BoxedStream>, user_attributes_mutation: BoxedStream>, @@ -174,6 +171,10 @@ impl From for PlayingTrack { const CONTEXT_FETCH_THRESHOLD: usize = 2; 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); +// 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 { @@ -231,6 +232,17 @@ impl Spirc { }), ); + let playlist_update = Box::pin( + session + .dealer() + .listen_for("hm://playlist/v2/playlist/")? + .map(|msg| match msg.payload { + PayloadValue::Raw(bytes) => PlaylistModificationInfo::parse_from_bytes(&bytes) + .map_err(Error::failed_precondition), + other => Err(SpircError::UnexpectedData(other).into()), + }), + ); + let connect_state_command = Box::pin( session .dealer() @@ -289,6 +301,7 @@ impl Spirc { connection_id_update, connect_state_update, connect_state_volume_update, + playlist_update, connect_state_command, user_attributes_update, user_attributes_mutation, @@ -400,6 +413,18 @@ impl SpircTask { break; } }, + playlist_update = self.playlist_update.next() => match playlist_update { + Some(result) => match result { + Ok(update) => if let Err(why) = self.handle_playlist_modification(update) { + error!("failed to handle playlist modificationL: {why}") + }, + Err(e) => error!("could not parse playlist update: {}", e), + } + None => { + error!("playlist update selected, but none received"); + break; + } + }, connect_state_command = self.connect_state_command.next() => match connect_state_command { Some(request) => if let Err(e) = self.handle_connect_state_command(request).await { error!("couldn't handle connect state command: {}", e); @@ -449,7 +474,7 @@ impl SpircTask { error!("could not dispatch player event: {}", e); } }, - _ = async {}, if !self.resolve_context.is_empty() => { + _ = async { sleep(RESOLVE_CONTEXT_DELAY).await }, if !self.resolve_context.is_empty() => { if let Err(why) = self.handle_resolve_context().await { error!("ContextError: {why}") } @@ -483,8 +508,27 @@ impl SpircTask { } async fn handle_resolve_context(&mut self) -> Result<(), Error> { + let mut last_resolve = None::; while let Some(resolve) = self.resolve_context.pop() { - self.resolve_context(resolve.uri, resolve.autoplay).await?; + 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()); + + // 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.autoplay()) + .await + { + error!("failed resolving context <{resolve}>: {why}"); + self.connect_state.reset_context(None); + self.handle_stop() + } + + self.connect_state.merge_context(Some(resolve.into())); + } } if let Some(transfer_state) = self.transfer_state.take() { @@ -492,6 +536,20 @@ impl SpircTask { .setup_state_from_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)?; + } else { + let current_index = ConnectState::find_index_in_context(ctx, |t| { + self.connect_state.current_track(|t| &t.uri) == &t.uri + }) + .ok(); + self.connect_state.reset_playback_context(current_index)?; + }; + } + self.connect_state.fill_up_next_tracks()?; self.connect_state.update_restrictions(); self.connect_state.update_queue_revision(); @@ -501,9 +559,9 @@ impl SpircTask { self.notify().await } - async fn resolve_context(&mut self, context_uri: String, autoplay: bool) -> Result<(), Error> { + async fn resolve_context(&mut self, context_uri: &str, autoplay: bool) -> Result<(), Error> { if !autoplay { - match self.session.spclient().get_context(&context_uri).await { + match self.session.spclient().get_context(context_uri).await { Err(why) => error!("failed to resolve context '{context_uri}': {why}"), Ok(ctx) => self.connect_state.update_context(ctx)?, }; @@ -515,11 +573,9 @@ impl SpircTask { if context_uri.contains("spotify:show:") || context_uri.contains("spotify:episode:") { // autoplay is not supported for podcasts - return Err(SpircError::NotAllowedContext(ResolveContext { - uri: context_uri, - autoplay: true, - }) - .into()); + return Err( + SpircError::NotAllowedContext(ResolveContext::from_uri(context_uri, true)).into(), + ); } let previous_tracks = self.connect_state.prev_autoplay_track_uris(); @@ -990,9 +1046,21 @@ impl SpircTask { if let Some(shuffle) = shuffle { self.connect_state.handle_shuffle(shuffle)?; } - + self.notify().await.map(|_| Reply::Success)? } + RequestCommand::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 + ) + } else { + self.resolve_context + .push(ResolveContext::from_context(update_context.context, false)); + } + Reply::Success + } RequestCommand::SkipNext(skip_next) => { self.handle_next(skip_next.track.map(|t| t.uri))?; self.notify().await.map(|_| Reply::Success)? @@ -1028,10 +1096,8 @@ impl SpircTask { } debug!("async resolve context for {}", ctx_uri); - self.resolve_context.push(ResolveContext { - autoplay: false, - uri: ctx_uri.clone(), - }); + self.resolve_context + .push(ResolveContext::from_uri(ctx_uri.clone(), false)); let timestamp = self.now_ms(); let state = &mut self.connect_state; @@ -1067,10 +1133,9 @@ impl SpircTask { && (self.connect_state.current_track(|t| t.is_autoplay()) || autoplay) { debug!("currently in autoplay context, async resolving autoplay for {ctx_uri}"); - self.resolve_context.push(ResolveContext { - uri: ctx_uri, - autoplay: true, - }) + + self.resolve_context + .push(ResolveContext::from_uri(ctx_uri, true)) } self.transfer_state = Some(transfer); @@ -1146,7 +1211,7 @@ impl SpircTask { debug!("context <{current_context_uri}> didn't change, no resolving required",) } else { debug!("resolving context for load command"); - self.resolve_context(cmd.context_uri.clone(), false).await?; + self.resolve_context(&cmd.context_uri, false).await?; } // for play commands with skip by uid, the context of the command contains @@ -1188,10 +1253,8 @@ impl SpircTask { } if !self.connect_state.has_next_tracks(None) && self.session.autoplay() { - self.resolve_context.push(ResolveContext { - uri: cmd.context_uri, - autoplay: true, - }) + self.resolve_context + .push(ResolveContext::from_uri(cmd.context_uri, true)) } Ok(()) @@ -1322,10 +1385,8 @@ impl SpircTask { if preload_autoplay { debug!("Preloading autoplay context for <{}>", uri); // resolve the next autoplay context - self.resolve_context.push(ResolveContext { - uri, - autoplay: true, - }); + self.resolve_context + .push(ResolveContext::from_uri(uri, true)); } } @@ -1403,6 +1464,25 @@ impl SpircTask { 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.resolve_context + .push(ResolveContext::from_uri(uri, false)); + + Ok(()) + } + fn position(&mut self) -> u32 { match self.play_status { SpircPlayStatus::Stopped => 0, diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 1119a507b..df27501e5 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -67,14 +67,15 @@ impl ConnectState { pub fn update_context(&mut self, mut context: Context) -> Result<(), Error> { debug!("context: {}, {}", context.uri, context.url); - self.player.context_url = format!("context://{}", context.uri); - self.player.context_uri = context.uri.clone(); - if context.restrictions.is_some() { self.player.restrictions = context.restrictions.clone(); self.player.context_restrictions = context.restrictions; + } else { + self.player.context_restrictions = Default::default(); + self.player.restrictions = Default::default() } + self.player.context_metadata.clear(); for (key, value) in context.metadata { self.player.context_metadata.insert(key, value); } @@ -84,11 +85,25 @@ impl ConnectState { Some(page) => page, }; + debug!( + "updated context from {} ({} tracks) to {} ({} tracks)", + self.player.context_uri, + self.context + .as_ref() + .map(|c| c.tracks.len()) + .unwrap_or_default(), + &context.uri, + page.tracks.len() + ); + + self.player.context_url = format!("context://{}", &context.uri); + self.player.context_uri = context.uri; + let tracks = page .tracks .iter() .flat_map(|track| { - match self.context_to_provided_track(track, Some(&context.uri), None) { + match self.context_to_provided_track(track, Some(&self.player.context_uri), None) { Ok(t) => Some(t), Err(_) => { error!("couldn't convert {track:#?} into ProvidedTrack"); diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index e9575f717..3720806a9 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -23,13 +23,14 @@ pub enum RequestCommand { Play(Box), Pause(PauseCommand), SeekTo(SeekToCommand), - SkipNext(SkipNextCommand), 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), @@ -54,6 +55,7 @@ impl Display for RequestCommand { RequestCommand::AddToQueue(_) => "add_to_queue", RequestCommand::SetQueue(_) => "set_queue", RequestCommand::SetOptions(_) => "set_options", + RequestCommand::UpdateContext(_) => "update_context", RequestCommand::SkipNext(_) => "skip_next", RequestCommand::SkipPrev(_) => "skip_prev", RequestCommand::Resume(_) => "resume", @@ -141,6 +143,13 @@ pub struct SetOptionsCommand { 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, From e7a1f50dc47dc072cea79a7ef0184f712c1a2f9d Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 16 Nov 2024 17:10:22 +0100 Subject: [PATCH 079/138] connect: move other structs into model.rs --- connect/src/model.rs | 55 +++++++++++++++++++++++++++++++++++++++++ connect/src/spirc.rs | 58 ++------------------------------------------ connect/src/state.rs | 2 +- 3 files changed, 58 insertions(+), 57 deletions(-) diff --git a/connect/src/model.rs b/connect/src/model.rs index b63d561bd..64bbefefb 100644 --- a/connect/src/model.rs +++ b/connect/src/model.rs @@ -1,3 +1,4 @@ +use librespot_core::dealer::protocol::SkipTo; use librespot_protocol::player::Context; use std::fmt::{Display, Formatter}; @@ -51,3 +52,57 @@ impl From for Context { value.context } } + +#[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 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, + }, +} diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index b25ad933f..8d3752454 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,4 +1,4 @@ -use crate::model::ResolveContext; +use crate::model::{PlayingTrack, ResolveContext, SpircLoadCommand, SpircPlayStatus}; use crate::state::context::ContextType; use crate::state::provider::IsProvider; use crate::state::{ConnectState, ConnectStateConfig}; @@ -14,7 +14,7 @@ use crate::{ }; use futures_util::{FutureExt, Stream, StreamExt}; use librespot_core::dealer::manager::{Reply, RequestReply}; -use librespot_core::dealer::protocol::{PayloadValue, RequestCommand, SkipTo}; +use librespot_core::dealer::protocol::{PayloadValue, RequestCommand}; use librespot_protocol::autoplay_context_request::AutoplayContextRequest; use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason, SetVolumeCommand}; use librespot_protocol::player::{Context, TransferState}; @@ -59,25 +59,6 @@ impl From for Error { } } -#[derive(Debug)] -pub(crate) 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 { @@ -133,41 +114,6 @@ 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 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 is important as it seems that the index can be 0, - // but there might still be a uid or uri provided, so we try 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 - })) - } - } -} - const CONTEXT_FETCH_THRESHOLD: usize = 2; const VOLUME_STEP_SIZE: u16 = 1024; // (u16::MAX + 1) / VOLUME_STEPS diff --git a/connect/src/state.rs b/connect/src/state.rs index af692e2eb..00fc0fec8 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -7,7 +7,7 @@ mod restrictions; mod tracks; mod transfer; -use crate::spirc::SpircPlayStatus; +use crate::model::SpircPlayStatus; use crate::state::consts::{METADATA_CONTEXT_URI, METADATA_IS_QUEUED}; use crate::state::context::{ContextType, StateContext}; use crate::state::provider::{IsProvider, Provider}; From c725ebb1d9f69b667b4fdff5c7be3f52f919b2ff Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 16 Nov 2024 17:15:34 +0100 Subject: [PATCH 080/138] connect: reduce SpircCommand visibility --- connect/src/model.rs | 2 +- connect/src/spirc.rs | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/connect/src/model.rs b/connect/src/model.rs index 64bbefefb..a8e429387 100644 --- a/connect/src/model.rs +++ b/connect/src/model.rs @@ -73,7 +73,7 @@ pub enum PlayingTrack { 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, + // 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) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 8d3752454..1c26091bd 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -95,7 +95,7 @@ struct SpircTask { static SPIRC_COUNTER: AtomicUsize = AtomicUsize::new(0); #[derive(Debug)] -pub enum SpircCommand { +enum SpircCommand { Play, PlayPause, Pause, @@ -784,9 +784,9 @@ impl SpircTask { } }; - // todo: handle received pages from transfer, important to not always shuffle the first 10 pages - // also important when the dealer is restarted, currently we shuffle again, index should be changed... - // maybe lookup current track of actual player + // 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 if let Some(cluster) = response { if !cluster.transfer_data.is_empty() { if let Ok(transfer_state) = TransferState::parse_from_bytes(&cluster.transfer_data) From 93c56557c37c6fcd13d0068f1a3433a107cde2d4 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 16 Nov 2024 18:04:38 +0100 Subject: [PATCH 081/138] connect: fix visibility of model --- connect/src/spirc.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 1c26091bd..e9caa9972 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,4 +1,4 @@ -use crate::model::{PlayingTrack, ResolveContext, SpircLoadCommand, SpircPlayStatus}; +use crate::model::{ResolveContext, SpircPlayStatus}; use crate::state::context::ContextType; use crate::state::provider::IsProvider; use crate::state::{ConnectState, ConnectStateConfig}; @@ -31,6 +31,8 @@ use thiserror::Error; use tokio::{sync::mpsc, time::sleep}; use tokio_stream::wrappers::UnboundedReceiverStream; +pub use crate::model::{PlayingTrack, SpircLoadCommand}; + #[derive(Debug, Error)] pub enum SpircError { #[error("response payload empty")] From b52a176c3076285006c6d39c6a0455e2b1033790 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 16 Nov 2024 18:34:18 +0100 Subject: [PATCH 082/138] connect: fix: shuffle on startup isn't applied --- connect/src/spirc.rs | 14 ++++++++++---- core/src/dealer/protocol/request.rs | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index e9caa9972..5c8df2da8 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -919,19 +919,19 @@ impl SpircTask { RequestCommand::Play(play) => { let shuffle = play .options - .player_options_overrides + .player_options_override .as_ref() .map(|o| o.shuffling_context) .unwrap_or_else(|| self.connect_state.shuffling_context()); let repeat = play .options - .player_options_overrides + .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_overrides + .player_options_override .as_ref() .map(|o| o.repeating_track) .unwrap_or_else(|| self.connect_state.repeat_track()); @@ -1179,7 +1179,14 @@ impl SpircTask { } }; + 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.active_context = ContextType::Default; self.connect_state.set_current_track(index)?; @@ -1190,7 +1197,6 @@ impl SpircTask { self.connect_state.reset_playback_context(Some(index))?; } - self.connect_state.set_repeat_context(cmd.repeat); self.connect_state.set_repeat_track(cmd.repeat_track); if self.connect_state.current_track(MessageField::is_some) { diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index 3720806a9..2248c8f98 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -167,7 +167,7 @@ pub struct TransferOptions { pub struct PlayOptions { pub skip_to: SkipTo, #[serde(default, deserialize_with = "option_json_proto")] - pub player_options_overrides: Option, + pub player_options_override: Option, pub license: String, // mobile pub always_play_something: Option, From 658dfb087aea9106a09029bd40ee568c1d55ff06 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Mon, 18 Nov 2024 00:03:13 +0100 Subject: [PATCH 083/138] connect: prevent loading a context with no tracks --- connect/src/state.rs | 4 +++- connect/src/state/context.rs | 13 ++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/connect/src/state.rs b/connect/src/state.rs index 00fc0fec8..f3b4ecf50 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -43,8 +43,10 @@ pub enum StateError { NoContext(ContextType), #[error("could not find track {0:?} in context of {1}")] CanNotFindTrackInContext(Option, usize), - #[error("Currently {action} is not allowed because {reason}")] + #[error("currently {action} is not allowed because {reason}")] CurrentlyDisallowed { action: String, reason: String }, + #[error("the provided context has no tracks")] + ContextHasNoTracks, } impl From for Error { diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index df27501e5..41b2c75db 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -67,6 +67,14 @@ impl ConnectState { pub fn update_context(&mut self, mut context: Context) -> Result<(), Error> { debug!("context: {}, {}", context.uri, context.url); + let page = match context.pages.pop() { + None => return Ok(()), + Some(page) if page.tracks.is_empty() => { + return Err(StateError::ContextHasNoTracks.into()) + } + Some(page) => page, + }; + if context.restrictions.is_some() { self.player.restrictions = context.restrictions.clone(); self.player.context_restrictions = context.restrictions; @@ -80,11 +88,6 @@ impl ConnectState { self.player.context_metadata.insert(key, value); } - let page = match context.pages.pop() { - None => return Ok(()), - Some(page) => page, - }; - debug!( "updated context from {} ({} tracks) to {} ({} tracks)", self.player.context_uri, From 1b9192b52a983f5aca2c145621393ac0d12ace1a Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Mon, 18 Nov 2024 00:13:12 +0100 Subject: [PATCH 084/138] connect: use the first page of a context --- connect/src/state/context.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 41b2c75db..c3aee446d 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -67,12 +67,16 @@ impl ConnectState { pub fn update_context(&mut self, mut context: Context) -> Result<(), Error> { debug!("context: {}, {}", context.uri, context.url); - let page = match context.pages.pop() { + let page = match context.pages.first() { None => return Ok(()), Some(page) if page.tracks.is_empty() => { return Err(StateError::ContextHasNoTracks.into()) } - Some(page) => page, + // todo: handle multiple pages + // currently i only expected a context to only have a single page, because playlists, + // albums and the collection behaves like it + // but the artist context sends multiple pages for example + Some(_) => context.pages.swap_remove(0), }; if context.restrictions.is_some() { From ac10e63163b3cf1f50bb00f50ad865b0a22df23e Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 19 Nov 2024 23:30:02 +0100 Subject: [PATCH 085/138] connect: improve context resolving - support multiple pages - support page_url of context - handle single track --- connect/src/model.rs | 52 +++++++++++- connect/src/spirc.rs | 71 +++++++++++----- connect/src/state.rs | 13 +-- connect/src/state/context.rs | 153 ++++++++++++++++++++-------------- connect/src/state/provider.rs | 1 + connect/src/state/tracks.rs | 2 +- 6 files changed, 197 insertions(+), 95 deletions(-) diff --git a/connect/src/model.rs b/connect/src/model.rs index a8e429387..80923cb8b 100644 --- a/connect/src/model.rs +++ b/connect/src/model.rs @@ -6,6 +6,12 @@ use std::fmt::{Display, Formatter}; pub struct ResolveContext { context: Context, autoplay: bool, + /// if `true` updates the entire context, otherwise only updates 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_all: bool, } impl ResolveContext { @@ -16,11 +22,40 @@ impl ResolveContext { ..Default::default() }, autoplay, + update_all: true, } } pub fn from_context(context: Context, autoplay: bool) -> Self { - Self { context, autoplay } + Self { + context, + autoplay, + update_all: 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 { + page_url.split("/") + }; + + let uri = split + .skip_while(|s| s != &"spotify") + .take(3) + .collect::>() + .join(":"); + + Self { + context: Context { + uri, + ..Default::default() + }, + update_all: false, + autoplay: false, + } } pub fn uri(&self) -> &str { @@ -30,20 +65,29 @@ impl ResolveContext { pub fn autoplay(&self) -> bool { self.autoplay } + + pub fn update_all(&self) -> bool { + self.update_all + } } impl Display for ResolveContext { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "uri: {}, autoplay: {}", self.context.uri, self.autoplay) + write!( + f, + "uri: {}, autoplay: {}, update_all: {}", + self.context.uri, self.autoplay, self.update_all + ) } } impl PartialEq for ResolveContext { fn eq(&self, other: &Self) -> bool { - let eq_autoplay = self.autoplay == other.autoplay; let eq_context = self.context.uri == other.context.uri; + let eq_autoplay = self.autoplay == other.autoplay; + let eq_update_all = self.update_all == other.update_all; - eq_autoplay && eq_context + eq_autoplay && eq_context && eq_update_all } } diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 5c8df2da8..282ec0ed2 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,5 +1,5 @@ use crate::model::{ResolveContext, SpircPlayStatus}; -use crate::state::context::ContextType; +use crate::state::context::{ContextType, LoadNext}; use crate::state::provider::IsProvider; use crate::state::{ConnectState, ConnectStateConfig}; use crate::{ @@ -467,7 +467,7 @@ impl SpircTask { // 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.autoplay()) + .resolve_context(resolve.uri(), resolve.autoplay(), resolve.update_all()) .await { error!("failed resolving context <{resolve}>: {why}"); @@ -489,13 +489,7 @@ impl SpircTask { if matches!(ctx, Some(ctx) if ctx.tracks.is_empty()) { self.connect_state.clear_next_tracks(true); self.handle_next(None)?; - } else { - let current_index = ConnectState::find_index_in_context(ctx, |t| { - self.connect_state.current_track(|t| &t.uri) == &t.uri - }) - .ok(); - self.connect_state.reset_playback_context(current_index)?; - }; + } } self.connect_state.fill_up_next_tracks()?; @@ -507,11 +501,28 @@ impl SpircTask { self.notify().await } - async fn resolve_context(&mut self, context_uri: &str, autoplay: bool) -> Result<(), Error> { + async fn resolve_context( + &mut self, + context_uri: &str, + autoplay: bool, + update_all: bool, + ) -> Result<(), Error> { if !autoplay { match self.session.spclient().get_context(context_uri).await { Err(why) => error!("failed to resolve context '{context_uri}': {why}"), - Ok(ctx) => self.connect_state.update_context(ctx)?, + Ok(ctx) if update_all => { + debug!("update entire context"); + self.connect_state.update_context(ctx)? + } + Ok(mut ctx) 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.update_context_from_page( + ctx.pages.remove(0), + None, + None, + ); + } + Ok(ctx) => 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}") @@ -1159,7 +1170,7 @@ impl SpircTask { debug!("context <{current_context_uri}> didn't change, no resolving required",) } else { debug!("resolving context for load command"); - self.resolve_context(&cmd.context_uri, false).await?; + self.resolve_context(&cmd.context_uri, false, true).await?; } // for play commands with skip by uid, the context of the command contains @@ -1330,17 +1341,35 @@ impl SpircTask { } fn preload_autoplay_when_required(&mut self, uri: String) { - let preload_autoplay = self + let require_load_new = !self .connect_state - .has_next_tracks(Some(CONTEXT_FETCH_THRESHOLD)) - && self.session.autoplay(); + .has_next_tracks(Some(CONTEXT_FETCH_THRESHOLD)); - // When in autoplay, keep topping up the playlist when it nears the end - if preload_autoplay { - debug!("Preloading autoplay context for <{}>", uri); - // resolve the next autoplay context - self.resolve_context - .push(ResolveContext::from_uri(uri, true)); + if !require_load_new { + return; + } + + 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.resolve_context + .push(ResolveContext::from_page_url(page_url)); + } + LoadNext::Empty if self.session.autoplay() => { + // When in autoplay, keep topping up the playlist when it nears the end + debug!("Preloading autoplay context for <{}>", uri); + // resolve the next autoplay context + self.resolve_context + .push(ResolveContext::from_uri(uri, true)); + } + LoadNext::Empty => { + debug!("next context is empty and autoplay isn't enabled, no preloading required") + } + } + } } } diff --git a/connect/src/state.rs b/connect/src/state.rs index f3b4ecf50..2afff544b 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -20,7 +20,8 @@ use librespot_protocol::connect::{ Capabilities, Device, DeviceInfo, MemberType, PutStateReason, PutStateRequest, }; use librespot_protocol::player::{ - ContextIndex, ContextPlayerOptions, PlayOrigin, PlayerState, ProvidedTrack, Suppressions, + ContextIndex, ContextPage, ContextPlayerOptions, PlayOrigin, PlayerState, ProvidedTrack, + Suppressions, }; use log::LevelFilter; use protobuf::{EnumOrUnknown, MessageField}; @@ -92,19 +93,21 @@ pub struct ConnectState { player_index: Option, /// index: 0 based, so the first track is index 0 - /// prev_track: bottom => top, aka the last track of the list is the prev track - /// next_track: top => bottom, aka the first track of the list is the next track player: PlayerState, - /// we don't work directly on the lists of the player state, because - /// we mostly need to push and pop at the beginning of both + // we don't work directly on the track lists of the player state, because + // we mostly need to push and pop at the beginning of them + /// bottom => top, aka the last track of the list is the prev track prev_tracks: VecDeque, + /// top => bottom, aka the first track of the list is the next track next_tracks: VecDeque, pub active_context: ContextType, /// the context from which we play, is used to top up prev and next tracks /// the index is used to keep track which tracks are already loaded into next tracks pub context: Option, + /// upcoming contexts, usually directly provided by the context-resolver + pub next_contexts: Vec, /// a context to keep track of our shuffled context, should be only available when option.shuffling_context is true pub shuffle_context: Option, /// a context to keep track of the autoplay context diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index c3aee446d..ff1325da7 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -2,7 +2,7 @@ use crate::state::consts::METADATA_ENTITY_URI; use crate::state::provider::Provider; use crate::state::{ConnectState, StateError, METADATA_CONTEXT_URI}; use librespot_core::{Error, SpotifyId}; -use librespot_protocol::player::{Context, ContextIndex, ContextTrack, ProvidedTrack}; +use librespot_protocol::player::{Context, ContextIndex, ContextPage, ContextTrack, ProvidedTrack}; use std::collections::HashMap; #[derive(Debug, Clone)] @@ -20,6 +20,12 @@ pub enum ContextType { Autoplay, } +pub enum LoadNext { + Done, + PageUrl(String), + Empty, +} + impl ConnectState { pub fn find_index_in_context bool>( context: Option<&StateContext>, @@ -64,19 +70,28 @@ impl ConnectState { self.update_restrictions() } - pub fn update_context(&mut self, mut context: Context) -> Result<(), Error> { + pub fn update_context(&mut self, context: Context) -> Result<(), Error> { debug!("context: {}, {}", context.uri, context.url); - let page = match context.pages.first() { - None => return Ok(()), - Some(page) if page.tracks.is_empty() => { - return Err(StateError::ContextHasNoTracks.into()) + if context.pages.iter().all(|p| p.tracks.is_empty()) { + error!("context didn't have any tracks: {context:#?}"); + return Err(StateError::ContextHasNoTracks.into()); + } + + 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) } - // todo: handle multiple pages - // currently i only expected a context to only have a single page, because playlists, - // albums and the collection behaves like it - // but the artist context sends multiple pages for example - Some(_) => context.pages.swap_remove(0), + } + + let page = match first_page { + None => return Err(StateError::ContextHasNoTracks.into()), + Some(p) => p, }; if context.restrictions.is_some() { @@ -92,40 +107,11 @@ impl ConnectState { self.player.context_metadata.insert(key, value); } - debug!( - "updated context from {} ({} tracks) to {} ({} tracks)", - self.player.context_uri, - self.context - .as_ref() - .map(|c| c.tracks.len()) - .unwrap_or_default(), - &context.uri, - page.tracks.len() - ); + self.update_context_from_page(page, Some(&context.uri), None); self.player.context_url = format!("context://{}", &context.uri); self.player.context_uri = context.uri; - let tracks = page - .tracks - .iter() - .flat_map(|track| { - match self.context_to_provided_track(track, Some(&self.player.context_uri), None) { - Ok(t) => Some(t), - Err(_) => { - error!("couldn't convert {track:#?} into ProvidedTrack"); - None - } - } - }) - .collect(); - - self.context = Some(StateContext { - tracks, - metadata: page.metadata, - index: ContextIndex::new(), - }); - Ok(()) } @@ -142,15 +128,35 @@ impl ConnectState { .ok_or(StateError::NoContext(ContextType::Autoplay))?; debug!("autoplay-context size: {}", page.tracks.len()); + self.update_context_from_page(page, Some(&context.uri), Some(Provider::Autoplay)); + + Ok(()) + } + + pub fn update_context_from_page( + &mut self, + page: ContextPage, + new_context_uri: Option<&str>, + provider: Option, + ) { + let new_context_uri = new_context_uri.unwrap_or(&self.player.context_uri); + debug!( + "updated context from {} ({} tracks) to {} ({} tracks)", + self.player.context_uri, + self.context + .as_ref() + .map(|c| c.tracks.len()) + .unwrap_or_default(), + new_context_uri, + page.tracks.len() + ); + let tracks = page .tracks .iter() .flat_map(|track| { - match self.context_to_provided_track( - track, - Some(&context.uri), - Some(Provider::Autoplay), - ) { + match self.context_to_provided_track(track, Some(new_context_uri), provider.clone()) + { Ok(t) => Some(t), Err(_) => { error!("couldn't convert {track:#?} into ProvidedTrack"); @@ -158,22 +164,13 @@ impl ConnectState { } } }) - .collect::>(); - - // add the tracks to the context if we already have an autoplay context - if let Some(autoplay_context) = self.autoplay_context.as_mut() { - for track in tracks { - autoplay_context.tracks.push(track) - } - } else { - self.autoplay_context = Some(StateContext { - tracks, - metadata: page.metadata, - index: ContextIndex::new(), - }) - } + .collect(); - Ok(()) + self.context = Some(StateContext { + tracks, + metadata: page.metadata, + index: ContextIndex::new(), + }) } pub fn merge_context(&mut self, context: Option) -> Option<()> { @@ -217,14 +214,26 @@ impl ConnectState { context_uri: Option<&str>, provider: Option, ) -> Result { - let provider = if self.unavailable_uri.contains(&ctx_track.uri) { + let question_mark_idx = ctx_track + .uri + .contains("?") + .then(|| ctx_track.uri.find('?')) + .flatten(); + + let ctx_track_uri = if let Some(idx) = question_mark_idx { + &ctx_track.uri[..idx].to_string() + } else { + &ctx_track.uri + }; + + let provider = if self.unavailable_uri.contains(ctx_track_uri) { Provider::Unavailable } else { provider.unwrap_or(Provider::Context) }; - let id = if !ctx_track.uri.is_empty() { - SpotifyId::from_uri(&ctx_track.uri) + let id = if !ctx_track_uri.is_empty() { + SpotifyId::from_uri(ctx_track_uri) } else if !ctx_track.gid.is_empty() { SpotifyId::from_raw(&ctx_track.gid) } else { @@ -249,4 +258,20 @@ impl ConnectState { ..Default::default() }) } + + 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() { + return Ok(LoadNext::PageUrl(next.page_url)); + } + + self.update_context_from_page(next, None, None); + self.fill_up_next_tracks()?; + + Ok(LoadNext::Done) + } } diff --git a/connect/src/state/provider.rs b/connect/src/state/provider.rs index 172efd7e4..e1c3fe6f8 100644 --- a/connect/src/state/provider.rs +++ b/connect/src/state/provider.rs @@ -11,6 +11,7 @@ const PROVIDER_AUTOPLAY: &str = "autoplay"; /// 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, diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index ab6ece6ce..70885bb30 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -295,7 +295,7 @@ impl<'ct> ConnectState { pub fn has_next_tracks(&self, min: Option) -> bool { if let Some(min) = min { - self.next_tracks.len() <= min + self.next_tracks.len() >= min } else { !self.next_tracks.is_empty() } From 98e55f5a1c95aa5199f2a4a349e565ced923ec49 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 19 Nov 2024 23:45:42 +0100 Subject: [PATCH 086/138] connect: prevent integer underflow --- connect/src/spirc.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 282ec0ed2..dc5fbd609 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -140,7 +140,6 @@ impl Spirc { let spirc_id = SPIRC_COUNTER.fetch_add(1, Ordering::AcqRel); debug!("new Spirc[{}]", spirc_id); - let initial_volume = config.initial_volume; let connect_state = ConnectState::new(config, &session); let connection_id_update = Box::pin( @@ -267,8 +266,14 @@ impl Spirc { }; let spirc = Spirc { commands: cmd_tx }; - task.set_volume(initial_volume as u16 - 1); - task.update_volume = false; + + let initial_volume = task.connect_state.device.volume; + task.connect_state.device.volume = 0; + + match initial_volume.try_into() { + Ok(volume) => task.set_volume(volume), + Err(why) => error!("failed to update initial volume: {why}"), + }; Ok((spirc, task.run())) } From 73257ab76ac262e7e830c280a10da4b0e3c162e0 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 19 Nov 2024 23:50:17 +0100 Subject: [PATCH 087/138] connect: rename method for better clarity --- connect/src/spirc.rs | 6 +++--- connect/src/state.rs | 2 +- connect/src/state/handle.rs | 4 ++-- connect/src/state/transfer.rs | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index dc5fbd609..cdad653c8 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1210,7 +1210,7 @@ impl SpircTask { } else { // set manually, so that we overwrite a possible current queue track self.connect_state.set_current_track(index)?; - self.connect_state.reset_playback_context(Some(index))?; + self.connect_state.reset_playback_to_position(Some(index))?; } self.connect_state.set_repeat_track(cmd.repeat_track); @@ -1408,7 +1408,7 @@ impl SpircTask { self.load_track(continue_playing, 0) } else { info!("Not playing next track because there are no more tracks left in queue."); - self.connect_state.reset_playback_context(None)?; + self.connect_state.reset_playback_to_position(None)?; self.handle_stop(); Ok(()) } @@ -1422,7 +1422,7 @@ impl SpircTask { let new_track_index = self.connect_state.prev_track()?; if new_track_index.is_none() && self.connect_state.repeat_context() { - self.connect_state.reset_playback_context(None)? + self.connect_state.reset_playback_to_position(None)? } self.load_track(self.is_playing(), 0) diff --git a/connect/src/state.rs b/connect/src/state.rs index 2afff544b..9ec75bab5 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -245,7 +245,7 @@ impl ConnectState { self.player.queue_revision = state.finish().to_string() } - pub fn reset_playback_context(&mut self, new_index: Option) -> Result<(), Error> { + pub fn reset_playback_to_position(&mut self, new_index: Option) -> Result<(), Error> { let new_index = new_index.unwrap_or(0); if let Some(player_index) = self.player.index.as_mut() { player_index.track = new_index as u32; diff --git a/connect/src/state/handle.rs b/connect/src/state/handle.rs index 4f84bb6c3..f53bf5f63 100644 --- a/connect/src/state/handle.rs +++ b/connect/src/state/handle.rs @@ -21,7 +21,7 @@ impl ConnectState { let current_index = ConnectState::find_index_in_context(ctx, |c| self.current_track(|t| c.uri == t.uri))?; - self.reset_playback_context(Some(current_index)) + self.reset_playback_to_position(Some(current_index)) } pub fn handle_set_queue(&mut self, set_queue: SetQueueCommand) { @@ -57,7 +57,7 @@ impl ConnectState { let current_track = ConnectState::find_index_in_context(ctx, |t| { self.current_track(|t| &t.uri) == &t.uri })?; - self.reset_playback_context(Some(current_track)) + self.reset_playback_to_position(Some(current_track)) } else { self.update_restrictions(); Ok(()) diff --git a/connect/src/state/transfer.rs b/connect/src/state/transfer.rs index 96faa4ab0..32d5c9de3 100644 --- a/connect/src/state/transfer.rs +++ b/connect/src/state/transfer.rs @@ -133,7 +133,7 @@ impl ConnectState { self.set_shuffle(true); self.shuffle()?; } else { - self.reset_playback_context(current_index)?; + self.reset_playback_to_position(current_index)?; } self.update_restrictions(); From 6bd09ff005f86ec238aa20f08c6f2df0556822ac Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Wed, 20 Nov 2024 00:16:31 +0100 Subject: [PATCH 088/138] connect: handle mutate and update messages --- connect/src/spirc.rs | 55 +++++++++---------------------------- core/src/dealer/mod.rs | 2 +- core/src/dealer/protocol.rs | 18 +++++++++++- 3 files changed, 31 insertions(+), 44 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index cdad653c8..c652ed302 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -12,9 +12,9 @@ use crate::{ explicit_content_pubsub::UserAttributesUpdate, user_attributes::UserAttributesMutation, }, }; -use futures_util::{FutureExt, Stream, StreamExt}; +use futures_util::{Stream, StreamExt}; use librespot_core::dealer::manager::{Reply, RequestReply}; -use librespot_core::dealer::protocol::{PayloadValue, RequestCommand}; +use librespot_core::dealer::protocol::RequestCommand; use librespot_protocol::autoplay_context_request::AutoplayContextRequest; use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason, SetVolumeCommand}; use librespot_protocol::player::{Context, TransferState}; @@ -37,8 +37,6 @@ pub use crate::model::{PlayingTrack, SpircLoadCommand}; pub enum SpircError { #[error("response payload empty")] NoData, - #[error("received unexpected data {0:#?}")] - UnexpectedData(PayloadValue), #[error("playback of local files is not supported")] UnsupportedLocalPlayBack, #[error("message addressed at another ident: {0}")] @@ -53,9 +51,7 @@ impl From for Error { fn from(err: SpircError) -> Self { use SpircError::*; match err { - NoData | UnsupportedLocalPlayBack | UnexpectedData(_) | NotAllowedContext(_) => { - Error::unavailable(err) - } + NoData | UnsupportedLocalPlayBack | NotAllowedContext(_) => Error::unavailable(err), Ident(_) | InvalidUri(_) => Error::aborted(err), } } @@ -159,35 +155,21 @@ impl Spirc { session .dealer() .listen_for("hm://connect-state/v1/cluster")? - .map(|msg| -> Result { - match msg.payload { - PayloadValue::Raw(bytes) => ClusterUpdate::parse_from_bytes(&bytes) - .map_err(Error::failed_precondition), - other => Err(SpircError::UnexpectedData(other).into()), - } - }), + .map(|msg| msg.payload.into_message()), ); let connect_state_volume_update = Box::pin( session .dealer() .listen_for("hm://connect-state/v1/connect/volume")? - .map(|msg| match msg.payload { - PayloadValue::Raw(bytes) => SetVolumeCommand::parse_from_bytes(&bytes) - .map_err(Error::failed_precondition), - other => Err(SpircError::UnexpectedData(other).into()), - }), + .map(|msg| msg.payload.into_message()), ); let playlist_update = Box::pin( session .dealer() .listen_for("hm://playlist/v2/playlist/")? - .map(|msg| match msg.payload { - PayloadValue::Raw(bytes) => PlaylistModificationInfo::parse_from_bytes(&bytes) - .map_err(Error::failed_precondition), - other => Err(SpircError::UnexpectedData(other).into()), - }), + .map(|msg| msg.payload.into_message()), ); let connect_state_command = Box::pin( @@ -197,30 +179,19 @@ impl Spirc { .map(UnboundedReceiverStream::new)?, ); - // todo: remove later? probably have to find the equivalent for the dealer 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)?) - }), + .dealer() + .listen_for("spotify:user:attributes:update")? + .map(|msg| msg.payload.into_message()), ); - // todo: remove later? probably have to find the equivalent for the dealer + // can be trigger by toggling autoplay in a desktop client 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)?) - }), + .dealer() + .listen_for("spotify:user:attributes:mutated")? + .map(|msg| msg.payload.into_message()), ); // pre-acquire client_token, preventing multiple request while running diff --git a/core/src/dealer/mod.rs b/core/src/dealer/mod.rs index 5657e0e7b..9000fcc63 100644 --- a/core/src/dealer/mod.rs +++ b/core/src/dealer/mod.rs @@ -166,7 +166,7 @@ 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 { return None; diff --git a/core/src/dealer/protocol.rs b/core/src/dealer/protocol.rs index 7db5bcae4..ef343c870 100644 --- a/core/src/dealer/protocol.rs +++ b/core/src/dealer/protocol.rs @@ -26,13 +26,18 @@ enum ProtocolError { 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 { - Error::failed_precondition(err) + match err { + ProtocolError::UnexpectedData(_) => Error::unavailable(err), + _ => Error::failed_precondition(err), + } } } @@ -81,6 +86,17 @@ pub enum PayloadValue { Raw(Vec), } +impl PayloadValue { + pub fn into_message(self) -> Result { + match self { + PayloadValue::Raw(bytes) => { + M::parse_from_bytes(&bytes).map_err(Error::failed_precondition) + } + other => Err(ProtocolError::UnexpectedData(other).into()), + } + } +} + #[derive(Clone, Debug)] pub struct Message { pub headers: HashMap, From 7be55c96ec36f944bb7dc0799b6697c874d59498 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Wed, 20 Nov 2024 00:21:16 +0100 Subject: [PATCH 089/138] connect: fix 1.75 problems --- connect/src/model.rs | 4 ++-- connect/src/state/context.rs | 11 ++++++----- core/src/spclient.rs | 2 ++ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/connect/src/model.rs b/connect/src/model.rs index 80923cb8b..fb89d378f 100644 --- a/connect/src/model.rs +++ b/connect/src/model.rs @@ -37,9 +37,9 @@ impl ResolveContext { // 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("/") + rest.split('/') } else { - page_url.split("/") + page_url.split('/') }; let uri = split diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index ff1325da7..a32cc98b5 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -216,24 +216,25 @@ impl ConnectState { ) -> Result { let question_mark_idx = ctx_track .uri - .contains("?") + .contains('?') .then(|| ctx_track.uri.find('?')) .flatten(); let ctx_track_uri = if let Some(idx) = question_mark_idx { - &ctx_track.uri[..idx].to_string() + &ctx_track.uri[..idx] } else { &ctx_track.uri - }; + } + .to_string(); - let provider = if self.unavailable_uri.contains(ctx_track_uri) { + let provider = if self.unavailable_uri.contains(&ctx_track_uri) { Provider::Unavailable } else { provider.unwrap_or(Provider::Context) }; let id = if !ctx_track_uri.is_empty() { - SpotifyId::from_uri(ctx_track_uri) + SpotifyId::from_uri(&ctx_track_uri) } else if !ctx_track.gid.is_empty() { SpotifyId::from_raw(&ctx_track.gid) } else { diff --git a/core/src/spclient.rs b/core/src/spclient.rs index 8abccbe8a..fe78b282a 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -48,7 +48,9 @@ component! { 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"); #[derive(Debug, Error)] From abad15665e883b07c2b7ab748c70e8f29a410423 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 24 Nov 2024 12:14:32 +0100 Subject: [PATCH 090/138] connect: fill, instead of replace next page --- connect/src/model.rs | 25 ++++++++++++++----------- connect/src/spirc.rs | 12 ++++-------- connect/src/state/context.rs | 35 +++++++++++++++++++++++++---------- 3 files changed, 43 insertions(+), 29 deletions(-) diff --git a/connect/src/model.rs b/connect/src/model.rs index fb89d378f..d2e17247d 100644 --- a/connect/src/model.rs +++ b/connect/src/model.rs @@ -6,12 +6,12 @@ use std::fmt::{Display, Formatter}; pub struct ResolveContext { context: Context, autoplay: bool, - /// if `true` updates the entire context, otherwise only updates the context from the next + /// 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_all: bool, + update: bool, } impl ResolveContext { @@ -22,7 +22,7 @@ impl ResolveContext { ..Default::default() }, autoplay, - update_all: true, + update: true, } } @@ -30,7 +30,7 @@ impl ResolveContext { Self { context, autoplay, - update_all: true, + update: true, } } @@ -39,6 +39,7 @@ impl ResolveContext { 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('/') }; @@ -48,12 +49,14 @@ impl ResolveContext { .collect::>() .join(":"); + trace!("created an ResolveContext from page_url <{page_url}> as uri <{uri}>"); + Self { context: Context { uri, ..Default::default() }, - update_all: false, + update: false, autoplay: false, } } @@ -66,8 +69,8 @@ impl ResolveContext { self.autoplay } - pub fn update_all(&self) -> bool { - self.update_all + pub fn update(&self) -> bool { + self.update } } @@ -75,8 +78,8 @@ impl Display for ResolveContext { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!( f, - "uri: {}, autoplay: {}, update_all: {}", - self.context.uri, self.autoplay, self.update_all + "uri: {}, autoplay: {}, update: {}", + self.context.uri, self.autoplay, self.update ) } } @@ -85,9 +88,9 @@ impl PartialEq for ResolveContext { fn eq(&self, other: &Self) -> bool { let eq_context = self.context.uri == other.context.uri; let eq_autoplay = self.autoplay == other.autoplay; - let eq_update_all = self.update_all == other.update_all; + let eq_update = self.update == other.update; - eq_autoplay && eq_context && eq_update_all + eq_autoplay && eq_context && eq_update } } diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index c652ed302..709f67a27 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -443,7 +443,7 @@ impl SpircTask { // 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.autoplay(), resolve.update_all()) + .resolve_context(resolve.uri(), resolve.autoplay(), resolve.update()) .await { error!("failed resolving context <{resolve}>: {why}"); @@ -481,22 +481,18 @@ impl SpircTask { &mut self, context_uri: &str, autoplay: bool, - update_all: bool, + update: bool, ) -> Result<(), Error> { if !autoplay { match self.session.spclient().get_context(context_uri).await { Err(why) => error!("failed to resolve context '{context_uri}': {why}"), - Ok(ctx) if update_all => { + Ok(ctx) if update => { debug!("update entire context"); self.connect_state.update_context(ctx)? } Ok(mut ctx) 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.update_context_from_page( - ctx.pages.remove(0), - None, - None, - ); + self.connect_state.fill_context_from_page(ctx.pages.remove(0))?; } Ok(ctx) => error!("resolving context should only update the tracks, but had no page, or track. {ctx:#?}"), }; diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index a32cc98b5..75c385231 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -107,7 +107,7 @@ impl ConnectState { self.player.context_metadata.insert(key, value); } - self.update_context_from_page(page, Some(&context.uri), None); + self.context = Some(self.state_context_from_page(page, Some(&context.uri), None)); self.player.context_url = format!("context://{}", &context.uri); self.player.context_uri = context.uri; @@ -128,25 +128,26 @@ impl ConnectState { .ok_or(StateError::NoContext(ContextType::Autoplay))?; debug!("autoplay-context size: {}", page.tracks.len()); - self.update_context_from_page(page, Some(&context.uri), Some(Provider::Autoplay)); + self.autoplay_context = + Some(self.state_context_from_page(page, Some(&context.uri), Some(Provider::Autoplay))); Ok(()) } - pub fn update_context_from_page( + pub fn state_context_from_page( &mut self, page: ContextPage, new_context_uri: Option<&str>, provider: Option, - ) { + ) -> StateContext { let new_context_uri = new_context_uri.unwrap_or(&self.player.context_uri); debug!( "updated context from {} ({} tracks) to {} ({} tracks)", self.player.context_uri, self.context .as_ref() - .map(|c| c.tracks.len()) - .unwrap_or_default(), + .map(|c| c.tracks.len().to_string()) + .unwrap_or_else(|| "-".to_string()), new_context_uri, page.tracks.len() ); @@ -164,13 +165,13 @@ impl ConnectState { } } }) - .collect(); + .collect::>(); - self.context = Some(StateContext { + StateContext { tracks, metadata: page.metadata, index: ContextIndex::new(), - }) + } } pub fn merge_context(&mut self, context: Option) -> Option<()> { @@ -260,6 +261,20 @@ impl ConnectState { }) } + pub fn fill_context_from_page(&mut self, page: ContextPage) -> Result<(), Error> { + let context = self.state_context_from_page(page, 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), @@ -270,7 +285,7 @@ impl ConnectState { return Ok(LoadNext::PageUrl(next.page_url)); } - self.update_context_from_page(next, None, None); + self.fill_context_from_page(next)?; self.fill_up_next_tracks()?; Ok(LoadNext::Done) From 29954f81f6f9e4b51921ff0a25834a1941643e62 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 24 Nov 2024 12:38:07 +0100 Subject: [PATCH 091/138] connect: reduce context update to single method --- connect/src/spirc.rs | 8 ++--- connect/src/state/context.rs | 65 +++++++++++++++++------------------- 2 files changed, 35 insertions(+), 38 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 709f67a27..425b7f77a 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,5 +1,5 @@ use crate::model::{ResolveContext, SpircPlayStatus}; -use crate::state::context::{ContextType, LoadNext}; +use crate::state::context::{ContextType, LoadNext, UpdateContext}; use crate::state::provider::IsProvider; use crate::state::{ConnectState, ConnectStateConfig}; use crate::{ @@ -487,8 +487,7 @@ impl SpircTask { match self.session.spclient().get_context(context_uri).await { Err(why) => error!("failed to resolve context '{context_uri}': {why}"), Ok(ctx) if update => { - debug!("update entire context"); - self.connect_state.update_context(ctx)? + self.connect_state.update_context(ctx, UpdateContext::Default)? } Ok(mut ctx) 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()); @@ -528,7 +527,8 @@ impl SpircTask { .get_autoplay_context(&ctx_request) .await?; - self.connect_state.update_autoplay_context(context) + self.connect_state + .update_context(context, UpdateContext::Autoplay) } fn now_ms(&self) -> i64 { diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 75c385231..7330f3485 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -26,6 +26,12 @@ pub enum LoadNext { Empty, } +#[derive(Debug)] +pub enum UpdateContext { + Default, + Autoplay, +} + impl ConnectState { pub fn find_index_in_context bool>( context: Option<&StateContext>, @@ -70,9 +76,7 @@ impl ConnectState { self.update_restrictions() } - pub fn update_context(&mut self, context: Context) -> Result<(), Error> { - debug!("context: {}, {}", context.uri, context.url); - + pub fn update_context(&mut self, 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()); @@ -94,6 +98,17 @@ impl ConnectState { Some(p) => p, }; + debug!( + "updated context {ty:?} from {} ({} tracks) to {} ({} tracks)", + self.player.context_uri, + self.context + .as_ref() + .map(|c| c.tracks.len().to_string()) + .unwrap_or_else(|| "-".to_string()), + context.uri, + page.tracks.len() + ); + if context.restrictions.is_some() { self.player.restrictions = context.restrictions.clone(); self.player.context_restrictions = context.restrictions; @@ -107,7 +122,18 @@ impl ConnectState { self.player.context_metadata.insert(key, value); } - self.context = Some(self.state_context_from_page(page, Some(&context.uri), None)); + match ty { + UpdateContext::Default => { + self.context = Some(self.state_context_from_page(page, Some(&context.uri), None)) + } + UpdateContext::Autoplay => { + self.autoplay_context = Some(self.state_context_from_page( + page, + Some(&context.uri), + Some(Provider::Autoplay), + )) + } + } self.player.context_url = format!("context://{}", &context.uri); self.player.context_uri = context.uri; @@ -115,42 +141,13 @@ impl ConnectState { Ok(()) } - pub fn update_autoplay_context(&mut self, mut context: Context) -> Result<(), Error> { - debug!( - "autoplay-context: {}, pages: {}", - context.uri, - context.pages.len() - ); - - let page = context - .pages - .pop() - .ok_or(StateError::NoContext(ContextType::Autoplay))?; - debug!("autoplay-context size: {}", page.tracks.len()); - - self.autoplay_context = - Some(self.state_context_from_page(page, Some(&context.uri), Some(Provider::Autoplay))); - - Ok(()) - } - - pub fn state_context_from_page( + fn state_context_from_page( &mut self, page: ContextPage, new_context_uri: Option<&str>, provider: Option, ) -> StateContext { let new_context_uri = new_context_uri.unwrap_or(&self.player.context_uri); - debug!( - "updated context from {} ({} tracks) to {} ({} tracks)", - self.player.context_uri, - self.context - .as_ref() - .map(|c| c.tracks.len().to_string()) - .unwrap_or_else(|| "-".to_string()), - new_context_uri, - page.tracks.len() - ); let tracks = page .tracks From 7d36dd424e5ddbeea01d625658e0f1e94baad229 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 24 Nov 2024 13:01:32 +0100 Subject: [PATCH 092/138] connect: remove unused SpircError, handle local files --- connect/src/spirc.rs | 10 ++++------ connect/src/state.rs | 16 +++++++++++++--- connect/src/state/context.rs | 6 ++++++ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 425b7f77a..7e7908644 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -37,22 +37,20 @@ pub use crate::model::{PlayingTrack, SpircLoadCommand}; 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(ResolveContext), + #[error("failed to put connect state for new device")] + FailedDealerSetup, } impl From for Error { fn from(err: SpircError) -> Self { use SpircError::*; match err { - NoData | UnsupportedLocalPlayBack | NotAllowedContext(_) => Error::unavailable(err), - Ident(_) | InvalidUri(_) => Error::aborted(err), + NoData | NotAllowedContext(_) => Error::unavailable(err), + InvalidUri(_) | FailedDealerSetup => Error::aborted(err), } } } diff --git a/connect/src/state.rs b/connect/src/state.rs index 9ec75bab5..ff4b5f37e 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -48,11 +48,21 @@ pub enum StateError { CurrentlyDisallowed { action: String, reason: String }, #[error("the provided context has no tracks")] ContextHasNoTracks, + #[error("playback of local files is not supported")] + UnsupportedLocalPlayBack, } impl From for Error { fn from(err: StateError) -> Self { - Error::failed_precondition(err) + use StateError::*; + match err { + CouldNotResolveTrackFromTransfer + | MessageFieldNone(_) + | NoContext(_) + | CanNotFindTrackInContext(_, _) + | ContextHasNoTracks => Error::failed_precondition(err), + CurrentlyDisallowed { .. } | UnsupportedLocalPlayBack => Error::unavailable(err), + } } } @@ -382,13 +392,13 @@ impl ConnectState { let now = SystemTime::now(); let since_the_epoch = now.duration_since(UNIX_EPOCH).expect("Time went backwards"); - let client_side_timestamp = u64::try_from(since_the_epoch.as_millis())?; + let client_side_timestamp = u64::try_from(since_the_epoch.as_millis())?; let member_type = EnumOrUnknown::new(MemberType::CONNECT_STATE); let put_state_reason = EnumOrUnknown::new(reason); - // we copy the player state, which only contains the infos, not the next and prev tracks let mut player_state = self.player.clone(); + // we copy the player state, which only contains the infos, not the next and prev tracks // cloning seems to be fine, because the cloned lists get dropped after the method call player_state.next_tracks = self.next_tracks.clone().into(); player_state.prev_tracks = self.prev_tracks.clone().into(); diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 7330f3485..01c0c1d99 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -212,6 +212,12 @@ impl ConnectState { context_uri: Option<&str>, provider: Option, ) -> Result { + // completely ignore local playback. + if matches!(context_uri, Some(context_uri) if context_uri.starts_with("spotify:local-files")) + { + return Err(StateError::UnsupportedLocalPlayBack.into()); + } + let question_mark_idx = ctx_track .uri .contains('?') From d86f219bd5499bab101fb6a5b62ff65a7b197b42 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 24 Nov 2024 13:02:37 +0100 Subject: [PATCH 093/138] connect: reduce nesting, adjust initial transfer handling --- connect/src/spirc.rs | 51 +++++++++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 7e7908644..581fb3b0a 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -378,7 +378,10 @@ impl SpircTask { }, 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).await, + Ok(connection_id) => if let Err(why) = self.handle_connection_id_update(connection_id).await { + error!("failed handling connection id update: {why}"); + break; + }, Err(e) => error!("could not parse connection ID update: {}", e), } None => { @@ -751,11 +754,11 @@ impl SpircTask { } } - async 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 response = match self + let cluster = match self .connect_state .update_state(&self.session, PutStateReason::NEW_DEVICE) .await @@ -765,31 +768,35 @@ impl SpircTask { error!("{why:?}"); None } - }; + } + .ok_or(SpircError::FailedDealerSetup)?; + + debug!( + "successfully put connect state for {} with connection-id {connection_id}", + self.session.device_id() + ); + + if !cluster.active_device_id.is_empty() { + info!("active device is <{}>", cluster.active_device_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") + } // 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 - if let Some(cluster) = response { - if !cluster.transfer_data.is_empty() { - if let Ok(transfer_state) = TransferState::parse_from_bytes(&cluster.transfer_data) - { - if !transfer_state.current_session.context.pages.is_empty() { - info!("received transfer state with context, trying to take over control again"); - match self.handle_transfer(transfer_state) { - Ok(_) => info!("successfully re-acquired control"), - Err(why) => error!("failed handling transfer state: {why}"), - } - } - } + if let Ok(transfer_state) = TransferState::parse_from_bytes(&cluster.transfer_data) { + if !transfer_state.current_session.context.pages.is_empty() { + info!("received transfer state with context, trying to take over control again"); + self.handle_transfer(transfer_state)? } - - debug!( - "successfully put connect state for {} with connection-id {connection_id}", - self.session.device_id() - ); - info!("active device is {:?}", cluster.active_device_id); } + + Ok(()) } fn handle_user_attributes_update(&mut self, update: UserAttributesUpdate) { From 209146aed65afbc98a490047709a70fe4afea484 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 24 Nov 2024 13:13:07 +0100 Subject: [PATCH 094/138] connect: don't update volume initially --- connect/src/spirc.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 581fb3b0a..eb9039f10 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -240,7 +240,12 @@ impl Spirc { task.connect_state.device.volume = 0; match initial_volume.try_into() { - Ok(volume) => task.set_volume(volume), + 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}"), }; @@ -858,7 +863,10 @@ impl SpircTask { let reason = cluster_update.update_reason.enum_value().ok(); let device_ids = cluster_update.devices_that_changed.join(", "); - debug!("cluster update: {reason:?} from {device_ids}"); + 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 = From b5530bb9c218d929e9a53bed7bc61d9cc3048773 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 24 Nov 2024 15:41:24 +0100 Subject: [PATCH 095/138] core: disable trace logging of handled mercury responses --- core/src/dealer/manager.rs | 12 ++++++++++++ core/src/dealer/maps.rs | 23 +++++++++++++++++++++-- core/src/dealer/mod.rs | 27 +++++++++++++++++++++++++++ core/src/mercury/mod.rs | 9 ++++++--- 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/core/src/dealer/manager.rs b/core/src/dealer/manager.rs index 43ce13e17..2fd3e1624 100644 --- a/core/src/dealer/manager.rs +++ b/core/src/dealer/manager.rs @@ -113,6 +113,18 @@ impl DealerManager { }) } + 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"); diff --git a/core/src/dealer/maps.rs b/core/src/dealer/maps.rs index 91388a59e..23d21a115 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() } diff --git a/core/src/dealer/mod.rs b/core/src/dealer/mod.rs index 9000fcc63..abcba3e53 100644 --- a/core/src/dealer/mod.rs +++ b/core/src/dealer/mod.rs @@ -234,6 +234,21 @@ 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)] struct Builder { message_handlers: SubscriberMap, @@ -277,6 +292,10 @@ 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, @@ -416,6 +435,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"); diff --git a/core/src/mercury/mod.rs b/core/src/mercury/mod.rs index 7fde9b7fd..76b060a39 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)?; From 27c0ab6cf3811b81b5df578f7cccae37577dd136 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 24 Nov 2024 17:42:19 +0100 Subject: [PATCH 096/138] core/connect: prevent takeover from other clients, handle session-update --- connect/src/spirc.rs | 143 ++++++++++++++++++++++++++++-------- connect/src/state.rs | 18 ++++- core/src/dealer/mod.rs | 2 + core/src/dealer/protocol.rs | 46 ++++++++---- core/src/session.rs | 45 +++++++----- protocol/build.rs | 1 + 6 files changed, 188 insertions(+), 67 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index eb9039f10..68cea3584 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,25 +1,38 @@ -use crate::model::{ResolveContext, SpircPlayStatus}; -use crate::state::context::{ContextType, LoadNext, UpdateContext}; -use crate::state::provider::IsProvider; -use crate::state::{ConnectState, ConnectStateConfig}; +pub use crate::model::{PlayingTrack, SpircLoadCommand}; use crate::{ - core::{authentication::Credentials, session::UserAttributes, Error, Session, SpotifyId}, + core::{ + authentication::Credentials, + dealer::{ + manager::{Reply, RequestReply}, + protocol::{Message, RequestCommand}, + }, + session::UserAttributes, + Error, Session, SpotifyId, + }, playback::{ mixer::Mixer, player::{Player, PlayerEvent, PlayerEventChannel}, }, protocol::{ - explicit_content_pubsub::UserAttributesUpdate, user_attributes::UserAttributesMutation, + autoplay_context_request::AutoplayContextRequest, + connect::{Cluster, ClusterUpdate, PutStateReason, SetVolumeCommand}, + explicit_content_pubsub::UserAttributesUpdate, + 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::{Stream, StreamExt}; -use librespot_core::dealer::manager::{Reply, RequestReply}; -use librespot_core::dealer::protocol::RequestCommand; -use librespot_protocol::autoplay_context_request::AutoplayContextRequest; -use librespot_protocol::connect::{Cluster, ClusterUpdate, PutStateReason, SetVolumeCommand}; -use librespot_protocol::player::{Context, TransferState}; -use librespot_protocol::playlist4_external::PlaylistModificationInfo; -use protobuf::{Message, MessageField}; +use protobuf::MessageField; use std::{ future::Future, pin::Pin, @@ -31,8 +44,6 @@ use thiserror::Error; use tokio::{sync::mpsc, time::sleep}; use tokio_stream::wrappers::UnboundedReceiverStream; -pub use crate::model::{PlayingTrack, SpircLoadCommand}; - #[derive(Debug, Error)] pub enum SpircError { #[error("response payload empty")] @@ -56,6 +67,7 @@ impl From for Error { } type BoxedStream = Pin + Send>>; +type BoxedStreamResult = BoxedStream>; struct SpircTask { player: Arc, @@ -66,13 +78,14 @@ struct SpircTask { play_request_id: Option, play_status: SpircPlayStatus, - connection_id_update: BoxedStream>, - connect_state_update: BoxedStream>, - connect_state_volume_update: BoxedStream>, - playlist_update: BoxedStream>, + connection_id_update: BoxedStreamResult, + connect_state_update: BoxedStreamResult, + connect_state_volume_update: BoxedStreamResult, + playlist_update: BoxedStreamResult, + session_update: BoxedStreamResult, connect_state_command: BoxedStream, - user_attributes_update: BoxedStream>, - user_attributes_mutation: BoxedStream>, + user_attributes_update: BoxedStreamResult, + user_attributes_mutation: BoxedStreamResult, commands: Option>, player_events: Option, @@ -153,21 +166,28 @@ impl Spirc { session .dealer() .listen_for("hm://connect-state/v1/cluster")? - .map(|msg| msg.payload.into_message()), + .map(Message::from_raw), ); let connect_state_volume_update = Box::pin( session .dealer() .listen_for("hm://connect-state/v1/connect/volume")? - .map(|msg| msg.payload.into_message()), + .map(Message::from_raw), ); let playlist_update = Box::pin( session .dealer() .listen_for("hm://playlist/v2/playlist/")? - .map(|msg| msg.payload.into_message()), + .map(Message::from_raw), + ); + + let session_update = Box::pin( + session + .dealer() + .listen_for("social-connect/v2/session_update")? + .map(Message::from_json), ); let connect_state_command = Box::pin( @@ -181,7 +201,7 @@ impl Spirc { session .dealer() .listen_for("spotify:user:attributes:update")? - .map(|msg| msg.payload.into_message()), + .map(Message::from_raw), ); // can be trigger by toggling autoplay in a desktop client @@ -189,7 +209,7 @@ impl Spirc { session .dealer() .listen_for("spotify:user:attributes:mutated")? - .map(|msg| msg.payload.into_message()), + .map(Message::from_raw), ); // pre-acquire client_token, preventing multiple request while running @@ -218,6 +238,7 @@ impl Spirc { connect_state_update, connect_state_volume_update, playlist_update, + session_update, connect_state_command, user_attributes_update, user_attributes_mutation, @@ -394,6 +415,16 @@ impl SpircTask { break; } }, + session_update = self.session_update.next() => match session_update { + Some(result) => match result { + Ok(session_update) => self.handle_session_update(session_update), + Err(e) => error!("could not parse session update: {}", e), + } + None => { + error!("session update selected, but none received"); + break; + } + }, cmd = async { commands?.recv().await }, if commands.is_some() => if let Some(cmd) = cmd { if let Err(e) = self.handle_command(cmd).await { debug!("could not dispatch command: {}", e); @@ -428,7 +459,7 @@ impl SpircTask { } } - if !self.shutdown { + if !self.shutdown && self.connect_state.active { if let Err(why) = self.notify().await { warn!("notify before unexpected shutdown couldn't be send: {why}") } @@ -781,16 +812,24 @@ impl SpircTask { self.session.device_id() ); - if !cluster.active_device_id.is_empty() { - info!("active device is <{}>", cluster.active_device_id); + if !cluster.active_device_id.is_empty() || !cluster.player_state.session_id.is_empty() { + 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") + 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 @@ -860,7 +899,7 @@ impl SpircTask { &mut self, mut cluster_update: ClusterUpdate, ) -> Result<(), Error> { - let reason = cluster_update.update_reason.enum_value().ok(); + let reason = cluster_update.update_reason.enum_value(); let device_ids = cluster_update.devices_that_changed.join(", "); debug!( @@ -1451,6 +1490,48 @@ impl SpircTask { 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 { match self.play_status { SpircPlayStatus::Stopped => 0, diff --git a/connect/src/state.rs b/connect/src/state.rs index ff4b5f37e..86410f73b 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -68,6 +68,7 @@ impl From for Error { #[derive(Debug, Clone)] pub struct ConnectStateConfig { + pub session_id: String, pub initial_volume: u32, pub name: String, pub device_type: DeviceType, @@ -79,6 +80,7 @@ pub struct ConnectStateConfig { 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, @@ -91,6 +93,7 @@ impl Default for ConnectStateConfig { #[derive(Default, Debug)] pub struct ConnectState { + pub session_id: String, pub active: bool, pub active_since: Option, @@ -131,6 +134,7 @@ pub struct ConnectState { impl ConnectState { pub fn new(cfg: ConnectStateConfig, session: &Session) -> Self { let mut state = Self { + session_id: cfg.session_id, device: DeviceInfo { can_play: true, volume: cfg.initial_volume, @@ -188,6 +192,7 @@ impl ConnectState { self.queue_count = 0; self.player = PlayerState { + session_id: self.session_id.clone(), is_system_initiated: true, playback_speed: 1., play_origin: MessageField::some(PlayOrigin::new()), @@ -211,6 +216,15 @@ impl ConnectState { } } + pub fn set_origin(&mut self, origin: PlayOrigin) { + self.player.play_origin = MessageField::some(origin) + } + + pub fn set_session_id(&mut self, session_id: String) { + self.session_id = session_id.clone(); + self.player.session_id = session_id; + } + pub(crate) fn set_status(&mut self, status: &SpircPlayStatus) { self.player.is_paused = matches!( status, @@ -378,10 +392,6 @@ impl ConnectState { self.player.timestamp = timestamp; } - pub fn set_origin(&mut self, origin: PlayOrigin) { - self.player.play_origin = MessageField::some(origin) - } - /// Updates the connect state for the connect session /// /// Prepares a [PutStateRequest] from the current connect state diff --git a/core/src/dealer/mod.rs b/core/src/dealer/mod.rs index abcba3e53..d5bff5b1e 100644 --- a/core/src/dealer/mod.rs +++ b/core/src/dealer/mod.rs @@ -168,6 +168,8 @@ fn split_uri(s: &str) -> Option> { ("hm", '/', rest) } else if let Some(rest) = s.strip_prefix("spotify:") { ("spotify", ':', rest) + } else if s.contains('/') { + ("", '/', s) } else { return None; }; diff --git a/core/src/dealer/protocol.rs b/core/src/dealer/protocol.rs index ef343c870..858a10e7c 100644 --- a/core/src/dealer/protocol.rs +++ b/core/src/dealer/protocol.rs @@ -14,6 +14,11 @@ use serde::Deserialize; use serde_json::Error as SerdeError; use thiserror::Error; +const IGNORE_UNKNOWN: protobuf_json_mapping::ParseOptions = protobuf_json_mapping::ParseOptions { + ignore_unknown_fields: true, + _future_options: (), +}; + type JsonValue = serde_json::Value; #[derive(Debug, Error)] @@ -84,11 +89,33 @@ pub(super) enum MessageOrRequest { pub enum PayloadValue { Empty, Raw(Vec), + Json(String), +} + +#[derive(Clone, Debug)] +pub struct Message { + pub headers: HashMap, + pub payload: PayloadValue, + pub uri: String, } -impl PayloadValue { - pub fn into_message(self) -> Result { - match self { +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) } @@ -97,13 +124,6 @@ impl PayloadValue { } } -#[derive(Clone, Debug)] -pub struct Message { - pub headers: HashMap, - pub payload: PayloadValue, - pub uri: String, -} - impl WebsocketMessage { pub fn handle_payload(&mut self) -> Result { if self.payloads.is_empty() { @@ -118,11 +138,7 @@ impl WebsocketMessage { .decode(string) .map_err(ProtocolError::Base64)?, MessagePayloadValue::Bytes(bytes) => bytes, - MessagePayloadValue::Json(json) => { - return Err(Error::unimplemented(format!( - "Received unknown data from websocket message: {json:?}" - ))) - } + MessagePayloadValue::Json(json) => return Ok(PayloadValue::Json(json.to_string())), }; handle_transfer_encoding(&self.headers, bytes).map(PayloadValue::Raw) diff --git a/core/src/session.rs b/core/src/session.rs index 3ca3e9335..45d54bc6d 100644 --- a/core/src/session.rs +++ b/core/src/session.rs @@ -9,23 +9,6 @@ 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}, @@ -44,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 { @@ -79,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, @@ -130,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() }; @@ -382,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/protocol/build.rs b/protocol/build.rs index fb1e4c839..43971bc84 100644 --- a/protocol/build.rs +++ b/protocol/build.rs @@ -38,6 +38,7 @@ fn compile() { 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"), From 232fb600f0d6f856eb9f0e3d1643591f30e81f47 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 24 Nov 2024 21:08:37 +0100 Subject: [PATCH 097/138] connect: add queue-uid for set_queue command --- connect/src/state/tracks.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index 70885bb30..d8c15988a 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -206,7 +206,13 @@ impl<'ct> ConnectState { tracks .iter_mut() .filter(|t| t.metadata.contains_key(METADATA_IS_QUEUED)) - .for_each(|t| t.set_provider(Provider::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.next_tracks = tracks.into(); } From dab0948aaaeeb4fd6efdf500398a102c44cdbf09 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 24 Nov 2024 21:27:50 +0100 Subject: [PATCH 098/138] connect: adjust fields for PlayCommand --- connect/src/model.rs | 1 + connect/src/spirc.rs | 3 ++- core/src/dealer/protocol/request.rs | 4 +++- examples/play_connect.rs | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/connect/src/model.rs b/connect/src/model.rs index d2e17247d..5872382b4 100644 --- a/connect/src/model.rs +++ b/connect/src/model.rs @@ -105,6 +105,7 @@ 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, diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 68cea3584..f0277ea02 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -975,6 +975,7 @@ impl SpircTask { 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, @@ -1235,7 +1236,7 @@ impl SpircTask { self.connect_state.set_repeat_track(cmd.repeat_track); if self.connect_state.current_track(MessageField::is_some) { - self.load_track(cmd.start_playing, 0)?; + self.load_track(cmd.start_playing, cmd.seek_to)?; } else { info!("No active track, stopping"); self.handle_stop(); diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index 2248c8f98..85426a305 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -168,7 +168,9 @@ pub struct PlayOptions { pub skip_to: SkipTo, #[serde(default, deserialize_with = "option_json_proto")] pub player_options_override: Option, - pub license: String, + pub license: Option, + // possible to send wie web-api + pub seek_to: Option, // mobile pub always_play_something: Option, pub audio_stream: Option, diff --git a/examples/play_connect.rs b/examples/play_connect.rs index 9bdcf9a9c..9a033da23 100644 --- a/examples/play_connect.rs +++ b/examples/play_connect.rs @@ -79,6 +79,7 @@ async fn main() { .load(SpircLoadCommand { context_uri, start_playing: true, + seek_to: 0, shuffle: false, repeat: false, repeat_track: false, From 45f2a4222a5c004c58ba92179062e61cf4cc90e9 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 24 Nov 2024 22:09:14 +0100 Subject: [PATCH 099/138] connect: preserve context position after update_context --- connect/src/spirc.rs | 9 ++++++--- connect/src/state/context.rs | 39 ++++++++++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index f0277ea02..c23439f86 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -524,7 +524,10 @@ impl SpircTask { match self.session.spclient().get_context(context_uri).await { Err(why) => error!("failed to resolve context '{context_uri}': {why}"), Ok(ctx) if update => { - self.connect_state.update_context(ctx, UpdateContext::Default)? + if let Err(why) = self.connect_state.update_context(ctx, UpdateContext::Default) { + error!("failed loading context: {why}"); + self.handle_stop() + } } Ok(mut ctx) 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()); @@ -1036,8 +1039,8 @@ impl SpircTask { RequestCommand::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 + "ignoring context update for <{}>, because it isn't the current context <{}>", + update_context.context.uri, self.connect_state.context_uri() ) } else { self.resolve_context diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 01c0c1d99..6b7b9de19 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -98,11 +98,15 @@ impl ConnectState { 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.player.context_uri, - self.context - .as_ref() + prev_context .map(|c| c.tracks.len().to_string()) .unwrap_or_else(|| "-".to_string()), context.uri, @@ -124,7 +128,33 @@ impl ConnectState { match ty { UpdateContext::Default => { - self.context = Some(self.state_context_from_page(page, Some(&context.uri), None)) + let mut new_context = self.state_context_from_page(page, 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.player.context_uri == context.uri { + match Self::find_index_in_context(Some(&new_context), |t| { + self.player.track.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(_) => { + warn!("current tracks was removed, setting pos to last known index"); + new_context.index.track = self.player.index.track + } + } + // enforce reloading the context + self.clear_next_tracks(true); + self.active_context = ContextType::Default; + } + + self.context = Some(new_context); + + self.player.context_url = format!("context://{}", &context.uri); + self.player.context_uri = context.uri; } UpdateContext::Autoplay => { self.autoplay_context = Some(self.state_context_from_page( @@ -135,9 +165,6 @@ impl ConnectState { } } - self.player.context_url = format!("context://{}", &context.uri); - self.player.context_uri = context.uri; - Ok(()) } From f3ed11c5f38370e21674ac25b67a309d97e741c7 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 24 Nov 2024 22:48:08 +0100 Subject: [PATCH 100/138] connect: unify metadata modification - only handle `is_queued` `true` items for queue --- connect/src/state.rs | 16 +++++++-------- connect/src/state/consts.rs | 7 ------- connect/src/state/context.rs | 20 ++++++++++--------- connect/src/state/metadata.rs | 37 +++++++++++++++++++++++++++++++++++ connect/src/state/provider.rs | 4 ++-- connect/src/state/tracks.rs | 26 ++++++++++++------------ connect/src/state/transfer.rs | 2 +- 7 files changed, 71 insertions(+), 41 deletions(-) delete mode 100644 connect/src/state/consts.rs create mode 100644 connect/src/state/metadata.rs diff --git a/connect/src/state.rs b/connect/src/state.rs index 86410f73b..16d466073 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -1,6 +1,6 @@ -mod consts; pub(super) mod context; mod handle; +mod metadata; mod options; pub(super) mod provider; mod restrictions; @@ -8,8 +8,8 @@ mod tracks; mod transfer; use crate::model::SpircPlayStatus; -use crate::state::consts::{METADATA_CONTEXT_URI, METADATA_IS_QUEUED}; use crate::state::context::{ContextType, StateContext}; +use crate::state::metadata::Metadata; use crate::state::provider::{IsProvider, Provider}; use librespot_core::config::DeviceType; use librespot_core::date::Date; @@ -279,7 +279,7 @@ impl ConnectState { debug!("reset playback state to {new_index}"); - if !self.player.track.is_queued() { + if !self.player.track.is_queue() { self.set_current_track(new_index)?; } @@ -313,14 +313,12 @@ impl ConnectState { self.queue_count += 1; track.set_provider(Provider::Queue); - if !track.metadata.contains_key(METADATA_IS_QUEUED) { - track - .metadata - .insert(METADATA_IS_QUEUED.to_string(), true.to_string()); + if !track.is_queued() { + track.add_queued(); } if let Some(next_not_queued_track) = - self.next_tracks.iter().position(|track| !track.is_queued()) + self.next_tracks.iter().position(|track| !track.is_queue()) { self.next_tracks.insert(next_not_queued_track, track); } else { @@ -413,7 +411,7 @@ impl ConnectState { player_state.next_tracks = self.next_tracks.clone().into(); player_state.prev_tracks = self.prev_tracks.clone().into(); - if let Some(context_uri) = player_state.track.metadata.get(METADATA_CONTEXT_URI) { + if let Some(context_uri) = player_state.track.get_context_uri() { player_state.context_uri = context_uri.to_owned(); player_state.context_url = format!("context://{context_uri}"); } diff --git a/connect/src/state/consts.rs b/connect/src/state/consts.rs deleted file mode 100644 index 8830755e6..000000000 --- a/connect/src/state/consts.rs +++ /dev/null @@ -1,7 +0,0 @@ -// identifier used as part of the uid -pub const IDENTIFIER_DELIMITER: &str = "delimiter"; - -// metadata entries -pub const METADATA_CONTEXT_URI: &str = "context_uri"; -pub const METADATA_ENTITY_URI: &str = "entity_uri"; -pub const METADATA_IS_QUEUED: &str = "is_queued"; diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 6b7b9de19..7c0e58007 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -1,6 +1,6 @@ -use crate::state::consts::METADATA_ENTITY_URI; +use crate::state::metadata::Metadata; use crate::state::provider::Provider; -use crate::state::{ConnectState, StateError, METADATA_CONTEXT_URI}; +use crate::state::{ConnectState, StateError}; use librespot_core::{Error, SpotifyId}; use librespot_protocol::player::{Context, ContextIndex, ContextPage, ContextTrack, ProvidedTrack}; use std::collections::HashMap; @@ -273,22 +273,24 @@ impl ConnectState { }?; let mut metadata = HashMap::new(); - if let Some(context_uri) = context_uri { - metadata.insert(METADATA_CONTEXT_URI.to_string(), context_uri.to_string()); - metadata.insert(METADATA_ENTITY_URI.to_string(), context_uri.to_string()); - } - for (k, v) in &ctx_track.metadata { metadata.insert(k.to_string(), v.to_string()); } - Ok(ProvidedTrack { + let mut track = ProvidedTrack { uri: id.to_uri()?.replace("unknown", "track"), uid: ctx_track.uid.clone(), metadata, provider: provider.to_string(), ..Default::default() - }) + }; + + if let Some(context_uri) = context_uri { + track.add_context_uri(context_uri.to_string()); + track.add_entity_uri(context_uri.to_string()); + } + + Ok(track) } pub fn fill_context_from_page(&mut self, page: ContextPage) -> Result<(), Error> { diff --git a/connect/src/state/metadata.rs b/connect/src/state/metadata.rs new file mode 100644 index 000000000..1caedff27 --- /dev/null +++ b/connect/src/state/metadata.rs @@ -0,0 +1,37 @@ +use librespot_protocol::player::ProvidedTrack; + +const CONTEXT_URI: &str = "context_uri"; +const ENTITY_URI: &str = "entity_uri"; +const IS_QUEUED: &str = "is_queued"; + +pub trait Metadata { + fn is_queued(&self) -> bool; + fn get_context_uri(&self) -> Option<&String>; + + fn add_queued(&mut self); + fn add_context_uri(&mut self, uri: String); + fn add_entity_uri(&mut self, uri: String); +} + +impl Metadata for ProvidedTrack { + fn is_queued(&self) -> bool { + matches!(self.metadata.get(IS_QUEUED), Some(is_queued) if is_queued.eq("true")) + } + + fn get_context_uri(&self) -> Option<&String> { + self.metadata.get(CONTEXT_URI) + } + + fn add_queued(&mut self) { + self.metadata + .insert(IS_QUEUED.to_string(), true.to_string()); + } + + fn add_context_uri(&mut self, uri: String) { + self.metadata.insert(CONTEXT_URI.to_string(), uri); + } + + fn add_entity_uri(&mut self, uri: String) { + self.metadata.insert(ENTITY_URI.to_string(), uri); + } +} diff --git a/connect/src/state/provider.rs b/connect/src/state/provider.rs index e1c3fe6f8..97eb7aa4e 100644 --- a/connect/src/state/provider.rs +++ b/connect/src/state/provider.rs @@ -37,7 +37,7 @@ impl Display for Provider { pub trait IsProvider { fn is_autoplay(&self) -> bool; fn is_context(&self) -> bool; - fn is_queued(&self) -> bool; + fn is_queue(&self) -> bool; fn is_unavailable(&self) -> bool; fn set_provider(&mut self, provider: Provider); @@ -52,7 +52,7 @@ impl IsProvider for ProvidedTrack { self.provider == PROVIDER_CONTEXT } - fn is_queued(&self) -> bool { + fn is_queue(&self) -> bool { self.provider == PROVIDER_QUEUE } diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index d8c15988a..76e8fcad3 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -1,5 +1,5 @@ -use crate::state::consts::{IDENTIFIER_DELIMITER, METADATA_IS_QUEUED}; use crate::state::context::ContextType; +use crate::state::metadata::Metadata; use crate::state::provider::{IsProvider, Provider}; use crate::state::{ ConnectState, StateError, SPOTIFY_MAX_NEXT_TRACKS_SIZE, SPOTIFY_MAX_PREV_TRACKS_SIZE, @@ -9,6 +9,9 @@ use librespot_protocol::player::ProvidedTrack; use protobuf::MessageField; use std::collections::{HashMap, VecDeque}; +// identifier used as part of the uid +pub const IDENTIFIER_DELIMITER: &str = "delimiter"; + impl<'ct> ConnectState { fn new_delimiter(iteration: i64) -> ProvidedTrack { const HIDDEN: &str = "hidden"; @@ -96,7 +99,7 @@ impl<'ct> ConnectState { self.fill_up_next_tracks()?; - let is_queue_or_autoplay = new_track.is_queued() || new_track.is_autoplay(); + let is_queue_or_autoplay = new_track.is_queue() || new_track.is_autoplay(); let update_index = if is_queue_or_autoplay && self.player.index.is_some() { // the index isn't send when we are a queued track, but we have to preserve it for later self.player_index = self.player.index.take(); @@ -203,16 +206,13 @@ impl<'ct> ConnectState { // 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.metadata.contains_key(METADATA_IS_QUEUED)) - .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; - }); + tracks.iter_mut().filter(|t| t.is_queued()).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.next_tracks = tracks.into(); } @@ -232,7 +232,7 @@ impl<'ct> ConnectState { .next_tracks .iter() .enumerate() - .find(|(_, track)| !track.is_queued()); + .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.pop_back().is_some() diff --git a/connect/src/state/transfer.rs b/connect/src/state/transfer.rs index 32d5c9de3..0168473b2 100644 --- a/connect/src/state/transfer.rs +++ b/connect/src/state/transfer.rs @@ -76,7 +76,7 @@ impl ConnectState { let ctx = self.get_current_context().ok(); - let current_index = if track.is_queued() { + 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 { From dee269a253dbb2cf5de429ffad05b548fb015c33 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 26 Nov 2024 21:13:07 +0100 Subject: [PATCH 101/138] connect: polish request command handling - reply to all request endpoints - adjust some naming - add some docs --- connect/src/spirc.rs | 160 +++++++++++++--------------- core/src/dealer/protocol/request.rs | 43 ++++---- 2 files changed, 98 insertions(+), 105 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index c23439f86..3d1efb311 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -4,7 +4,7 @@ use crate::{ authentication::Credentials, dealer::{ manager::{Reply, RequestReply}, - protocol::{Message, RequestCommand}, + protocol::{Command, Message, Request}, }, session::UserAttributes, Error, Session, SpotifyId, @@ -54,6 +54,8 @@ pub enum SpircError { NotAllowedContext(ResolveContext), #[error("failed to put connect state for new device")] FailedDealerSetup, + #[error("unknown endpoint: {0:#?}")] + UnknownEndpoint(serde_json::Value), } impl From for Error { @@ -62,6 +64,7 @@ impl From for Error { match err { NoData | NotAllowedContext(_) => Error::unavailable(err), InvalidUri(_) | FailedDealerSetup => Error::aborted(err), + UnknownEndpoint(_) => Error::unimplemented(err), } } } @@ -334,6 +337,7 @@ impl SpircTask { let commands = self.commands.as_mut(); let player_events = self.player_events.as_mut(); tokio::select! { + // main dealer update of any remote device updates cluster_update = self.connect_state_update.next() => match cluster_update { Some(result) => match result { Ok(cluster_update) => { @@ -348,6 +352,17 @@ impl SpircTask { break; } }, + // main dealer request handling (dealer expects an answer) + request = self.connect_state_command.next() => match request { + Some(request) => if let Err(e) = self.handle_connect_state_request(request).await { + error!("couldn't handle connect state command: {}", e); + }, + None => { + error!("connect state command selected, but none received"); + break; + } + }, + // volume request handling is send separately (it's more like a fire forget) volume_update = self.connect_state_volume_update.next() => match volume_update { Some(result) => match result { Ok(volume_update) => match volume_update.volume.try_into() { @@ -373,15 +388,6 @@ impl SpircTask { break; } }, - connect_state_command = self.connect_state_command.next() => match connect_state_command { - Some(request) => if let Err(e) = self.handle_connect_state_command(request).await { - error!("couldn't handle connect state command: {}", e); - }, - None => { - error!("connect state command 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), @@ -932,7 +938,7 @@ impl SpircTask { Ok(()) } - async fn handle_connect_state_command( + async fn handle_connect_state_request( &mut self, (request, sender): RequestReply, ) -> Result<(), Error> { @@ -943,18 +949,45 @@ impl SpircTask { request.command, request.sent_by_device_id ); - let response = match request.command { - RequestCommand::Transfer(transfer) if transfer.data.is_some() => { - self.handle_transfer(transfer.data.expect("by condition checked"))?; - self.notify().await?; - - Reply::Success + let response = match self.handle_request(request).await { + Ok(_) => Reply::Success, + Err(why) => { + error!("failed to handle request: {why}"); + Reply::Failure } - RequestCommand::Transfer(_) => { + }; + + 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"); - Reply::Failure + Err(SpircError::NoData)? } - RequestCommand::Play(play) => { + 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.resolve_context + .push(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 @@ -988,43 +1021,24 @@ impl SpircTask { ) .await?; - self.connect_state.set_origin(play.play_origin); - - self.notify().await.map(|_| Reply::Success)? + self.connect_state.set_origin(play.play_origin) } - RequestCommand::Pause(_) => { - self.handle_pause(); - self.notify().await.map(|_| Reply::Success)? - } - RequestCommand::SeekTo(seek_to) => { + 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); - self.notify().await.map(|_| Reply::Success)? - } - RequestCommand::SetShufflingContext(shuffle) => { - self.connect_state.handle_shuffle(shuffle.value)?; - self.notify().await.map(|_| Reply::Success)? - } - RequestCommand::SetRepeatingContext(repeat_context) => { - self.connect_state - .handle_set_repeat(Some(repeat_context.value), None)?; - self.notify().await.map(|_| Reply::Success)? + self.handle_seek(seek_to.value) } - RequestCommand::SetRepeatingTrack(repeat_track) => { - self.connect_state - .handle_set_repeat(None, Some(repeat_track.value))?; - self.notify().await.map(|_| Reply::Success)? - } - RequestCommand::AddToQueue(add_to_queue) => { - self.connect_state.add_to_queue(add_to_queue.track, true); - self.notify().await.map(|_| Reply::Success)? - } - RequestCommand::SetQueue(set_queue) => { - self.connect_state.handle_set_queue(set_queue); - self.notify().await.map(|_| Reply::Success)? - } - RequestCommand::SetOptions(set_options) => { + 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)?; @@ -1033,41 +1047,13 @@ impl SpircTask { if let Some(shuffle) = shuffle { self.connect_state.handle_shuffle(shuffle)?; } - - self.notify().await.map(|_| Reply::Success)? - } - RequestCommand::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.resolve_context - .push(ResolveContext::from_context(update_context.context, false)); - } - Reply::Success - } - RequestCommand::SkipNext(skip_next) => { - self.handle_next(skip_next.track.map(|t| t.uri))?; - self.notify().await.map(|_| Reply::Success)? } - RequestCommand::SkipPrev(_) => { - self.handle_prev()?; - self.notify().await.map(|_| Reply::Success)? - } - RequestCommand::Resume(_) => { - self.handle_play(); - self.notify().await.map(|_| Reply::Success)? - } - RequestCommand::Unknown(unknown) => { - warn!("unknown request command: {unknown}"); - // we just don't handle the command, by that we don't lose our connect state - Reply::Success - } - }; + SkipNext(skip_next) => self.handle_next(skip_next.track.map(|t| t.uri))?, + SkipPrev(_) => self.handle_prev()?, + Resume(_) => self.handle_play(), + } - sender.send(response).map_err(Into::into) + self.notify().await } fn handle_transfer(&mut self, mut transfer: TransferState) -> Result<(), Error> { @@ -1206,6 +1192,8 @@ impl SpircTask { self.connect_state.merge_context(context); self.connect_state.clear_next_tracks(false); + debug!("play track <{:?}>", cmd.playing_track); + let index = match cmd.playing_track { PlayingTrack::Index(i) => i as usize, PlayingTrack::Uri(uri) => { diff --git a/core/src/dealer/protocol/request.rs b/core/src/dealer/protocol/request.rs index 85426a305..4d796469f 100644 --- a/core/src/dealer/protocol/request.rs +++ b/core/src/dealer/protocol/request.rs @@ -12,12 +12,12 @@ pub struct Request { // 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: RequestCommand, + pub command: Command, } #[derive(Clone, Debug, Deserialize)] #[serde(tag = "endpoint", rename_all = "snake_case")] -pub enum RequestCommand { +pub enum Command { Transfer(TransferCommand), #[serde(deserialize_with = "boxed")] Play(Box), @@ -39,27 +39,32 @@ pub enum RequestCommand { Unknown(Value), } -impl Display for RequestCommand { +impl Display for Command { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + use Command::*; + write!( f, - "endpoint: {}", + "endpoint: {}{}", + matches!(self, Unknown(_)) + .then_some("unknown ") + .unwrap_or_default(), match self { - RequestCommand::Transfer(_) => "transfer", - RequestCommand::Play(_) => "play", - RequestCommand::Pause(_) => "pause", - RequestCommand::SeekTo(_) => "seek_to", - RequestCommand::SetShufflingContext(_) => "set_shuffling_context", - RequestCommand::SetRepeatingContext(_) => "set_repeating_context", - RequestCommand::SetRepeatingTrack(_) => "set_repeating_track", - RequestCommand::AddToQueue(_) => "add_to_queue", - RequestCommand::SetQueue(_) => "set_queue", - RequestCommand::SetOptions(_) => "set_options", - RequestCommand::UpdateContext(_) => "update_context", - RequestCommand::SkipNext(_) => "skip_next", - RequestCommand::SkipPrev(_) => "skip_prev", - RequestCommand::Resume(_) => "resume", - RequestCommand::Unknown(json) => { + 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() From bca6b70dc6b54de226eb2840933aea710f7c21b1 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 26 Nov 2024 21:57:12 +0100 Subject: [PATCH 102/138] connect: add uid to tracks without --- Cargo.lock | 3 +-- connect/Cargo.toml | 3 +-- connect/src/state/context.rs | 30 +++++++++++++++++++++--------- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 60fa66f35..7e318412a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1957,7 +1957,6 @@ dependencies = [ name = "librespot-connect" version = "0.6.0-dev" dependencies = [ - "form_urlencoded", "futures-util", "librespot-core", "librespot-playback", @@ -1965,11 +1964,11 @@ dependencies = [ "log", "protobuf", "rand", - "serde", "serde_json", "thiserror", "tokio", "tokio-stream", + "uuid", ] [[package]] diff --git a/connect/Cargo.toml b/connect/Cargo.toml index e35d3c778..7ed3fab76 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/state/context.rs b/connect/src/state/context.rs index 7c0e58007..b1b6f309f 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -4,6 +4,7 @@ use crate::state::{ConnectState, StateError}; use librespot_core::{Error, SpotifyId}; use librespot_protocol::player::{Context, ContextIndex, ContextPage, ContextTrack, ProvidedTrack}; use std::collections::HashMap; +use uuid::Uuid; #[derive(Debug, Clone)] pub struct StateContext { @@ -183,8 +184,8 @@ impl ConnectState { match self.context_to_provided_track(track, Some(new_context_uri), provider.clone()) { Ok(t) => Some(t), - Err(_) => { - error!("couldn't convert {track:#?} into ProvidedTrack"); + Err(why) => { + error!("couldn't convert {track:#?} into ProvidedTrack: {why}"); None } } @@ -204,17 +205,18 @@ impl ConnectState { return None; } - let mutable_context = self.context.as_mut()?; - let page = context.pages.pop()?; - for track in page.tracks { - if track.uid.is_empty() || track.uri.is_empty() { + let current_context = self.context.as_mut()?; + let new_page = context.pages.pop()?; + for new_track in new_page.tracks { + if new_track.uid.is_empty() || new_track.uri.is_empty() { continue; } if let Ok(position) = - Self::find_index_in_context(Some(mutable_context), |t| t.uri == track.uri) + Self::find_index_in_context(Some(current_context), |t| t.uri == new_track.uri) { - mutable_context.tracks.get_mut(position)?.uid = track.uid + // the uid provided from another context might be actual uid of an item + current_context.tracks.get_mut(position)?.uid = new_track.uid } } @@ -272,6 +274,16 @@ impl ConnectState { return Err(Error::unavailable("track not available")); }?; + // 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()); @@ -279,7 +291,7 @@ impl ConnectState { let mut track = ProvidedTrack { uri: id.to_uri()?.replace("unknown", "track"), - uid: ctx_track.uid.clone(), + uid, metadata, provider: provider.to_string(), ..Default::default() From ffc31bb4cd9edd3f0bcc836474d0ac2f29cd1313 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 26 Nov 2024 21:58:15 +0100 Subject: [PATCH 103/138] connect: simpler update of current index --- connect/src/state.rs | 17 ++++++++++++----- connect/src/state/context.rs | 4 +++- connect/src/state/tracks.rs | 20 ++++---------------- connect/src/state/transfer.rs | 12 ++---------- 4 files changed, 21 insertions(+), 32 deletions(-) diff --git a/connect/src/state.rs b/connect/src/state.rs index 16d466073..2e113eaa4 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -102,8 +102,6 @@ pub struct ConnectState { pub device: DeviceInfo, unavailable_uri: Vec, - /// is only some when we're playing a queued item and have to preserve the index - player_index: Option, /// index: 0 based, so the first track is index 0 player: PlayerState, @@ -254,6 +252,17 @@ impl ConnectState { self.update_restrictions() } + pub fn update_current_index(&mut self, f: impl Fn(&mut ContextIndex)) { + match self.player.index.as_mut() { + Some(player_index) => f(player_index), + None => { + let mut new_index = ContextIndex::new(); + f(&mut new_index); + self.player.index = MessageField::some(new_index) + } + } + } + pub fn update_position(&mut self, position_ms: u32, timestamp: i64) { self.player.position_as_of_timestamp = position_ms.into(); self.player.timestamp = timestamp; @@ -271,9 +280,7 @@ impl ConnectState { pub fn reset_playback_to_position(&mut self, new_index: Option) -> Result<(), Error> { let new_index = new_index.unwrap_or(0); - if let Some(player_index) = self.player.index.as_mut() { - player_index.track = new_index as u32; - } + self.update_current_index(|i| i.track = new_index as u32); self.update_context_index(new_index + 1)?; diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index b1b6f309f..77e02593d 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -69,6 +69,7 @@ impl ConnectState { if matches!(new_context, Some(ctx) if self.player.context_uri != ctx) { self.context = None; + self.next_contexts.clear(); } else if let Some(ctx) = self.context.as_mut() { ctx.index.track = 0; ctx.index.page = 0; @@ -275,7 +276,7 @@ impl ConnectState { }?; // 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 + // - 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 @@ -326,6 +327,7 @@ impl ConnectState { }; if next.tracks.is_empty() { + self.update_current_index(|i| i.page += 1); return Ok(LoadNext::PageUrl(next.page_url)); } diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index 76e8fcad3..7a1c053e8 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -50,9 +50,7 @@ impl<'ct> ConnectState { self.player.track = MessageField::some(new_track.clone()); - if let Some(player_index) = self.player.index.as_mut() { - player_index.track = index as u32; - } + self.update_current_index(|i| i.track = index as u32); Ok(()) } @@ -100,11 +98,7 @@ impl<'ct> ConnectState { self.fill_up_next_tracks()?; let is_queue_or_autoplay = new_track.is_queue() || new_track.is_autoplay(); - let update_index = if is_queue_or_autoplay && self.player.index.is_some() { - // the index isn't send when we are a queued track, but we have to preserve it for later - self.player_index = self.player.index.take(); - None - } else if is_queue_or_autoplay { + let update_index = if is_queue_or_autoplay { None } else { let ctx = self.context.as_ref(); @@ -119,11 +113,7 @@ impl<'ct> ConnectState { }; if let Some(update_index) = update_index { - if let Some(index) = self.player.index.as_mut() { - index.track = update_index - } else { - debug!("next: index can't be updated, no index available") - } + self.update_current_index(|i| i.track = update_index) } self.player.track = MessageField::some(new_track); @@ -180,10 +170,8 @@ impl<'ct> ConnectState { if self.player.index.track == 0 { warn!("prev: trying to skip into negative, index update skipped") - } else if let Some(index) = self.player.index.as_mut() { - index.track -= 1; } else { - debug!("prev: index can't be decreased, no index available") + self.update_current_index(|i| i.track -= 1) } self.update_restrictions(); diff --git a/connect/src/state/transfer.rs b/connect/src/state/transfer.rs index 0168473b2..05e4a43b2 100644 --- a/connect/src/state/transfer.rs +++ b/connect/src/state/transfer.rs @@ -1,7 +1,7 @@ use crate::state::provider::{IsProvider, Provider}; use crate::state::{ConnectState, StateError}; use librespot_core::Error; -use librespot_protocol::player::{ContextIndex, ProvidedTrack, TransferState}; +use librespot_protocol::player::{ProvidedTrack, TransferState}; use protobuf::MessageField; impl ConnectState { @@ -96,15 +96,7 @@ impl ConnectState { let current_index = current_index.ok(); if let Some(current_index) = current_index { - if let Some(index) = self.player.index.as_mut() { - index.track = current_index as u32; - } else { - self.player.index = MessageField::some(ContextIndex { - page: 0, - track: current_index as u32, - ..Default::default() - }) - } + self.update_current_index(|i| i.track = current_index as u32); } debug!( From 85a68c8fe39845edb86f81f3e0d6065f7eb99524 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 26 Nov 2024 22:31:13 +0100 Subject: [PATCH 104/138] core/connect: update log msg, fix wrong behavior - handle became inactive separately - remove duplicate stop - adjust docs for websocket request --- connect/src/spirc.rs | 16 ++++------------ connect/src/state.rs | 9 ++++++++- connect/src/state/context.rs | 4 +++- core/src/dealer/protocol.rs | 6 +++++- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 3d1efb311..2cac273c6 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -530,10 +530,7 @@ impl SpircTask { match self.session.spclient().get_context(context_uri).await { Err(why) => error!("failed to resolve context '{context_uri}': {why}"), Ok(ctx) if update => { - if let Err(why) = self.connect_state.update_context(ctx, UpdateContext::Default) { - error!("failed loading context: {why}"); - self.handle_stop() - } + self.connect_state.update_context(ctx, UpdateContext::Default)? } Ok(mut ctx) 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()); @@ -921,12 +918,8 @@ impl SpircTask { self.connect_state.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(); - self.connect_state.reset(); - let _ = self - .connect_state - .update_state(&self.session, PutStateReason::BECAME_INACTIVE) - .await?; } else if self.connect_state.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 @@ -1125,9 +1118,7 @@ impl SpircTask { .update_position_in_relation(self.now_ms()); self.notify().await?; - self.connect_state - .update_state(&self.session, PutStateReason::BECAME_INACTIVE) - .await?; + self.connect_state.became_inactive(&self.session).await?; self.player .emit_session_disconnected_event(self.session.connection_id(), self.session.username()); @@ -1538,6 +1529,7 @@ impl SpircTask { 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(()); } diff --git a/connect/src/state.rs b/connect/src/state.rs index 2e113eaa4..94095cc5a 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -397,12 +397,19 @@ impl ConnectState { self.player.timestamp = timestamp; } + pub async fn became_inactive(&mut self, session: &Session) -> SpClientResult { + self.reset(); + self.reset_context(None); + + session.spclient().put_connect_state_inactive(false).await + } + /// Updates the connect state for the connect session /// /// Prepares a [PutStateRequest] from the current connect state pub async fn update_state(&self, session: &Session, reason: PutStateReason) -> SpClientResult { if matches!(reason, PutStateReason::BECAME_INACTIVE) { - return session.spclient().put_connect_state_inactive(false).await; + warn!("should use instead") } let now = SystemTime::now(); diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 77e02593d..825dca157 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -67,7 +67,9 @@ impl ConnectState { self.autoplay_context = None; self.shuffle_context = None; - if matches!(new_context, Some(ctx) if self.player.context_uri != ctx) { + let reset_default_context = new_context.is_none() + || matches!(new_context, Some(ctx) if self.player.context_uri != ctx); + if reset_default_context { self.context = None; self.next_contexts.clear(); } else if let Some(ctx) = self.context.as_mut() { diff --git a/core/src/dealer/protocol.rs b/core/src/dealer/protocol.rs index 858a10e7c..e6b7f2dc3 100644 --- a/core/src/dealer/protocol.rs +++ b/core/src/dealer/protocol.rs @@ -155,7 +155,11 @@ impl WebsocketRequest { let payload = String::from_utf8(payload)?; if log::max_level() >= LevelFilter::Trace { - trace!("{:#?}", serde_json::from_str::(&payload)); + if let Ok(json) = serde_json::from_str::(&payload) { + trace!("websocket request: {json:#?}"); + } else { + trace!("websocket request: {payload}"); + } } serde_json::from_str(&payload) From 2a8d6e13c8dda82ad2771d405f0ab22e14446c85 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 26 Nov 2024 23:16:11 +0100 Subject: [PATCH 105/138] core: add option to request without metrics and salt --- core/src/spclient.rs | 69 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/core/src/spclient.rs b/core/src/spclient.rs index fe78b282a..da55b7007 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -53,6 +53,11 @@ 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}")] @@ -77,6 +82,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) @@ -355,6 +374,24 @@ impl SpClient { endpoint: &str, headers: Option, message: &M, + ) -> SpClientResult { + 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()?; @@ -364,7 +401,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 } @@ -388,6 +425,18 @@ impl SpClient { endpoint: &str, headers: Option, 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; @@ -411,16 +460,18 @@ impl SpClient { // `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 { + let _ = write!( + url, + "{}product=0&country={}", + separator, + 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, "{separator}salt={}", rand::thread_rng().next_u32()); } let mut request = Request::builder() From 81020faa519c9328511603116f2abc6e3fa31c01 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 26 Nov 2024 23:38:04 +0100 Subject: [PATCH 106/138] 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/src/spirc.rs | 7 +++--- connect/src/state.rs | 5 ++++- connect/src/state/context.rs | 41 +++++++++++++----------------------- connect/src/state/options.rs | 5 ++--- core/src/spclient.rs | 23 ++++++++++++++++++-- 5 files changed, 46 insertions(+), 35 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 2cac273c6..c218109eb 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -546,9 +546,10 @@ impl SpircTask { if context_uri.contains("spotify:show:") || context_uri.contains("spotify:episode:") { // autoplay is not supported for podcasts - return Err( - SpircError::NotAllowedContext(ResolveContext::from_uri(context_uri, true)).into(), - ); + Err(SpircError::NotAllowedContext(ResolveContext::from_uri( + context_uri, + true, + )))? } let previous_tracks = self.connect_state.prev_autoplay_track_uris(); diff --git a/connect/src/state.rs b/connect/src/state.rs index 94095cc5a..27c69ef98 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -50,6 +50,8 @@ pub enum StateError { ContextHasNoTracks, #[error("playback of local files is not supported")] UnsupportedLocalPlayBack, + #[error("track uri <{0}> contains invalid characters")] + InvalidTrackUri(String), } impl From for Error { @@ -60,7 +62,8 @@ impl From for Error { | MessageFieldNone(_) | NoContext(_) | CanNotFindTrackInContext(_, _) - | ContextHasNoTracks => Error::failed_precondition(err), + | ContextHasNoTracks + | InvalidTrackUri(_) => Error::failed_precondition(err), CurrentlyDisallowed { .. } | UnsupportedLocalPlayBack => Error::unavailable(err), } } diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 825dca157..0b71fc781 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -6,6 +6,8 @@ use librespot_protocol::player::{Context, ContextIndex, ContextPage, ContextTrac use std::collections::HashMap; use uuid::Uuid; +const LOCAL_FILES_IDENTIFIER: &str = "spotify:local-files"; + #[derive(Debug, Clone)] pub struct StateContext { pub tracks: Vec, @@ -84,6 +86,8 @@ impl ConnectState { 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()); } self.next_contexts.clear(); @@ -98,7 +102,7 @@ impl ConnectState { } let page = match first_page { - None => return Err(StateError::ContextHasNoTracks.into()), + None => Err(StateError::ContextHasNoTracks)?, Some(p) => p, }; @@ -244,39 +248,24 @@ impl ConnectState { context_uri: Option<&str>, provider: Option, ) -> Result { - // completely ignore local playback. - if matches!(context_uri, Some(context_uri) if context_uri.starts_with("spotify:local-files")) - { - return Err(StateError::UnsupportedLocalPlayBack.into()); - } - - let question_mark_idx = ctx_track - .uri - .contains('?') - .then(|| ctx_track.uri.find('?')) - .flatten(); + let id = if !ctx_track.uri.is_empty() { + if ctx_track.uri.contains(['?', '%']) { + Err(StateError::InvalidTrackUri(ctx_track.uri.clone()))? + } - let ctx_track_uri = if let Some(idx) = question_mark_idx { - &ctx_track.uri[..idx] + SpotifyId::from_uri(&ctx_track.uri)? + } else if !ctx_track.gid.is_empty() { + SpotifyId::from_raw(&ctx_track.gid)? } else { - &ctx_track.uri - } - .to_string(); + Err(StateError::InvalidTrackUri(String::new()))? + }; - let provider = if self.unavailable_uri.contains(&ctx_track_uri) { + let provider = if self.unavailable_uri.contains(&ctx_track.uri) { Provider::Unavailable } else { provider.unwrap_or(Provider::Context) }; - let id = if !ctx_track_uri.is_empty() { - SpotifyId::from_uri(&ctx_track_uri) - } else if !ctx_track.gid.is_empty() { - SpotifyId::from_raw(&ctx_track.gid) - } else { - return Err(Error::unavailable("track not available")); - }?; - // 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 diff --git a/connect/src/state/options.rs b/connect/src/state/options.rs index 7a32a2531..534869df1 100644 --- a/connect/src/state/options.rs +++ b/connect/src/state/options.rs @@ -40,11 +40,10 @@ impl ConnectState { .disallow_toggling_shuffle_reasons .first() { - return Err(StateError::CurrentlyDisallowed { + Err(StateError::CurrentlyDisallowed { action: "shuffle".to_string(), reason: reason.clone(), - } - .into()); + })? } self.prev_tracks.clear(); diff --git a/core/src/spclient.rs b/core/src/spclient.rs index da55b7007..e04ae304e 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -802,10 +802,28 @@ 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(&Method::GET, &uri, None, None).await?; + 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())?; let ctx = protobuf_json_mapping::parse_from_str::(&ctx_json)?; @@ -817,11 +835,12 @@ impl SpClient { context_request: &AutoplayContextRequest, ) -> Result { let res = self - .request_with_protobuf( + .request_with_protobuf_and_options( &Method::POST, "/context-resolve/v1/autoplay", None, context_request, + &NO_METRICS_AND_SALT, ) .await?; From 7ebacc7ff3c2d461c891641264191a944615dc5c Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Wed, 27 Nov 2024 21:52:21 +0100 Subject: [PATCH 107/138] connect: handle possible search context uri --- connect/src/spirc.rs | 11 ++++++++- connect/src/state.rs | 2 +- connect/src/state/context.rs | 45 +++++++++++++++++++++++++++++++++-- connect/src/state/metadata.rs | 45 +++++++++++++++++++++++------------ 4 files changed, 84 insertions(+), 19 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index c218109eb..2f9be5714 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -981,7 +981,7 @@ impl SpircTask { Transfer(transfer) => { self.handle_transfer(transfer.data.expect("by condition checked"))? } - Play(play) => { + Play(mut play) => { let shuffle = play .options .player_options_override @@ -1001,6 +1001,9 @@ impl SpircTask { .map(|o| o.repeating_track) .unwrap_or_else(|| self.connect_state.repeat_track()); + self.connect_state + .handle_possible_search_uri(&mut play.context)?; + self.handle_load( SpircLoadCommand { context_uri: play.context.uri.clone(), @@ -1054,6 +1057,12 @@ impl SpircTask { self.connect_state .reset_context(Some(&transfer.current_session.context.uri)); + if let Some(session) = transfer.current_session.as_mut() { + if let Some(context) = session.context.as_mut() { + self.connect_state.handle_possible_search_uri(context)? + } + } + let mut ctx_uri = transfer.current_session.context.uri.clone(); let autoplay = ctx_uri.contains("station"); diff --git a/connect/src/state.rs b/connect/src/state.rs index 27c69ef98..34c11161f 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -324,7 +324,7 @@ impl ConnectState { track.set_provider(Provider::Queue); if !track.is_queued() { - track.add_queued(); + track.set_queued(); } if let Some(next_not_queued_track) = diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 0b71fc781..3b18d4ab2 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -7,6 +7,7 @@ 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 { @@ -82,6 +83,38 @@ impl ConnectState { self.update_restrictions() } + pub fn handle_possible_search_uri(&self, context: &mut Context) -> Result<(), Error> { + if !context.uri.starts_with(SEARCH_IDENTIFIER) { + return Ok(()); + } + + let first_page = context + .pages + .first_mut() + .ok_or(StateError::ContextHasNoTracks)?; + + debug!( + "search context <{}> isn't used directly, playing only first track of {}", + context.uri, + first_page.tracks.len() + ); + + let first_track = first_page + .tracks + .first_mut() + .ok_or(StateError::ContextHasNoTracks)?; + + // enrich with context_uri, so that the context is displayed correctly + first_track.add_context_uri(context.uri.clone()); + first_track.add_entity_uri(context.uri.clone()); + + // there might be a chance that the uri isn't provided + // so we handle the track first before using the uri + context.uri = self.context_to_provided_track(first_track, None, None)?.uri; + + Ok(()) + } + pub fn update_context(&mut self, context: Context, ty: UpdateContext) -> Result<(), Error> { if context.pages.iter().all(|p| p.tracks.is_empty()) { error!("context didn't have any tracks: {context:#?}"); @@ -214,16 +247,24 @@ impl ConnectState { let current_context = self.context.as_mut()?; let new_page = context.pages.pop()?; + for new_track in new_page.tracks { - if new_track.uid.is_empty() || new_track.uri.is_empty() { + 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 - current_context.tracks.get_mut(position)?.uid = new_track.uid + context_track.uid = new_track.uid; } } diff --git a/connect/src/state/metadata.rs b/connect/src/state/metadata.rs index 1caedff27..c92ecbe34 100644 --- a/connect/src/state/metadata.rs +++ b/connect/src/state/metadata.rs @@ -1,37 +1,52 @@ -use librespot_protocol::player::ProvidedTrack; +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"; pub trait Metadata { - fn is_queued(&self) -> bool; - fn get_context_uri(&self) -> Option<&String>; + fn metadata(&self) -> &HashMap; + fn metadata_mut(&mut self) -> &mut HashMap; - fn add_queued(&mut self); - fn add_context_uri(&mut self, uri: String); - fn add_entity_uri(&mut self, uri: String); -} - -impl Metadata for ProvidedTrack { fn is_queued(&self) -> bool { - matches!(self.metadata.get(IS_QUEUED), Some(is_queued) if is_queued.eq("true")) + matches!(self.metadata().get(IS_QUEUED), Some(is_queued) if is_queued.eq("true")) } fn get_context_uri(&self) -> Option<&String> { - self.metadata.get(CONTEXT_URI) + self.metadata().get(CONTEXT_URI) } - fn add_queued(&mut self) { - self.metadata + fn set_queued(&mut self) { + self.metadata_mut() .insert(IS_QUEUED.to_string(), true.to_string()); } fn add_context_uri(&mut self, uri: String) { - self.metadata.insert(CONTEXT_URI.to_string(), uri); + self.metadata_mut().insert(CONTEXT_URI.to_string(), uri); } fn add_entity_uri(&mut self, uri: String) { - self.metadata.insert(ENTITY_URI.to_string(), uri); + self.metadata_mut().insert(ENTITY_URI.to_string(), uri); + } +} + +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 } } From 749622abeecdbb5d86d5165bd3bdd065ca99ac0c Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Wed, 27 Nov 2024 22:11:37 +0100 Subject: [PATCH 108/138] connect: remove logout support - handle logout command - disable support for logout - add todos for logout --- connect/src/spirc.rs | 24 +++++++++++++++++++++++- connect/src/state.rs | 5 ++--- src/main.rs | 2 -- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 2f9be5714..d8ad856a6 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -15,7 +15,7 @@ use crate::{ }, protocol::{ autoplay_context_request::AutoplayContextRequest, - connect::{Cluster, ClusterUpdate, PutStateReason, SetVolumeCommand}, + connect::{Cluster, ClusterUpdate, LogoutCommand, PutStateReason, SetVolumeCommand}, explicit_content_pubsub::UserAttributesUpdate, player::{Context, TransferState}, playlist4_external::PlaylistModificationInfo, @@ -84,6 +84,7 @@ struct SpircTask { 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, @@ -179,6 +180,13 @@ impl Spirc { .map(Message::from_raw), ); + let connect_state_logout_request = Box::pin( + session + .dealer() + .listen_for("hm://connect-state/v1/connect/logout")? + .map(Message::from_raw), + ); + let playlist_update = Box::pin( session .dealer() @@ -240,6 +248,7 @@ impl Spirc { connection_id_update, connect_state_update, connect_state_volume_update, + connect_state_logout_request, playlist_update, session_update, connect_state_command, @@ -376,6 +385,19 @@ impl SpircTask { break; } }, + logout_request = self.connect_state_logout_request.next() => match logout_request { + Some(result) => match result { + Ok(logout_request) => { + error!("received logout request, currently not supported: {logout_request:#?}"); + // todo: call logout handling + }, + Err(e) => error!("could not parse logout request: {}", e), + } + None => { + error!("logout request selected, but none received"); + break; + } + }, playlist_update = self.playlist_update.next() => match playlist_update { Some(result) => match result { Ok(update) => if let Err(why) = self.handle_playlist_modification(update) { diff --git a/connect/src/state.rs b/connect/src/state.rs index 34c11161f..4d6c7cccc 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -75,7 +75,6 @@ pub struct ConnectStateConfig { pub initial_volume: u32, pub name: String, pub device_type: DeviceType, - pub zeroconf_enabled: bool, pub volume_steps: i32, pub is_group: bool, } @@ -87,7 +86,6 @@ impl Default for ConnectStateConfig { initial_volume: u32::from(u16::MAX) / 2, name: "librespot".to_string(), device_type: DeviceType::Speaker, - zeroconf_enabled: false, volume_steps: 64, is_group: false, } @@ -158,7 +156,8 @@ impl ConnectState { is_controllable: true, supports_gzip_pushes: true, - supports_logout: cfg.zeroconf_enabled, + // 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, diff --git a/src/main.rs b/src/main.rs index 47f90ba4e..6aaa72cea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1487,7 +1487,6 @@ fn get_setup() -> Setup { device_type, is_group, initial_volume: initial_volume.into(), - zeroconf_enabled: zeroconf_backend.is_some(), ..Default::default() } } else { @@ -1495,7 +1494,6 @@ fn get_setup() -> Setup { name, device_type, is_group, - zeroconf_enabled: zeroconf_backend.is_some(), ..Default::default() } } From 049b7ee3e272e26289048adcbcc8472b98b7f798 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Fri, 29 Nov 2024 20:02:15 +0100 Subject: [PATCH 109/138] connect: adjust detailed tracks/context handling - always allow next - handle no prev track available - separate active and fill up context --- connect/src/spirc.rs | 21 ++++++++++++--------- connect/src/state.rs | 6 +++++- connect/src/state/context.rs | 7 ++++--- connect/src/state/options.rs | 1 + connect/src/state/restrictions.rs | 9 --------- connect/src/state/tracks.rs | 24 +++++++++++++++--------- connect/src/state/transfer.rs | 2 +- 7 files changed, 38 insertions(+), 32 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index d8ad856a6..11ed18f9f 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1091,6 +1091,7 @@ impl SpircTask { if autoplay { ctx_uri = ctx_uri.replace("station:", ""); self.connect_state.active_context = ContextType::Autoplay; + self.connect_state.fill_up_context = ContextType::Autoplay; } debug!("async resolve context for {}", ctx_uri); @@ -1238,11 +1239,10 @@ impl SpircTask { self.connect_state.set_repeat_context(cmd.repeat); if cmd.shuffle { - self.connect_state.active_context = ContextType::Default; self.connect_state.set_current_track(index)?; self.connect_state.shuffle()?; } else { - // set manually, so that we overwrite a possible current queue track + // manually overwrite a possible current queued track self.connect_state.set_current_track(index)?; self.connect_state.reset_playback_to_position(Some(index))?; } @@ -1453,17 +1453,20 @@ impl SpircTask { // Under 3s it goes to the previous song (starts playing) // Over 3s it seeks to zero (retains previous play status) if self.position() < 3000 { - let new_track_index = self.connect_state.prev_track()?; - - if new_track_index.is_none() && self.connect_state.repeat_context() { - self.connect_state.reset_playback_to_position(None)? + 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)?, } - - self.load_track(self.is_playing(), 0) } else { self.handle_seek(0); - Ok(()) } + + Ok(()) } fn handle_volume_up(&mut self) { diff --git a/connect/src/state.rs b/connect/src/state.rs index 4d6c7cccc..da9b5e1c6 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -114,7 +114,11 @@ pub struct ConnectState { /// top => bottom, aka the first track of the list is the next track next_tracks: VecDeque, + // separation is necessary because we could have already loaded + // the autoplay context but are still playing from the default 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 /// the index is used to keep track which tracks are already loaded into next tracks pub context: Option, @@ -295,7 +299,7 @@ impl ConnectState { self.prev_tracks.clear(); if new_index > 0 { - let context = self.get_current_context()?; + let context = self.get_context(&self.active_context)?; let before_new_track = context.tracks.len() - new_index; self.prev_tracks = context diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 3b18d4ab2..55869d571 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -51,13 +51,13 @@ impl ConnectState { .ok_or(StateError::CanNotFindTrackInContext(None, ctx.tracks.len())) } - pub(super) fn get_current_context(&self) -> Result<&StateContext, StateError> { - match self.active_context { + 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(self.active_context)) + .ok_or(StateError::NoContext(*ty)) } pub fn context_uri(&self) -> &String { @@ -66,6 +66,7 @@ impl ConnectState { pub fn reset_context(&mut self, new_context: Option<&str>) { self.active_context = ContextType::Default; + self.fill_up_context = ContextType::Default; self.autoplay_context = None; self.shuffle_context = None; diff --git a/connect/src/state/options.rs b/connect/src/state/options.rs index 534869df1..5e2c769d4 100644 --- a/connect/src/state/options.rs +++ b/connect/src/state/options.rs @@ -67,6 +67,7 @@ impl ConnectState { self.shuffle_context = Some(shuffle_context); self.active_context = ContextType::Shuffle; + self.fill_up_context = ContextType::Shuffle; self.fill_up_next_tracks()?; Ok(()) diff --git a/connect/src/state/restrictions.rs b/connect/src/state/restrictions.rs index 953139a9e..088a7931b 100644 --- a/connect/src/state/restrictions.rs +++ b/connect/src/state/restrictions.rs @@ -6,7 +6,6 @@ use protobuf::MessageField; impl ConnectState { pub fn update_restrictions(&mut self) { const NO_PREV: &str = "no previous tracks"; - const NO_NEXT: &str = "no next tracks"; const AUTOPLAY: &str = "autoplay"; const ENDLESS_CONTEXT: &str = "endless_context"; @@ -35,14 +34,6 @@ impl ConnectState { restrictions.disallow_skipping_prev_reasons.clear(); } - if self.next_tracks.is_empty() { - restrictions.disallow_peeking_next_reasons = vec![NO_NEXT.to_string()]; - restrictions.disallow_skipping_next_reasons = vec![NO_NEXT.to_string()]; - } else { - restrictions.disallow_peeking_next_reasons.clear(); - restrictions.disallow_skipping_next_reasons.clear(); - } - if self.player.track.is_autoplay() { restrictions.disallow_toggling_shuffle_reasons = vec![AUTOPLAY.to_string()]; restrictions.disallow_toggling_repeat_context_reasons = vec![AUTOPLAY.to_string()]; diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index 7a1c053e8..89909f2bc 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -31,7 +31,7 @@ impl<'ct> ConnectState { } pub fn set_current_track(&mut self, index: usize) -> Result<(), Error> { - let context = self.get_current_context()?; + let context = self.get_context(&self.active_context)?; let new_track = context .tracks @@ -97,8 +97,10 @@ impl<'ct> ConnectState { self.fill_up_next_tracks()?; - let is_queue_or_autoplay = new_track.is_queue() || new_track.is_autoplay(); - let update_index = if is_queue_or_autoplay { + let update_index = if new_track.is_queue() { + None + } else if new_track.is_autoplay() { + self.active_context = ContextType::Autoplay; None } else { let ctx = self.context.as_ref(); @@ -230,12 +232,12 @@ impl<'ct> ConnectState { } pub fn fill_up_next_tracks(&mut self) -> Result<(), StateError> { - let ctx = self.get_current_context()?; + 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_current_context()?; + 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()); @@ -244,10 +246,14 @@ impl<'ct> ConnectState { delimiter } None if self.autoplay_context.is_some() => { - // transitional to autoplay as active context - self.active_context = ContextType::Autoplay; - - match self.get_current_context()?.tracks.get(new_index) { + // transition to autoplay as fill up context + self.fill_up_context = ContextType::Autoplay; + + match self + .get_context(&ContextType::Autoplay)? + .tracks + .get(new_index) + { None => break, Some(ct) => { new_index += 1; diff --git a/connect/src/state/transfer.rs b/connect/src/state/transfer.rs index 05e4a43b2..3edcf3faa 100644 --- a/connect/src/state/transfer.rs +++ b/connect/src/state/transfer.rs @@ -74,7 +74,7 @@ impl ConnectState { Some(track) => track.clone(), }; - let ctx = self.get_current_context().ok(); + 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) From 3d77f5dd68ea91362ab9c77de4b2b7157007bf5f Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Fri, 29 Nov 2024 21:44:15 +0100 Subject: [PATCH 110/138] connect: adjust context resolve handling, again --- connect/src/model.rs | 138 +++++++++++++++++++--------------- connect/src/spirc.rs | 127 +++++++++++++++++++------------ connect/src/state.rs | 5 -- connect/src/state/context.rs | 31 ++------ connect/src/state/transfer.rs | 7 +- 5 files changed, 166 insertions(+), 142 deletions(-) diff --git a/connect/src/model.rs b/connect/src/model.rs index 5872382b4..f658caffd 100644 --- a/connect/src/model.rs +++ b/connect/src/model.rs @@ -1,10 +1,67 @@ +use crate::state::ConnectState; use librespot_core::dealer::protocol::SkipTo; use librespot_protocol::player::Context; use std::fmt::{Display, Formatter}; +#[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 struct ResolveContext { +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 @@ -15,12 +72,17 @@ pub struct ResolveContext { } impl ResolveContext { - pub fn from_uri(uri: impl Into, autoplay: bool) -> Self { + pub fn from_uri( + uri: impl Into, + fallback_uri: impl Into, + autoplay: bool, + ) -> Self { Self { context: Context { uri: uri.into(), ..Default::default() }, + fallback: Some(fallback_uri.into()), autoplay, update: true, } @@ -29,6 +91,7 @@ impl ResolveContext { pub fn from_context(context: Context, autoplay: bool) -> Self { Self { context, + fallback: None, autoplay, update: true, } @@ -56,12 +119,19 @@ impl ResolveContext { uri, ..Default::default() }, + fallback: None, update: false, autoplay: false, } } - pub fn uri(&self) -> &str { + /// the uri which should be used to resolve the context, might not be the context uri + pub fn resolve_uri(&self) -> Option<&String> { + ConnectState::get_context_uri_from_context(&self.context).or(self.fallback.as_ref()) + } + + /// the actual context uri + pub fn context_uri(&self) -> &str { &self.context.uri } @@ -78,8 +148,11 @@ impl Display for ResolveContext { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!( f, - "uri: {}, autoplay: {}, update: {}", - self.context.uri, self.autoplay, self.update + "context_uri: {}, resolve_uri: {:?}, autoplay: {}, update: {}", + self.context.uri, + self.resolve_uri(), + self.autoplay, + self.update ) } } @@ -99,58 +172,3 @@ impl From for Context { value.context } } - -#[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, - }, -} diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 11ed18f9f..b31aaff61 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -51,7 +51,7 @@ pub enum SpircError { #[error("message pushed for another URI")] InvalidUri(String), #[error("tried resolving not allowed context: {0:?}")] - NotAllowedContext(ResolveContext), + NotAllowedContext(String), #[error("failed to put connect state for new device")] FailedDealerSetup, #[error("unknown endpoint: {0:#?}")] @@ -505,10 +505,19 @@ impl SpircTask { } else { last_resolve = Some(resolve.clone()); + let resolve_uri = resolve + .resolve_uri() + .ok_or(SpircError::InvalidUri(resolve.context_uri().to_string()))?; + // 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.autoplay(), resolve.update()) + .resolve_context( + resolve_uri, + resolve.context_uri(), + resolve.autoplay(), + resolve.update(), + ) .await { error!("failed resolving context <{resolve}>: {why}"); @@ -537,21 +546,23 @@ impl SpircTask { self.connect_state.update_restrictions(); self.connect_state.update_queue_revision(); - self.preload_autoplay_when_required(self.connect_state.context_uri().clone()); + 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 { - match self.session.spclient().get_context(context_uri).await { + match self.session.spclient().get_context(resolve_uri).await { Err(why) => error!("failed to resolve context '{context_uri}': {why}"), - Ok(ctx) if update => { + Ok(mut ctx) if update => { + ctx.uri = context_uri.to_string(); self.connect_state.update_context(ctx, UpdateContext::Default)? } Ok(mut ctx) if matches!(ctx.pages.first(), Some(p) if !p.tracks.is_empty()) => { @@ -566,23 +577,20 @@ impl SpircTask { return Ok(()); } - if context_uri.contains("spotify:show:") || context_uri.contains("spotify:episode:") { + if resolve_uri.contains("spotify:show:") || resolve_uri.contains("spotify:episode:") { // autoplay is not supported for podcasts - Err(SpircError::NotAllowedContext(ResolveContext::from_uri( - context_uri, - true, - )))? + Err(SpircError::NotAllowedContext(resolve_uri.to_string()))? } let previous_tracks = self.connect_state.prev_autoplay_track_uris(); debug!( - "loading autoplay context {context_uri} with {} previous tracks", + "loading autoplay context {resolve_uri} with {} previous tracks", previous_tracks.len() ); let ctx_request = AutoplayContextRequest { - context_uri: Some(context_uri.to_string()), + context_uri: Some(resolve_uri.to_string()), recent_track_uri: previous_tracks, ..Default::default() }; @@ -1003,7 +1011,7 @@ impl SpircTask { Transfer(transfer) => { self.handle_transfer(transfer.data.expect("by condition checked"))? } - Play(mut play) => { + Play(play) => { let shuffle = play .options .player_options_override @@ -1023,9 +1031,6 @@ impl SpircTask { .map(|o| o.repeating_track) .unwrap_or_else(|| self.connect_state.repeat_track()); - self.connect_state - .handle_possible_search_uri(&mut play.context)?; - self.handle_load( SpircLoadCommand { context_uri: play.context.uri.clone(), @@ -1079,13 +1084,12 @@ impl SpircTask { self.connect_state .reset_context(Some(&transfer.current_session.context.uri)); - if let Some(session) = transfer.current_session.as_mut() { - if let Some(context) = session.context.as_mut() { - self.connect_state.handle_possible_search_uri(context)? - } - } - - let mut ctx_uri = transfer.current_session.context.uri.clone(); + let mut ctx_uri = + ConnectState::get_context_uri_from_context(&transfer.current_session.context) + .ok_or(SpircError::InvalidUri( + transfer.current_session.context.uri.clone(), + ))? + .clone(); let autoplay = ctx_uri.contains("station"); if autoplay { @@ -1094,15 +1098,29 @@ impl SpircTask { self.connect_state.fill_up_context = ContextType::Autoplay; } + debug!("trying to find initial track"); + match self.connect_state.current_track_from_transfer(&transfer) { + Err(why) => warn!("{why}"), + Ok(track) => { + debug!("found initial track"); + self.connect_state.set_track(track) + } + }; + + let fallback_uri = self.connect_state.current_track(|t| &t.uri).clone(); + debug!("async resolve context for {}", ctx_uri); - self.resolve_context - .push(ResolveContext::from_uri(ctx_uri.clone(), false)); + self.resolve_context.push(ResolveContext::from_uri( + ctx_uri.clone(), + &fallback_uri, + false, + )); let timestamp = self.now_ms(); let state = &mut self.connect_state; state.set_active(true); - state.handle_initial_transfer(&mut transfer); + state.handle_initial_transfer(&mut transfer, ctx_uri.clone()); // update position if the track continued playing let position = if transfer.playback.is_paused { @@ -1119,22 +1137,13 @@ impl SpircTask { if self.connect_state.context.is_some() { self.connect_state.setup_state_from_transfer(transfer)?; } else { - debug!("trying to find initial track"); - match self.connect_state.current_track_from_transfer(&transfer) { - Err(why) => warn!("{why}"), - Ok(track) => { - debug!("initial track found"); - self.connect_state.set_track(track) - } - } - if self.connect_state.autoplay_context.is_none() && (self.connect_state.current_track(|t| t.is_autoplay()) || autoplay) { debug!("currently in autoplay context, async resolving autoplay for {ctx_uri}"); self.resolve_context - .push(ResolveContext::from_uri(ctx_uri, true)) + .push(ResolveContext::from_uri(ctx_uri, fallback_uri, true)) } self.transfer_state = Some(transfer); @@ -1204,11 +1213,22 @@ impl SpircTask { } let current_context_uri = self.connect_state.context_uri(); - if current_context_uri == &cmd.context_uri && self.connect_state.context.is_some() { - debug!("context <{current_context_uri}> didn't change, no resolving required",) + let resolve_uri = 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 { + &cmd.context_uri + } + .clone(); + + if current_context_uri == &cmd.context_uri && resolve_uri == cmd.context_uri { + debug!("context <{current_context_uri}> didn't change, no resolving required") } else { debug!("resolving context for load command"); - self.resolve_context(&cmd.context_uri, false, true).await?; + self.resolve_context(&resolve_uri, &cmd.context_uri, false, true) + .await?; } // for play commands with skip by uid, the context of the command contains @@ -1257,8 +1277,11 @@ impl SpircTask { } if !self.connect_state.has_next_tracks(None) && self.session.autoplay() { - self.resolve_context - .push(ResolveContext::from_uri(cmd.context_uri, true)) + self.resolve_context.push(ResolveContext::from_uri( + &cmd.context_uri, + resolve_uri, + true, + )) } Ok(()) @@ -1379,7 +1402,7 @@ impl SpircTask { Ok(()) } - fn preload_autoplay_when_required(&mut self, uri: String) { + fn preload_autoplay_when_required(&mut self) { let require_load_new = !self .connect_state .has_next_tracks(Some(CONTEXT_FETCH_THRESHOLD)); @@ -1398,11 +1421,16 @@ impl SpircTask { .push(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); // When in autoplay, keep topping up the playlist when it nears the end - debug!("Preloading autoplay context for <{}>", uri); + debug!("Preloading autoplay context for <{current_context}>"); // resolve the next autoplay context - self.resolve_context - .push(ResolveContext::from_uri(uri, true)); + self.resolve_context.push(ResolveContext::from_uri( + current_context, + fallback, + true, + )); } LoadNext::Empty => { debug!("next context is empty and autoplay isn't enabled, no preloading required") @@ -1436,7 +1464,7 @@ impl SpircTask { }; }; - self.preload_autoplay_when_required(self.connect_state.context_uri().clone()); + self.preload_autoplay_when_required(); if has_next_track { self.load_track(continue_playing, 0) @@ -1502,8 +1530,11 @@ impl SpircTask { } debug!("playlist modification for current context: {uri}"); - self.resolve_context - .push(ResolveContext::from_uri(uri, false)); + self.resolve_context.push(ResolveContext::from_uri( + uri, + self.connect_state.current_track(|t| &t.uri), + false, + )); Ok(()) } diff --git a/connect/src/state.rs b/connect/src/state.rs index da9b5e1c6..b31a9ce64 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -431,11 +431,6 @@ impl ConnectState { player_state.next_tracks = self.next_tracks.clone().into(); player_state.prev_tracks = self.prev_tracks.clone().into(); - if let Some(context_uri) = player_state.track.get_context_uri() { - player_state.context_uri = context_uri.to_owned(); - player_state.context_url = format!("context://{context_uri}"); - } - let is_active = self.active; let device = MessageField::some(Device { device_info: MessageField::some(self.device.clone()), diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 55869d571..92a03bb84 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -84,36 +84,15 @@ impl ConnectState { self.update_restrictions() } - pub fn handle_possible_search_uri(&self, context: &mut Context) -> Result<(), Error> { + pub fn get_context_uri_from_context(context: &Context) -> Option<&String> { if !context.uri.starts_with(SEARCH_IDENTIFIER) { - return Ok(()); + return Some(&context.uri); } - let first_page = context + context .pages - .first_mut() - .ok_or(StateError::ContextHasNoTracks)?; - - debug!( - "search context <{}> isn't used directly, playing only first track of {}", - context.uri, - first_page.tracks.len() - ); - - let first_track = first_page - .tracks - .first_mut() - .ok_or(StateError::ContextHasNoTracks)?; - - // enrich with context_uri, so that the context is displayed correctly - first_track.add_context_uri(context.uri.clone()); - first_track.add_entity_uri(context.uri.clone()); - - // there might be a chance that the uri isn't provided - // so we handle the track first before using the uri - context.uri = self.context_to_provided_track(first_track, None, None)?.uri; - - Ok(()) + .first() + .and_then(|p| p.tracks.first().map(|t| &t.uri)) } pub fn update_context(&mut self, context: Context, ty: UpdateContext) -> Result<(), Error> { diff --git a/connect/src/state/transfer.rs b/connect/src/state/transfer.rs index 3edcf3faa..eb24d8dca 100644 --- a/connect/src/state/transfer.rs +++ b/connect/src/state/transfer.rs @@ -23,7 +23,7 @@ impl ConnectState { ) } - pub fn handle_initial_transfer(&mut self, transfer: &mut TransferState) { + pub fn handle_initial_transfer(&mut self, transfer: &mut TransferState, ctx_uri: String) { self.player.is_buffering = false; if let Some(options) = transfer.options.take() { @@ -44,9 +44,10 @@ impl ConnectState { self.player.suppressions = MessageField::some(suppressions.clone()); } + self.player.context_url = format!("context://{ctx_uri}"); + self.player.context_uri = ctx_uri; + if let Some(context) = transfer.current_session.context.as_ref() { - self.player.context_uri = context.uri.clone(); - self.player.context_url = context.url.clone(); self.player.context_restrictions = context.restrictions.clone(); } From 58a96900782f2b4ee7f4b2bb3fbfe4f4e489aef2 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Fri, 29 Nov 2024 22:11:52 +0100 Subject: [PATCH 111/138] connect: add autoplay metadata to tracks - transfer into autoplay again --- connect/src/spirc.rs | 23 +++++++++++------------ connect/src/state.rs | 6 +++--- connect/src/state/context.rs | 14 ++++++++++---- connect/src/state/metadata.rs | 17 ++++++++++++++--- connect/src/state/tracks.rs | 17 ++++++++++------- 5 files changed, 48 insertions(+), 29 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index b31aaff61..0c82019cd 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,4 +1,5 @@ pub use crate::model::{PlayingTrack, SpircLoadCommand}; +use crate::state::metadata::Metadata; use crate::{ core::{ authentication::Credentials, @@ -870,11 +871,9 @@ impl SpircTask { // 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 - if let Ok(transfer_state) = TransferState::parse_from_bytes(&cluster.transfer_data) { - if !transfer_state.current_session.context.pages.is_empty() { - info!("received transfer state with context, trying to take over control again"); - self.handle_transfer(transfer_state)? - } + 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(()) @@ -1090,13 +1089,6 @@ impl SpircTask { transfer.current_session.context.uri.clone(), ))? .clone(); - let autoplay = ctx_uri.contains("station"); - - if autoplay { - ctx_uri = ctx_uri.replace("station:", ""); - self.connect_state.active_context = ContextType::Autoplay; - self.connect_state.fill_up_context = ContextType::Autoplay; - } debug!("trying to find initial track"); match self.connect_state.current_track_from_transfer(&transfer) { @@ -1107,6 +1099,13 @@ impl SpircTask { } }; + let autoplay = self.connect_state.current_track(|t| t.is_from_autoplay()); + if autoplay { + ctx_uri = ctx_uri.replace("station:", ""); + self.connect_state.active_context = ContextType::Autoplay; + self.connect_state.fill_up_context = ContextType::Autoplay; + } + let fallback_uri = self.connect_state.current_track(|t| &t.uri).clone(); debug!("async resolve context for {}", ctx_uri); diff --git a/connect/src/state.rs b/connect/src/state.rs index b31a9ce64..bf62b097c 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -1,6 +1,6 @@ pub(super) mod context; mod handle; -mod metadata; +pub mod metadata; mod options; pub(super) mod provider; mod restrictions; @@ -326,8 +326,8 @@ impl ConnectState { self.queue_count += 1; track.set_provider(Provider::Queue); - if !track.is_queued() { - track.set_queued(); + if !track.is_from_queue() { + track.set_queued(true); } if let Some(next_not_queued_track) = diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 92a03bb84..f891ec0d1 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -103,7 +103,9 @@ impl ConnectState { return Err(StateError::UnsupportedLocalPlayBack.into()); } - self.next_contexts.clear(); + if matches!(ty, UpdateContext::Default) { + self.next_contexts.clear(); + } let mut first_page = None; for page in context.pages { @@ -162,14 +164,14 @@ impl ConnectState { new_context.index.track = (new_pos + 1) as u32; } // the track isn't anymore in the context - Err(_) => { - warn!("current tracks was removed, setting pos to last known index"); + 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.active_context = ContextType::Default; } self.context = Some(new_context); @@ -315,6 +317,10 @@ impl ConnectState { track.add_entity_uri(context_uri.to_string()); } + if matches!(provider, Provider::Autoplay) { + track.set_autoplay(true) + } + Ok(track) } diff --git a/connect/src/state/metadata.rs b/connect/src/state/metadata.rs index c92ecbe34..fd8fb700e 100644 --- a/connect/src/state/metadata.rs +++ b/connect/src/state/metadata.rs @@ -4,22 +4,33 @@ 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"; +#[allow(dead_code)] pub trait Metadata { fn metadata(&self) -> &HashMap; fn metadata_mut(&mut self) -> &mut HashMap; - fn is_queued(&self) -> bool { + 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 get_context_uri(&self) -> Option<&String> { self.metadata().get(CONTEXT_URI) } - fn set_queued(&mut self) { + 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_QUEUED.to_string(), true.to_string()); + .insert(IS_AUTOPLAY.to_string(), autoplay.to_string()); } fn add_context_uri(&mut self, uri: String) { diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index 89909f2bc..516342e37 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -196,13 +196,16 @@ impl<'ct> ConnectState { // 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_queued()).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; - }); + 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.next_tracks = tracks.into(); } From e46ab03dc29fa79f9dd5e43223ab28ae37d58fd6 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Fri, 29 Nov 2024 23:05:09 +0100 Subject: [PATCH 112/138] core/connect: cleanup session after spirc stops --- connect/src/spirc.rs | 4 ++++ core/src/spclient.rs | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 0c82019cd..9fa612b3f 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -494,6 +494,10 @@ impl SpircTask { } } + // 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; } diff --git a/core/src/spclient.rs b/core/src/spclient.rs index e04ae304e..32940e4a2 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -545,6 +545,11 @@ impl SpClient { .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}", From 2957546321aff5f413f28f11ba5633b572dc2d68 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Fri, 29 Nov 2024 23:57:01 +0100 Subject: [PATCH 113/138] update CHANGELOG.md --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82bfb094a..93a1304f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,16 @@ 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) + ### Fixed ### Removed From a252a59e5e2ec11316d53ab078eacca0c85bd500 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Fri, 29 Nov 2024 23:58:06 +0100 Subject: [PATCH 114/138] playback: fix clippy warnings --- playback/src/audio_backend/portaudio.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/playback/src/audio_backend/portaudio.rs b/playback/src/audio_backend/portaudio.rs index c44245cfe..29fba7d96 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"; } From 6cd4492eb62ee69ea57d362e7609bc630d20cb93 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 30 Nov 2024 19:35:22 +0100 Subject: [PATCH 115/138] connect: adjust metadata - unify naming - move more metadata infos into metadata.rs --- connect/src/state/context.rs | 4 ++-- connect/src/state/metadata.rs | 25 +++++++++++++++++++++++-- connect/src/state/tracks.rs | 18 +++++++----------- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index f891ec0d1..742142318 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -313,8 +313,8 @@ impl ConnectState { }; if let Some(context_uri) = context_uri { - track.add_context_uri(context_uri.to_string()); - track.add_entity_uri(context_uri.to_string()); + track.set_context_uri(context_uri.to_string()); + track.set_entity_uri(context_uri.to_string()); } if matches!(provider, Provider::Autoplay) { diff --git a/connect/src/state/metadata.rs b/connect/src/state/metadata.rs index fd8fb700e..d3788b22b 100644 --- a/connect/src/state/metadata.rs +++ b/connect/src/state/metadata.rs @@ -6,6 +6,9 @@ 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; @@ -19,10 +22,18 @@ pub trait Metadata { 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()); @@ -33,13 +44,23 @@ pub trait Metadata { .insert(IS_AUTOPLAY.to_string(), autoplay.to_string()); } - fn add_context_uri(&mut self, uri: 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 add_entity_uri(&mut self, uri: String) { + 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 { diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index 516342e37..e5d18ab74 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -7,27 +7,23 @@ use crate::state::{ use librespot_core::{Error, SpotifyId}; use librespot_protocol::player::ProvidedTrack; use protobuf::MessageField; -use std::collections::{HashMap, VecDeque}; +use std::collections::VecDeque; // identifier used as part of the uid pub const IDENTIFIER_DELIMITER: &str = "delimiter"; impl<'ct> ConnectState { fn new_delimiter(iteration: i64) -> ProvidedTrack { - const HIDDEN: &str = "hidden"; - const ITERATION: &str = "iteration"; - - let mut metadata = HashMap::new(); - metadata.insert(HIDDEN.to_string(), true.to_string()); - metadata.insert(ITERATION.to_string(), iteration.to_string()); - - ProvidedTrack { + let mut delimiter = ProvidedTrack { uri: format!("spotify:{IDENTIFIER_DELIMITER}"), uid: format!("{IDENTIFIER_DELIMITER}{iteration}"), provider: Provider::Context.to_string(), - metadata, ..Default::default() - } + }; + delimiter.set_hidden(true); + delimiter.add_iteration(iteration); + + delimiter } pub fn set_current_track(&mut self, index: usize) -> Result<(), Error> { From 7c394e702639c792df0f98cf5025bbf4ca667087 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 30 Nov 2024 19:47:58 +0100 Subject: [PATCH 116/138] connect: add delimiter between context and autoplay playback --- connect/src/state/tracks.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index e5d18ab74..1c304a408 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -244,10 +244,15 @@ impl<'ct> ConnectState { new_index = 0; delimiter } - None if self.autoplay_context.is_some() => { + None if matches!(self.fill_up_context, ContextType::Default) + && self.autoplay_context.is_some() => + { // transition to autoplay as fill up context self.fill_up_context = ContextType::Autoplay; - + // 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 From 37444bcdecb336700498d956b771453a4b2107d4 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 30 Nov 2024 19:48:26 +0100 Subject: [PATCH 117/138] connect: stop and resume correctly --- connect/src/spirc.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 9fa612b3f..1ac56cf96 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -527,7 +527,7 @@ impl SpircTask { { error!("failed resolving context <{resolve}>: {why}"); self.connect_state.reset_context(None); - self.handle_stop() + self.handle_stop()? } self.connect_state.merge_context(Some(resolve.into())); @@ -953,7 +953,7 @@ impl SpircTask { if became_inactive { info!("device became inactive"); self.connect_state.became_inactive(&self.session).await?; - self.handle_stop(); + self.handle_stop()?; } else if self.connect_state.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 @@ -1077,6 +1077,9 @@ impl SpircTask { } 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(), } @@ -1156,7 +1159,7 @@ impl SpircTask { } async fn handle_disconnect(&mut self) -> Result<(), Error> { - self.handle_stop(); + self.handle_stop()?; self.play_status = SpircPlayStatus::Stopped {}; self.connect_state @@ -1171,8 +1174,11 @@ impl SpircTask { Ok(()) } - fn handle_stop(&mut self) { + fn handle_stop(&mut self) -> Result<(), Error> { self.player.stop(); + self.connect_state.update_position(0, self.now_ms()); + self.connect_state.clear_next_tracks(true); + self.connect_state.fill_up_next_tracks().map_err(Into::into) } fn handle_activate(&mut self) { @@ -1276,7 +1282,7 @@ impl SpircTask { self.load_track(cmd.start_playing, cmd.seek_to)?; } else { info!("No active track, stopping"); - self.handle_stop(); + self.handle_stop()?; } if !self.connect_state.has_next_tracks(None) && self.session.autoplay() { @@ -1474,8 +1480,7 @@ impl SpircTask { } else { info!("Not playing next track because there are no more tracks left in queue."); self.connect_state.reset_playback_to_position(None)?; - self.handle_stop(); - Ok(()) + self.handle_stop() } } @@ -1489,7 +1494,7 @@ impl SpircTask { None if repeat_context => self.connect_state.reset_playback_to_position(None)?, None => { self.connect_state.reset_playback_to_position(None)?; - self.handle_stop() + self.handle_stop()? } Some(_) => self.load_track(self.is_playing(), 0)?, } @@ -1599,7 +1604,7 @@ impl SpircTask { 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(); + self.handle_stop()?; return Ok(()); } From b8d3e9d63f13075eda2db0d73bab68f0c489d803 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 30 Nov 2024 20:54:28 +0100 Subject: [PATCH 118/138] 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/src/model.rs | 26 +++++++----- connect/src/spirc.rs | 76 ++++++++++++++++++++---------------- connect/src/state.rs | 2 +- connect/src/state/context.rs | 2 +- 4 files changed, 61 insertions(+), 45 deletions(-) diff --git a/connect/src/model.rs b/connect/src/model.rs index f658caffd..fbd8bd56b 100644 --- a/connect/src/model.rs +++ b/connect/src/model.rs @@ -61,7 +61,7 @@ pub(super) enum SpircPlayStatus { #[derive(Debug, Clone)] pub(super) struct ResolveContext { context: Context, - fallback: Option, + resolve_uri: 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 @@ -74,24 +74,29 @@ pub(super) struct ResolveContext { impl ResolveContext { pub fn from_uri( uri: impl Into, - fallback_uri: impl Into, + resolve_uri: impl Into, autoplay: bool, ) -> Self { + let fallback_uri = resolve_uri.into(); Self { context: Context { uri: uri.into(), ..Default::default() }, - fallback: Some(fallback_uri.into()), + resolve_uri: (!fallback_uri.is_empty()).then_some(fallback_uri), autoplay, update: true, } } pub fn from_context(context: Context, autoplay: bool) -> Self { + let resolve_uri = ConnectState::get_context_uri_from_context(&context) + .and_then(|s| (!s.is_empty()).then_some(s)) + .cloned(); + Self { context, - fallback: None, + resolve_uri, autoplay, update: true, } @@ -119,7 +124,7 @@ impl ResolveContext { uri, ..Default::default() }, - fallback: None, + resolve_uri: None, update: false, autoplay: false, } @@ -127,7 +132,7 @@ impl ResolveContext { /// the uri which should be used to resolve the context, might not be the context uri pub fn resolve_uri(&self) -> Option<&String> { - ConnectState::get_context_uri_from_context(&self.context).or(self.fallback.as_ref()) + self.resolve_uri.as_ref() } /// the actual context uri @@ -148,9 +153,9 @@ impl Display for ResolveContext { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!( f, - "context_uri: {}, resolve_uri: {:?}, autoplay: {}, update: {}", - self.context.uri, + "resolve_uri: <{:?}>, context_uri: <{}>, autoplay: <{}>, update: <{}>", self.resolve_uri(), + self.context.uri, self.autoplay, self.update ) @@ -159,11 +164,12 @@ impl Display for ResolveContext { impl PartialEq for ResolveContext { fn eq(&self, other: &Self) -> bool { - let eq_context = self.context.uri == other.context.uri; + 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_autoplay && eq_context && eq_update + eq_context && eq_resolve && eq_autoplay && eq_update } } diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 1ac56cf96..9ba054a49 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -510,10 +510,15 @@ impl SpircTask { } else { last_resolve = Some(resolve.clone()); - let resolve_uri = resolve - .resolve_uri() - .ok_or(SpircError::InvalidUri(resolve.context_uri().to_string()))?; + 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 @@ -564,21 +569,28 @@ impl SpircTask { update: bool, ) -> Result<(), Error> { if !autoplay { - match self.session.spclient().get_context(resolve_uri).await { - Err(why) => error!("failed to resolve context '{context_uri}': {why}"), - Ok(mut ctx) if update => { - ctx.uri = context_uri.to_string(); - self.connect_state.update_context(ctx, UpdateContext::Default)? - } - Ok(mut ctx) 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))?; - } - Ok(ctx) => error!("resolving context should only update the tracks, but had no page, or track. {ctx:#?}"), + let mut ctx = self.session.spclient().get_context(resolve_uri).await?; + + if update { + ctx.uri = context_uri.to_string(); + 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(()); } @@ -590,7 +602,7 @@ impl SpircTask { let previous_tracks = self.connect_state.prev_autoplay_track_uris(); debug!( - "loading autoplay context {resolve_uri} with {} previous tracks", + "loading autoplay context <{resolve_uri}> with {} previous tracks", previous_tracks.len() ); @@ -925,6 +937,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!( @@ -1090,12 +1104,7 @@ impl SpircTask { self.connect_state .reset_context(Some(&transfer.current_session.context.uri)); - let mut ctx_uri = - ConnectState::get_context_uri_from_context(&transfer.current_session.context) - .ok_or(SpircError::InvalidUri( - transfer.current_session.context.uri.clone(), - ))? - .clone(); + let mut ctx_uri = transfer.current_session.context.uri.clone(); debug!("trying to find initial track"); match self.connect_state.current_track_from_transfer(&transfer) { @@ -1113,12 +1122,12 @@ impl SpircTask { self.connect_state.fill_up_context = ContextType::Autoplay; } - let fallback_uri = self.connect_state.current_track(|t| &t.uri).clone(); + let resolve_uri = self.connect_state.current_track(|t| &t.uri).clone(); - debug!("async resolve context for {}", ctx_uri); + debug!("async resolve context for <{}>", ctx_uri); self.resolve_context.push(ResolveContext::from_uri( ctx_uri.clone(), - &fallback_uri, + &resolve_uri, false, )); @@ -1149,7 +1158,7 @@ impl SpircTask { debug!("currently in autoplay context, async resolving autoplay for {ctx_uri}"); self.resolve_context - .push(ResolveContext::from_uri(ctx_uri, fallback_uri, true)) + .push(ResolveContext::from_uri(ctx_uri, resolve_uri, true)) } self.transfer_state = Some(transfer); @@ -1431,15 +1440,13 @@ impl SpircTask { } 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 = self.connect_state.current_track(|t| &t.uri); + let resolve = ResolveContext::from_uri(current_context, resolve, true); + // When in autoplay, keep topping up the playlist when it nears the end - debug!("Preloading autoplay context for <{current_context}>"); + debug!("Preloading autoplay: {resolve}"); // resolve the next autoplay context - self.resolve_context.push(ResolveContext::from_uri( - current_context, - fallback, - true, - )); + self.resolve_context.push(resolve); } LoadNext::Empty => { debug!("next context is empty and autoplay isn't enabled, no preloading required") @@ -1450,7 +1457,10 @@ impl SpircTask { } fn is_playing(&self) -> bool { - matches!(self.play_status, SpircPlayStatus::Playing { .. }) + matches!( + self.play_status, + SpircPlayStatus::Playing { .. } | SpircPlayStatus::LoadingPlay { .. } + ) } fn handle_next(&mut self, track_uri: Option) -> Result<(), Error> { diff --git a/connect/src/state.rs b/connect/src/state.rs index bf62b097c..41406d647 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -40,7 +40,7 @@ pub enum StateError { CouldNotResolveTrackFromTransfer, #[error("message field {0} was not available")] MessageFieldNone(String), - #[error("context is not available. shuffle: {0:?}")] + #[error("context is not available. type: {0:?}")] NoContext(ContextType), #[error("could not find track {0:?} in context of {1}")] CanNotFindTrackInContext(Option, usize), diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 742142318..39417db75 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -127,7 +127,7 @@ impl ConnectState { }; debug!( - "updated context {ty:?} from {} ({} tracks) to {} ({} tracks)", + "updated context {ty:?} from <{}> ({} tracks) to <{}> ({} tracks)", self.player.context_uri, prev_context .map(|c| c.tracks.len().to_string()) From f35e4cd118aea4a52685c230048927442bdfaf7e Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 30 Nov 2024 21:39:54 +0100 Subject: [PATCH 119/138] connect: ignore failure during stop --- connect/src/spirc.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 9ba054a49..a6596ee11 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -532,7 +532,7 @@ impl SpircTask { { error!("failed resolving context <{resolve}>: {why}"); self.connect_state.reset_context(None); - self.handle_stop()? + self.handle_stop() } self.connect_state.merge_context(Some(resolve.into())); @@ -967,7 +967,7 @@ impl SpircTask { if became_inactive { info!("device became inactive"); self.connect_state.became_inactive(&self.session).await?; - self.handle_stop()?; + self.handle_stop() } else if self.connect_state.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 @@ -1168,7 +1168,7 @@ impl SpircTask { } async fn handle_disconnect(&mut self) -> Result<(), Error> { - self.handle_stop()?; + self.handle_stop(); self.play_status = SpircPlayStatus::Stopped {}; self.connect_state @@ -1183,11 +1183,14 @@ impl SpircTask { Ok(()) } - fn handle_stop(&mut self) -> Result<(), Error> { + fn handle_stop(&mut self) { self.player.stop(); self.connect_state.update_position(0, self.now_ms()); self.connect_state.clear_next_tracks(true); - self.connect_state.fill_up_next_tracks().map_err(Into::into) + + 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) { @@ -1291,7 +1294,7 @@ impl SpircTask { self.load_track(cmd.start_playing, cmd.seek_to)?; } else { info!("No active track, stopping"); - self.handle_stop()?; + self.handle_stop() } if !self.connect_state.has_next_tracks(None) && self.session.autoplay() { @@ -1490,7 +1493,8 @@ impl SpircTask { } else { info!("Not playing next track because there are no more tracks left in queue."); self.connect_state.reset_playback_to_position(None)?; - self.handle_stop() + self.handle_stop(); + Ok(()) } } @@ -1504,7 +1508,7 @@ impl SpircTask { None if repeat_context => self.connect_state.reset_playback_to_position(None)?, None => { self.connect_state.reset_playback_to_position(None)?; - self.handle_stop()? + self.handle_stop() } Some(_) => self.load_track(self.is_playing(), 0)?, } @@ -1614,7 +1618,7 @@ impl SpircTask { 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()?; + self.handle_stop(); return Ok(()); } From 7362c838e7b61f64f6fd1ffa2d7d97d90fbc2f71 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 30 Nov 2024 23:10:14 +0100 Subject: [PATCH 120/138] connect: revert resolve_uri changes --- connect/src/model.rs | 26 +++++++++++--------------- connect/src/spirc.rs | 28 +++++++++++----------------- 2 files changed, 22 insertions(+), 32 deletions(-) diff --git a/connect/src/model.rs b/connect/src/model.rs index fbd8bd56b..59a9dff1d 100644 --- a/connect/src/model.rs +++ b/connect/src/model.rs @@ -61,7 +61,7 @@ pub(super) enum SpircPlayStatus { #[derive(Debug, Clone)] pub(super) struct ResolveContext { context: Context, - resolve_uri: Option, + 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 @@ -72,31 +72,23 @@ pub(super) struct ResolveContext { } impl ResolveContext { - pub fn from_uri( - uri: impl Into, - resolve_uri: impl Into, - autoplay: bool, - ) -> Self { - let fallback_uri = resolve_uri.into(); + 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() }, - resolve_uri: (!fallback_uri.is_empty()).then_some(fallback_uri), + fallback: (!fallback_uri.is_empty()).then_some(fallback_uri), autoplay, update: true, } } pub fn from_context(context: Context, autoplay: bool) -> Self { - let resolve_uri = ConnectState::get_context_uri_from_context(&context) - .and_then(|s| (!s.is_empty()).then_some(s)) - .cloned(); - Self { context, - resolve_uri, + fallback: None, autoplay, update: true, } @@ -124,7 +116,7 @@ impl ResolveContext { uri, ..Default::default() }, - resolve_uri: None, + fallback: None, update: false, autoplay: false, } @@ -132,7 +124,11 @@ impl ResolveContext { /// the uri which should be used to resolve the context, might not be the context uri pub fn resolve_uri(&self) -> Option<&String> { - self.resolve_uri.as_ref() + // 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 diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index a6596ee11..ac61d35a2 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1122,14 +1122,11 @@ impl SpircTask { self.connect_state.fill_up_context = ContextType::Autoplay; } - let resolve_uri = self.connect_state.current_track(|t| &t.uri).clone(); + let fallback = self.connect_state.current_track(|t| &t.uri).clone(); debug!("async resolve context for <{}>", ctx_uri); - self.resolve_context.push(ResolveContext::from_uri( - ctx_uri.clone(), - &resolve_uri, - false, - )); + self.resolve_context + .push(ResolveContext::from_uri(ctx_uri.clone(), &fallback, false)); let timestamp = self.now_ms(); let state = &mut self.connect_state; @@ -1158,7 +1155,7 @@ impl SpircTask { debug!("currently in autoplay context, async resolving autoplay for {ctx_uri}"); self.resolve_context - .push(ResolveContext::from_uri(ctx_uri, resolve_uri, true)) + .push(ResolveContext::from_uri(ctx_uri, fallback, true)) } self.transfer_state = Some(transfer); @@ -1234,7 +1231,7 @@ impl SpircTask { } let current_context_uri = self.connect_state.context_uri(); - let resolve_uri = if let Some(ref ctx) = context { + 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()))?, @@ -1244,11 +1241,11 @@ impl SpircTask { } .clone(); - if current_context_uri == &cmd.context_uri && resolve_uri == cmd.context_uri { + 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(&resolve_uri, &cmd.context_uri, false, true) + self.resolve_context(&fallback, &cmd.context_uri, false, true) .await?; } @@ -1298,11 +1295,8 @@ impl SpircTask { } if !self.connect_state.has_next_tracks(None) && self.session.autoplay() { - self.resolve_context.push(ResolveContext::from_uri( - &cmd.context_uri, - resolve_uri, - true, - )) + self.resolve_context + .push(ResolveContext::from_uri(&cmd.context_uri, fallback, true)) } Ok(()) @@ -1443,8 +1437,8 @@ impl SpircTask { } LoadNext::Empty if self.session.autoplay() => { let current_context = self.connect_state.context_uri(); - let resolve = self.connect_state.current_track(|t| &t.uri); - let resolve = ResolveContext::from_uri(current_context, resolve, true); + let fallback = self.connect_state.current_track(|t| &t.uri); + let resolve = ResolveContext::from_uri(current_context, fallback, true); // When in autoplay, keep topping up the playlist when it nears the end debug!("Preloading autoplay: {resolve}"); From 421d9f56e81e80cc8b6f6fc2cd3bc6485b221c65 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 30 Nov 2024 23:24:58 +0100 Subject: [PATCH 121/138] connect: correct context reset --- connect/src/spirc.rs | 11 +++++++---- connect/src/state.rs | 19 +++++++++--------- connect/src/state/context.rs | 37 ++++++++++++++++++++++++++---------- connect/src/state/handle.rs | 9 ++++----- connect/src/state/tracks.rs | 2 +- 5 files changed, 49 insertions(+), 29 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index ac61d35a2..f450c0193 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,5 +1,5 @@ pub use crate::model::{PlayingTrack, SpircLoadCommand}; -use crate::state::metadata::Metadata; +use crate::state::{context::ResetContext, metadata::Metadata}; use crate::{ core::{ authentication::Credentials, @@ -531,7 +531,7 @@ impl SpircTask { .await { error!("failed resolving context <{resolve}>: {why}"); - self.connect_state.reset_context(None); + self.connect_state.reset_context(ResetContext::Completely); self.handle_stop() } @@ -1102,7 +1102,9 @@ impl SpircTask { fn handle_transfer(&mut self, mut transfer: TransferState) -> Result<(), Error> { self.connect_state - .reset_context(Some(&transfer.current_session.context.uri)); + .reset_context(ResetContext::WhenDifferent( + &transfer.current_session.context.uri, + )); let mut ctx_uri = transfer.current_session.context.uri.clone(); @@ -1224,7 +1226,8 @@ impl SpircTask { cmd: SpircLoadCommand, context: Option, ) -> Result<(), Error> { - self.connect_state.reset_context(Some(&cmd.context_uri)); + self.connect_state + .reset_context(ResetContext::WhenDifferent(&cmd.context_uri)); if !self.connect_state.active { self.handle_activate(); diff --git a/connect/src/state.rs b/connect/src/state.rs index 41406d647..bd69c7d3e 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -8,14 +8,15 @@ mod tracks; mod transfer; use crate::model::SpircPlayStatus; -use crate::state::context::{ContextType, StateContext}; -use crate::state::metadata::Metadata; -use crate::state::provider::{IsProvider, Provider}; -use librespot_core::config::DeviceType; -use librespot_core::date::Date; -use librespot_core::dealer::protocol::Request; -use librespot_core::spclient::SpClientResult; -use librespot_core::{version, Error, Session, SpotifyId}; +use crate::state::{ + context::{ContextType, ResetContext, StateContext}, + metadata::Metadata, + provider::{IsProvider, Provider}, +}; +use librespot_core::{ + config::DeviceType, date::Date, dealer::protocol::Request, spclient::SpClientResult, version, + Error, Session, SpotifyId, +}; use librespot_protocol::connect::{ Capabilities, Device, DeviceInfo, MemberType, PutStateReason, PutStateRequest, }; @@ -405,7 +406,7 @@ impl ConnectState { pub async fn became_inactive(&mut self, session: &Session) -> SpClientResult { self.reset(); - self.reset_context(None); + self.reset_context(ResetContext::Completely); session.spclient().put_connect_state_inactive(false).await } diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 39417db75..dfb1505f4 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -36,6 +36,13 @@ pub enum UpdateContext { Autoplay, } +pub enum ResetContext<'s> { + Completely, + DefaultIndex, + DefaultIndexWithoutAutoplay, + WhenDifferent(&'s str), +} + impl ConnectState { pub fn find_index_in_context bool>( context: Option<&StateContext>, @@ -64,21 +71,31 @@ impl ConnectState { &self.player.context_uri } - pub fn reset_context(&mut self, new_context: Option<&str>) { + pub fn reset_context(&mut self, reset_as: ResetContext) { self.active_context = ContextType::Default; self.fill_up_context = ContextType::Default; - self.autoplay_context = None; + if !matches!(reset_as, ResetContext::DefaultIndexWithoutAutoplay) { + self.autoplay_context = None; + } self.shuffle_context = None; - let reset_default_context = new_context.is_none() - || matches!(new_context, Some(ctx) if self.player.context_uri != ctx); - if reset_default_context { - self.context = None; - self.next_contexts.clear(); - } else if let Some(ctx) = self.context.as_mut() { - ctx.index.track = 0; - ctx.index.page = 0; + match reset_as { + ResetContext::Completely => { + self.context = None; + self.next_contexts.clear(); + } + ResetContext::WhenDifferent(ctx) if self.player.context_uri != ctx => { + self.context = None; + self.next_contexts.clear(); + } + ResetContext::WhenDifferent(_) => debug!("context didn't change, no reset"), + ResetContext::DefaultIndex | ResetContext::DefaultIndexWithoutAutoplay => { + if let Some(ctx) = self.context.as_mut() { + ctx.index.track = 0; + ctx.index.page = 0; + } + } } self.update_restrictions() diff --git a/connect/src/state/handle.rs b/connect/src/state/handle.rs index f53bf5f63..ba9b7d21f 100644 --- a/connect/src/state/handle.rs +++ b/connect/src/state/handle.rs @@ -1,6 +1,5 @@ -use crate::state::ConnectState; -use librespot_core::dealer::protocol::SetQueueCommand; -use librespot_core::Error; +use crate::state::{context::ResetContext, ConnectState}; +use librespot_core::{dealer::protocol::SetQueueCommand, Error}; use protobuf::MessageField; impl ConnectState { @@ -11,7 +10,7 @@ impl ConnectState { return self.shuffle(); } - self.reset_context(None); + self.reset_context(ResetContext::DefaultIndexWithoutAutoplay); if self.current_track(MessageField::is_none) { return Ok(()); @@ -51,7 +50,7 @@ impl ConnectState { if self.repeat_context() { self.set_shuffle(false); - self.reset_context(None); + self.reset_context(ResetContext::DefaultIndexWithoutAutoplay); let ctx = self.context.as_ref(); let current_track = ConnectState::find_index_in_context(ctx, |t| { diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index 1c304a408..1f0a86c8d 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -244,7 +244,7 @@ impl<'ct> ConnectState { new_index = 0; delimiter } - None if matches!(self.fill_up_context, ContextType::Default) + None if !matches!(self.fill_up_context, ContextType::Autoplay) && self.autoplay_context.is_some() => { // transition to autoplay as fill up context From 68be3d1bf0c545e73cf27c475fd9868b75b6e11c Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 30 Nov 2024 23:28:51 +0100 Subject: [PATCH 122/138] connect: reduce boiler code --- connect/src/spirc.rs | 148 +++++++++++++++++-------------------------- 1 file changed, 58 insertions(+), 90 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index f450c0193..29bdd82e8 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -338,6 +338,25 @@ 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; @@ -346,113 +365,62 @@ impl SpircTask { while !self.session.is_invalid() && !self.shutdown { let commands = self.commands.as_mut(); let player_events = self.player_events.as_mut(); + tokio::select! { - // main dealer update of any remote device updates - cluster_update = self.connect_state_update.next() => match cluster_update { - Some(result) => match result { - Ok(cluster_update) => { - if let Err(e) = self.handle_cluster_update(cluster_update).await { - error!("could not dispatch connect state update: {}", e); - } - }, - Err(e) => error!("could not parse connect state update: {}", e), - } - None => { - error!("connect state update selected, but none received"); + // 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() => match request { - Some(request) => if let Err(e) = self.handle_connect_state_request(request).await { + 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); - }, - None => { - error!("connect state command selected, but none received"); - break; } }, // volume request handling is send separately (it's more like a fire forget) - volume_update = self.connect_state_volume_update.next() => match volume_update { - Some(result) => match result { - Ok(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}") - }, - Err(e) => error!("could not parse set volume update request: {}", e), - } - None => { - error!("volume update selected, but none received"); - break; + 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() => match logout_request { - Some(result) => match result { - Ok(logout_request) => { - error!("received logout request, currently not supported: {logout_request:#?}"); - // todo: call logout handling - }, - Err(e) => error!("could not parse logout request: {}", e), - } - None => { - error!("logout request selected, but none received"); - break; + 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() => match playlist_update { - Some(result) => match result { - Ok(update) => if let Err(why) = self.handle_playlist_modification(update) { - error!("failed to handle playlist modificationL: {why}") - }, - Err(e) => error!("could not parse playlist update: {}", e), - } - None => { - error!("playlist update selected, but none received"); - break; + 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() => 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; - } + user_attributes_update = self.user_attributes_update.next() => unwrap! { + user_attributes_update, + match |attributes| self.handle_user_attributes_update(attributes) }, - connection_id_update = self.connection_id_update.next() => match connection_id_update { - Some(result) => match result { - Ok(connection_id) => if let Err(why) = self.handle_connection_id_update(connection_id).await { - error!("failed handling connection id update: {why}"); - break; - }, - Err(e) => error!("could not parse connection ID update: {}", e), - } - None => { - error!("connection ID update selected, but none received"); - break; - } + 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() => match session_update { - Some(result) => match result { - Ok(session_update) => self.handle_session_update(session_update), - Err(e) => error!("could not parse session update: {}", e), - } - None => { - error!("session update selected, but none received"); - break; - } + 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).await { From afbbcf0803e3b8dce4c5ef75b43027a66add429c Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 1 Dec 2024 15:59:55 +0100 Subject: [PATCH 123/138] connect: fix some incorrect states - uid getting replaced by empty value - shuffle/repeat clearing autoplay context - fill_up updating and using incorrect index --- connect/src/state.rs | 2 +- connect/src/state/context.rs | 34 ++++++++++++++++++++-------------- connect/src/state/handle.rs | 4 ++-- connect/src/state/tracks.rs | 6 +++++- 4 files changed, 28 insertions(+), 18 deletions(-) diff --git a/connect/src/state.rs b/connect/src/state.rs index bd69c7d3e..a6ce998ed 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -289,7 +289,7 @@ impl ConnectState { let new_index = new_index.unwrap_or(0); self.update_current_index(|i| i.track = new_index as u32); - self.update_context_index(new_index + 1)?; + self.update_context_index(self.active_context, new_index + 1)?; debug!("reset playback state to {new_index}"); diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index dfb1505f4..79bd1d433 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -39,7 +39,6 @@ pub enum UpdateContext { pub enum ResetContext<'s> { Completely, DefaultIndex, - DefaultIndexWithoutAutoplay, WhenDifferent(&'s str), } @@ -71,27 +70,28 @@ impl ConnectState { &self.player.context_uri } - pub fn reset_context(&mut self, reset_as: ResetContext) { + pub fn reset_context(&mut self, mut reset_as: ResetContext) { self.active_context = ContextType::Default; self.fill_up_context = ContextType::Default; - if !matches!(reset_as, ResetContext::DefaultIndexWithoutAutoplay) { - self.autoplay_context = None; + if matches!(reset_as, ResetContext::WhenDifferent(ctx) if self.player.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(ctx) if self.player.context_uri != ctx => { - self.context = None; - self.next_contexts.clear(); + self.player.context_restrictions.clear() } ResetContext::WhenDifferent(_) => debug!("context didn't change, no reset"), - ResetContext::DefaultIndex | ResetContext::DefaultIndexWithoutAutoplay => { - if let Some(ctx) = self.context.as_mut() { + 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; } @@ -263,20 +263,26 @@ impl ConnectState { } // the uid provided from another context might be actual uid of an item - context_track.uid = new_track.uid; + if !new_track.uid.is_empty() { + context_track.uid = new_track.uid; + } } } Some(()) } - pub(super) fn update_context_index(&mut self, new_index: usize) -> Result<(), StateError> { - let context = match self.active_context { + 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(self.active_context))?; + .ok_or(StateError::NoContext(ty))?; context.index.track = new_index as u32; Ok(()) diff --git a/connect/src/state/handle.rs b/connect/src/state/handle.rs index ba9b7d21f..a69e1ebe1 100644 --- a/connect/src/state/handle.rs +++ b/connect/src/state/handle.rs @@ -10,7 +10,7 @@ impl ConnectState { return self.shuffle(); } - self.reset_context(ResetContext::DefaultIndexWithoutAutoplay); + self.reset_context(ResetContext::DefaultIndex); if self.current_track(MessageField::is_none) { return Ok(()); @@ -50,7 +50,7 @@ impl ConnectState { if self.repeat_context() { self.set_shuffle(false); - self.reset_context(ResetContext::DefaultIndexWithoutAutoplay); + self.reset_context(ResetContext::DefaultIndex); let ctx = self.context.as_ref(); let current_track = ConnectState::find_index_in_context(ctx, |t| { diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index 1f0a86c8d..6746e9682 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -247,8 +247,12 @@ impl<'ct> ConnectState { 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()) } @@ -279,7 +283,7 @@ impl<'ct> ConnectState { self.next_tracks.push_back(track); } - self.update_context_index(new_index)?; + 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(); From 87a5569a40c0be3cfa5c80dcaf920b5118f2a9e9 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 3 Dec 2024 17:29:42 +0100 Subject: [PATCH 124/138] core: adjust incorrect separator --- core/src/spclient.rs | 16 ++++++++-------- core/src/util.rs | 7 +++++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/core/src/spclient.rs b/core/src/spclient.rs index 32940e4a2..2836c413f 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -451,27 +451,27 @@ 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. - if options.metrics { + if options.metrics && !url.contains("product=0") { let _ = write!( url, "{}product=0&country={}", - separator, + util::get_next_query_separator(&url), self.session().country() ); } // Defeat caches. Spotify-generated URLs already contain this. if options.salt && !url.contains("salt=") { - let _ = write!(url, "{separator}salt={}", rand::thread_rng().next_u32()); + let _ = write!( + url, + "{}salt={}", + util::get_next_query_separator(&url), + rand::thread_rng().next_u32() + ); } let mut request = Request::builder() diff --git a/core/src/util.rs b/core/src/util.rs index 31cdd962b..c48378afd 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 => "?", + } +} From 239e61d914583877cb2647d11ccc2f75683d738f Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 3 Dec 2024 18:52:12 +0100 Subject: [PATCH 125/138] connect: move `add_to_queue` and `mark_unavailable` into tracks.rs --- connect/src/state.rs | 57 ------------------------------------- connect/src/state/tracks.rs | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 57 deletions(-) diff --git a/connect/src/state.rs b/connect/src/state.rs index a6ce998ed..9c9541e77 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -322,63 +322,6 @@ impl ConnectState { 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); - } - - if let Some(next_not_queued_track) = - self.next_tracks.iter().position(|track| !track.is_queue()) - { - self.next_tracks.insert(next_not_queued_track, track); - } else { - self.next_tracks.push_back(track) - } - - while self.next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE { - self.next_tracks.pop_back(); - } - - if rev_update { - self.update_queue_revision(); - } - self.update_restrictions(); - } - - pub fn mark_unavailable(&mut self, id: SpotifyId) -> Result<(), Error> { - let uri = id.to_uri()?; - - debug!("marking {uri} as unavailable"); - - for next_track in &mut self.next_tracks { - Self::mark_as_unavailable_for_match(next_track, &uri) - } - - for prev_track in &mut self.prev_tracks { - Self::mark_as_unavailable_for_match(prev_track, &uri) - } - - if self.player.track.uri != uri { - while let Some(pos) = self.next_tracks.iter().position(|t| t.uri == uri) { - let _ = self.next_tracks.remove(pos); - } - - while let Some(pos) = self.prev_tracks.iter().position(|t| t.uri == uri) { - let _ = self.prev_tracks.remove(pos); - } - - self.unavailable_uri.push(uri); - self.fill_up_next_tracks()?; - self.update_queue_revision(); - } - - Ok(()) - } - fn mark_as_unavailable_for_match(track: &mut ProvidedTrack, uri: &str) { if track.uri == uri { debug!("Marked <{}:{}> as unavailable", track.provider, track.uri); diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index 6746e9682..76d9b9dba 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -323,4 +323,61 @@ impl<'ct> ConnectState { prev } + + pub fn mark_unavailable(&mut self, id: SpotifyId) -> Result<(), Error> { + let uri = id.to_uri()?; + + debug!("marking {uri} as unavailable"); + + for next_track in &mut self.next_tracks { + Self::mark_as_unavailable_for_match(next_track, &uri) + } + + for prev_track in &mut self.prev_tracks { + Self::mark_as_unavailable_for_match(prev_track, &uri) + } + + if self.player.track.uri != uri { + while let Some(pos) = self.next_tracks.iter().position(|t| t.uri == uri) { + let _ = self.next_tracks.remove(pos); + } + + while let Some(pos) = self.prev_tracks.iter().position(|t| t.uri == uri) { + let _ = self.prev_tracks.remove(pos); + } + + 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); + } + + if let Some(next_not_queued_track) = + self.next_tracks.iter().position(|track| !track.is_queue()) + { + self.next_tracks.insert(next_not_queued_track, track); + } else { + self.next_tracks.push_back(track) + } + + while self.next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE { + self.next_tracks.pop_back(); + } + + if rev_update { + self.update_queue_revision(); + } + self.update_restrictions(); + } } From 3f38945fc7db554848c694d8b11cd5c8fb3920da Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 3 Dec 2024 20:12:59 +0100 Subject: [PATCH 126/138] connect: refactor - directly modify PutStateRequest - replace `next_tracks`, `prev_tracks`, `player` and `device` with `request` - provide helper methods for the removed fields --- connect/src/spirc.rs | 49 +++-- connect/src/state.rs | 346 ++++++++++++++++-------------- connect/src/state/context.rs | 36 ++-- connect/src/state/options.rs | 25 +-- connect/src/state/restrictions.rs | 20 +- connect/src/state/tracks.rs | 199 ++++++++++------- connect/src/state/transfer.rs | 45 ++-- core/src/spclient.rs | 4 +- 8 files changed, 400 insertions(+), 324 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 29bdd82e8..8b04f5747 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -16,7 +16,7 @@ use crate::{ }, protocol::{ autoplay_context_request::AutoplayContextRequest, - connect::{Cluster, ClusterUpdate, LogoutCommand, PutStateReason, SetVolumeCommand}, + connect::{Cluster, ClusterUpdate, LogoutCommand, SetVolumeCommand}, explicit_content_pubsub::UserAttributesUpdate, player::{Context, TransferState}, playlist4_external::PlaylistModificationInfo, @@ -270,8 +270,8 @@ impl Spirc { let spirc = Spirc { commands: cmd_tx }; - let initial_volume = task.connect_state.device.volume; - task.connect_state.device.volume = 0; + let initial_volume = task.connect_state.device_info().volume; + task.connect_state.set_volume(0); match initial_volume.try_into() { Ok(volume) => { @@ -440,8 +440,8 @@ impl SpircTask { _ = 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.volume); - if let Err(why) = self.connect_state.update_state(&self.session, PutStateReason::VOLUME_CHANGED).await { + 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}") } @@ -456,7 +456,7 @@ impl SpircTask { } } - if !self.shutdown && self.connect_state.active { + 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}") } @@ -590,6 +590,7 @@ impl SpircTask { .update_context(context, UpdateContext::Autoplay) } + // todo: time_delta still necessary? fn now_ms(&self) -> i64 { let dur = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -607,7 +608,7 @@ impl SpircTask { rx.close() } Ok(()) - } else if self.connect_state.active { + } else if self.connect_state.is_active() { trace!("Received SpircCommand::{:?}", cmd); match cmd { SpircCommand::Play => { @@ -818,7 +819,7 @@ impl SpircTask { let cluster = match self .connect_state - .update_state(&self.session, PutStateReason::NEW_DEVICE) + .notify_new_device_appeared(&self.session) .await { Ok(res) => Cluster::parse_from_bytes(&res).ok(), @@ -930,13 +931,13 @@ impl SpircTask { ); if let Some(cluster) = cluster_update.cluster.take() { - let became_inactive = - self.connect_state.active && cluster.active_device_id != self.session.device_id(); + 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.active { + } 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" @@ -951,7 +952,7 @@ impl SpircTask { &mut self, (request, sender): RequestReply, ) -> Result<(), Error> { - self.connect_state.last_command = Some(request.clone()); + self.connect_state.set_last_command(request.clone()); debug!( "handling: '{}' from {}", @@ -1119,9 +1120,7 @@ impl SpircTask { if self.connect_state.context.is_some() { self.connect_state.setup_state_from_transfer(transfer)?; } else { - if self.connect_state.autoplay_context.is_none() - && (self.connect_state.current_track(|t| t.is_autoplay()) || autoplay) - { + if self.connect_state.current_track(|t| t.is_autoplay()) || autoplay { debug!("currently in autoplay context, async resolving autoplay for {ctx_uri}"); self.resolve_context @@ -1172,7 +1171,7 @@ impl SpircTask { ); self.player - .emit_volume_changed_event(self.connect_state.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()); @@ -1197,7 +1196,7 @@ impl SpircTask { self.connect_state .reset_context(ResetContext::WhenDifferent(&cmd.context_uri)); - if !self.connect_state.active { + if !self.connect_state.is_active() { self.handle_activate(); } @@ -1485,12 +1484,14 @@ impl SpircTask { } fn handle_volume_up(&mut self) { - let volume = (self.connect_state.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.connect_state.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); } @@ -1611,24 +1612,26 @@ impl SpircTask { .update_position_in_relation(self.now_ms()); } + self.connect_state.set_now(self.now_ms() as u64); + self.connect_state - .update_state(&self.session, PutStateReason::PLAYER_STATE_CHANGED) + .send_state(&self.session) .await .map(|_| ()) } fn set_volume(&mut self, volume: u16) { - let old_volume = self.connect_state.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.update_volume = true; - self.connect_state.device.volume = new_volume; + 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.connect_state.active { + if self.connect_state.is_active() { self.player.emit_volume_changed_event(volume); } } diff --git a/connect/src/state.rs b/connect/src/state.rs index 9c9541e77..deaf783d5 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -10,12 +10,11 @@ mod transfer; use crate::model::SpircPlayStatus; use crate::state::{ context::{ContextType, ResetContext, StateContext}, - metadata::Metadata, provider::{IsProvider, Provider}, }; use librespot_core::{ config::DeviceType, date::Date, dealer::protocol::Request, spclient::SpClientResult, version, - Error, Session, SpotifyId, + Error, Session, }; use librespot_protocol::connect::{ Capabilities, Device, DeviceInfo, MemberType, PutStateReason, PutStateRequest, @@ -26,9 +25,11 @@ use librespot_protocol::player::{ }; use log::LevelFilter; use protobuf::{EnumOrUnknown, MessageField}; -use std::collections::{hash_map::DefaultHasher, VecDeque}; -use std::hash::{Hash, Hasher}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +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 @@ -95,25 +96,13 @@ impl Default for ConnectStateConfig { #[derive(Default, Debug)] pub struct ConnectState { - pub session_id: String, - pub active: bool, - pub active_since: Option, - - pub has_been_playing_for: Option, - - pub device: DeviceInfo, + /// the entire state that is updated to the remote server + request: PutStateRequest, unavailable_uri: Vec, - /// index: 0 based, so the first track is index 0 - player: PlayerState, - - // we don't work directly on the track lists of the player state, because - // we mostly need to push and pop at the beginning of them - /// bottom => top, aka the last track of the list is the prev track - prev_tracks: VecDeque, - /// top => bottom, aka the first track of the list is the next track - next_tracks: VecDeque, + 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 @@ -121,131 +110,196 @@ pub struct ConnectState { pub fill_up_context: ContextType, /// the context from which we play, is used to top up prev and next tracks - /// the index is used to keep track which tracks are already loaded into next tracks pub context: Option, - /// upcoming contexts, usually directly provided by the context-resolver - pub next_contexts: Vec, - /// a context to keep track of our shuffled context, should be only available when option.shuffling_context is true - pub shuffle_context: Option, - /// a context to keep track of the autoplay context - pub autoplay_context: Option, - - pub queue_count: u64, + /// upcoming contexts, directly provided by the context-resolver + next_contexts: Vec, - pub last_command: Option, + /// 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 { - session_id: cfg.session_id, - device: 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, + 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() }, - // + 1, so that we have a buffer where we can swap elements - prev_tracks: VecDeque::with_capacity(SPOTIFY_MAX_PREV_TRACKS_SIZE + 1), - next_tracks: VecDeque::with_capacity(SPOTIFY_MAX_NEXT_TRACKS_SIZE + 1), ..Default::default() }; state.reset(); state } - pub fn reset(&mut self) { + fn reset(&mut self) { self.set_active(false); self.queue_count = 0; - self.player = PlayerState { - session_id: self.session_id.clone(), + // 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.active { + if self.request.is_active { return; } - self.active = true; + self.request.is_active = true; self.active_since = Some(SystemTime::now()) } else { - self.active = false; + self.request.is_active = false; self.active_since = None } } pub fn set_origin(&mut self, origin: PlayOrigin) { - self.player.play_origin = MessageField::some(origin) + self.player_mut().play_origin = MessageField::some(origin) } pub fn set_session_id(&mut self, session_id: String) { - self.session_id = session_id.clone(); - self.player.session_id = session_id; + self.player_mut().session_id = session_id; } pub(crate) fn set_status(&mut self, status: &SpircPlayStatus) { - self.player.is_paused = matches!( + let player = self.player_mut(); + player.is_paused = matches!( status, SpircPlayStatus::LoadingPause { .. } | SpircPlayStatus::Paused { .. } | SpircPlayStatus::Stopped ); - // desktop and mobile want all 'states' set to true, when we are paused, + // 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 - self.player.is_buffering = self.player.is_paused + player.is_buffering = player.is_paused || matches!( status, SpircPlayStatus::LoadingPause { .. } | SpircPlayStatus::LoadingPlay { .. } ); - self.player.is_playing = self.player.is_paused + player.is_playing = player.is_paused || matches!( status, SpircPlayStatus::LoadingPlay { .. } | SpircPlayStatus::Playing { .. } @@ -253,36 +307,40 @@ impl ConnectState { debug!( "updated connect play status playing: {}, paused: {}, buffering: {}", - self.player.is_playing, self.player.is_paused, self.player.is_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.index.as_mut() { + 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.index = MessageField::some(new_index) + self.player_mut().index = MessageField::some(new_index) } } } pub fn update_position(&mut self, position_ms: u32, timestamp: i64) { - self.player.position_as_of_timestamp = position_ms.into(); - self.player.timestamp = timestamp; + 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.duration = duration.into() + 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.queue_revision = state.finish().to_string() + 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> { @@ -293,17 +351,17 @@ impl ConnectState { debug!("reset playback state to {new_index}"); - if !self.player.track.is_queue() { + if !self.current_track(|t| t.is_queue()) { self.set_current_track(new_index)?; } - self.prev_tracks.clear(); + 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.prev_tracks = context + self.player_mut().prev_tracks = context .tracks .iter() .rev() @@ -312,7 +370,7 @@ impl ConnectState { .rev() .cloned() .collect(); - debug!("has {} prev tracks", self.prev_tracks.len()) + debug!("has {} prev tracks", self.prev_tracks().len()) } self.clear_next_tracks(true); @@ -330,11 +388,13 @@ impl ConnectState { } pub fn update_position_in_relation(&mut self, timestamp: i64) { - let diff = timestamp - self.player.timestamp; - self.player.position_as_of_timestamp += diff; + 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(self.player.position_as_of_timestamp as u64); + 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()); @@ -344,7 +404,7 @@ impl ConnectState { debug!("update position to {min}:{sec:0>2} at {time}"); } - self.player.timestamp = timestamp; + player.timestamp = timestamp; } pub async fn became_inactive(&mut self, session: &Session) -> SpClientResult { @@ -354,67 +414,37 @@ impl ConnectState { session.spclient().put_connect_state_inactive(false).await } - /// Updates the connect state for the connect session - /// - /// Prepares a [PutStateRequest] from the current connect state - pub async fn update_state(&self, session: &Session, reason: PutStateReason) -> SpClientResult { - if matches!(reason, PutStateReason::BECAME_INACTIVE) { - warn!("should use instead") - } - - let now = SystemTime::now(); - let since_the_epoch = now.duration_since(UNIX_EPOCH).expect("Time went backwards"); - - let client_side_timestamp = u64::try_from(since_the_epoch.as_millis())?; - let member_type = EnumOrUnknown::new(MemberType::CONNECT_STATE); - let put_state_reason = EnumOrUnknown::new(reason); - - let mut player_state = self.player.clone(); - // we copy the player state, which only contains the infos, not the next and prev tracks - // cloning seems to be fine, because the cloned lists get dropped after the method call - player_state.next_tracks = self.next_tracks.clone().into(); - player_state.prev_tracks = self.prev_tracks.clone().into(); + async fn send_with_reason( + &mut self, + session: &Session, + reason: PutStateReason, + ) -> SpClientResult { + let prev_reason = self.request.put_state_reason; - let is_active = self.active; - let device = MessageField::some(Device { - device_info: MessageField::some(self.device.clone()), - player_state: MessageField::some(player_state), - ..Default::default() - }); + self.request.put_state_reason = EnumOrUnknown::new(reason); + let res = self.send_state(session).await; - let mut put_state = PutStateRequest { - client_side_timestamp, - member_type, - put_state_reason, - is_active, - device, - ..Default::default() - }; - - if let Some(has_been_playing_for) = self.has_been_playing_for { - match has_been_playing_for.elapsed().as_millis().try_into() { - Ok(ms) => put_state.has_been_playing_for_ms = ms, - Err(why) => warn!("couldn't update has been playing for because {why}"), - } - } + self.request.put_state_reason = prev_reason; + res + } - 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) => put_state.started_playing_at = active_since_ms, - Err(why) => warn!("couldn't update active since because {why}"), - } - } - } + /// 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 + } - if let Some(request) = self.last_command.clone() { - put_state.last_command_message_id = request.message_id; - put_state.last_command_sent_by_device_id = request.sent_by_device_id; - } + /// 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(put_state) + .put_connect_state_request(&self.request) .await } } diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 79bd1d433..21935596b 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -13,6 +13,7 @@ const SEARCH_IDENTIFIER: &str = "spotify:search"; pub struct StateContext { pub tracks: Vec, pub metadata: HashMap, + /// is used to keep track which tracks are already loaded into the next_tracks pub index: ContextIndex, } @@ -67,14 +68,14 @@ impl ConnectState { } pub fn context_uri(&self) -> &String { - &self.player.context_uri + &self.player().context_uri } pub fn reset_context(&mut self, mut reset_as: ResetContext) { self.active_context = ContextType::Default; self.fill_up_context = ContextType::Default; - if matches!(reset_as, ResetContext::WhenDifferent(ctx) if self.player.context_uri != ctx) { + if matches!(reset_as, ResetContext::WhenDifferent(ctx) if self.context_uri() != ctx) { reset_as = ResetContext::Completely } self.shuffle_context = None; @@ -84,7 +85,7 @@ impl ConnectState { self.context = None; self.autoplay_context = None; self.next_contexts.clear(); - self.player.context_restrictions.clear() + self.player_mut().context_restrictions.clear() } ResetContext::WhenDifferent(_) => debug!("context didn't change, no reset"), ResetContext::DefaultIndex => { @@ -145,7 +146,7 @@ impl ConnectState { debug!( "updated context {ty:?} from <{}> ({} tracks) to <{}> ({} tracks)", - self.player.context_uri, + self.context_uri(), prev_context .map(|c| c.tracks.len().to_string()) .unwrap_or_else(|| "-".to_string()), @@ -153,17 +154,18 @@ impl ConnectState { page.tracks.len() ); + let player = self.player_mut(); if context.restrictions.is_some() { - self.player.restrictions = context.restrictions.clone(); - self.player.context_restrictions = context.restrictions; + player.restrictions = context.restrictions.clone(); + player.context_restrictions = context.restrictions; } else { - self.player.context_restrictions = Default::default(); - self.player.restrictions = Default::default() + player.context_restrictions = Default::default(); + player.restrictions = Default::default() } - self.player.context_metadata.clear(); + player.context_metadata.clear(); for (key, value) in context.metadata { - self.player.context_metadata.insert(key, value); + player.context_metadata.insert(key, value); } match ty { @@ -172,9 +174,9 @@ impl ConnectState { // when we update the same context, we should try to preserve the previous position // otherwise we might load the entire context twice - if self.player.context_uri == context.uri { + if self.context_uri() == &context.uri { match Self::find_index_in_context(Some(&new_context), |t| { - self.player.track.uri == t.uri + 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}"); @@ -183,7 +185,7 @@ impl ConnectState { // 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 + new_context.index.track = self.player().index.track } Err(_) => {} } @@ -193,8 +195,8 @@ impl ConnectState { self.context = Some(new_context); - self.player.context_url = format!("context://{}", &context.uri); - self.player.context_uri = context.uri; + self.player_mut().context_url = format!("context://{}", &context.uri); + self.player_mut().context_uri = context.uri; } UpdateContext::Autoplay => { self.autoplay_context = Some(self.state_context_from_page( @@ -214,7 +216,7 @@ impl ConnectState { new_context_uri: Option<&str>, provider: Option, ) -> StateContext { - let new_context_uri = new_context_uri.unwrap_or(&self.player.context_uri); + let new_context_uri = new_context_uri.unwrap_or(self.context_uri()); let tracks = page .tracks @@ -240,7 +242,7 @@ impl ConnectState { pub fn merge_context(&mut self, context: Option) -> Option<()> { let mut context = context?; - if context.uri != self.player.context_uri { + if self.context_uri() != &context.uri { return None; } diff --git a/connect/src/state/options.rs b/connect/src/state/options.rs index 5e2c769d4..b42b41b17 100644 --- a/connect/src/state/options.rs +++ b/connect/src/state/options.rs @@ -7,35 +7,35 @@ use rand::prelude::SliceRandom; impl ConnectState { fn add_options_if_empty(&mut self) { - if self.player.options.is_none() { - self.player.options = MessageField::some(ContextPlayerOptions::new()) + 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.options.as_mut() { + 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.options.as_mut() { + 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.options.as_mut() { + 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 + .player() .restrictions .disallow_toggling_shuffle_reasons .first() @@ -46,15 +46,16 @@ impl ConnectState { })? } - self.prev_tracks.clear(); + self.clear_prev_track(); self.clear_next_tracks(true); - let current_uri = &self.player.track.uri; + let current_uri = self.current_track(|t| &t.uri); let ctx = self .context - .as_mut() + .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(); @@ -74,14 +75,14 @@ impl ConnectState { } pub fn shuffling_context(&self) -> bool { - self.player.options.shuffling_context + self.player().options.shuffling_context } pub fn repeat_context(&self) -> bool { - self.player.options.repeating_context + self.player().options.repeating_context } pub fn repeat_track(&self) -> bool { - self.player.options.repeating_track + self.player().options.repeating_track } } diff --git a/connect/src/state/restrictions.rs b/connect/src/state/restrictions.rs index 088a7931b..f072fabd4 100644 --- a/connect/src/state/restrictions.rs +++ b/connect/src/state/restrictions.rs @@ -9,24 +9,26 @@ impl ConnectState { const AUTOPLAY: &str = "autoplay"; const ENDLESS_CONTEXT: &str = "endless_context"; - if let Some(restrictions) = self.player.restrictions.as_mut() { - if self.player.is_playing { + 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 self.player.is_paused { + if player.is_paused { restrictions.disallow_resuming_reasons.clear(); restrictions.disallow_pausing_reasons = vec!["not_playing".to_string()] } } - if self.player.restrictions.is_none() { - self.player.restrictions = MessageField::some(Restrictions::new()) + if player.restrictions.is_none() { + player.restrictions = MessageField::some(Restrictions::new()) } - if let Some(restrictions) = self.player.restrictions.as_mut() { - if self.prev_tracks.is_empty() { + 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 { @@ -34,11 +36,11 @@ impl ConnectState { restrictions.disallow_skipping_prev_reasons.clear(); } - if self.player.track.is_autoplay() { + 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 self.player.options.repeating_context { + } else if player.options.repeating_context { restrictions.disallow_toggling_shuffle_reasons = vec![ENDLESS_CONTEXT.to_string()] } else { restrictions.disallow_toggling_shuffle_reasons.clear(); diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index 76d9b9dba..6c301e94a 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -1,13 +1,12 @@ -use crate::state::context::ContextType; -use crate::state::metadata::Metadata; -use crate::state::provider::{IsProvider, Provider}; 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; -use std::collections::VecDeque; // identifier used as part of the uid pub const IDENTIFIER_DELIMITER: &str = "delimiter"; @@ -26,6 +25,45 @@ impl<'ct> ConnectState { 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)?; @@ -44,7 +82,7 @@ impl<'ct> ConnectState { context.tracks.len() ); - self.player.track = MessageField::some(new_track.clone()); + self.set_track(new_track.clone()); self.update_current_index(|i| i.track = index as u32); @@ -61,29 +99,24 @@ impl<'ct> ConnectState { self.set_repeat_track(false); } - let old_track = self.player.track.take(); + 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() { - // add old current track to prev tracks, while preserving a length of 10 - if self.prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { - let _ = self.prev_tracks.pop_front(); - } - self.prev_tracks.push_back(old_track); + self.push_prev(old_track) } } - let new_track = match self.next_tracks.pop_front() { - Some(next) if next.uid.starts_with(IDENTIFIER_DELIMITER) => { - if self.prev_tracks.len() >= SPOTIFY_MAX_PREV_TRACKS_SIZE { - let _ = self.prev_tracks.pop_front(); + let new_track = loop { + match self.get_next_track() { + Some(next) if next.uid.starts_with(IDENTIFIER_DELIMITER) => { + self.push_prev(next); + continue; } - self.prev_tracks.push_back(next); - self.next_tracks.pop_front() - } - Some(next) if next.is_unavailable() => self.next_tracks.pop_front(), - other => other, + Some(next) if next.is_unavailable() => continue, + other => break other, + }; }; let new_track = match new_track { @@ -112,13 +145,14 @@ impl<'ct> ConnectState { if let Some(update_index) = update_index { self.update_current_index(|i| i.track = update_index) + } else { + self.player_mut().index.clear() } - self.player.track = MessageField::some(new_track); - + self.set_track(new_track); self.update_restrictions(); - Ok(Some(self.player.index.track)) + Ok(Some(self.player().index.track)) } /// Move to the prev track @@ -127,32 +161,36 @@ impl<'ct> ConnectState { /// to next tracks (when from the context) and fills up the prev tracks from the /// current context pub fn prev_track(&mut self) -> Result>, StateError> { - let old_track = self.player.track.take(); + let old_track = self.player_mut().track.take(); if let Some(old_track) = old_track { if old_track.is_context() || old_track.is_autoplay() { - self.next_tracks.push_front(old_track); + // todo: O(n) + self.next_tracks_mut().insert(0, old_track); } } // handle possible delimiter - if matches!(self.prev_tracks.back(), Some(prev) if prev.uid.starts_with(IDENTIFIER_DELIMITER)) + if matches!(self.prev_tracks().last(), Some(prev) if prev.uid.starts_with(IDENTIFIER_DELIMITER)) { let delimiter = self - .prev_tracks - .pop_back() + .prev_tracks_mut() + .pop() .expect("item that was prechecked"); - if self.next_tracks.len() >= SPOTIFY_MAX_NEXT_TRACKS_SIZE { - let _ = self.next_tracks.pop_back(); + + let next_tracks = self.next_tracks_mut(); + if next_tracks.len() >= SPOTIFY_MAX_NEXT_TRACKS_SIZE { + let _ = next_tracks.pop(); } - self.next_tracks.push_front(delimiter) + // todo: O(n) + next_tracks.insert(0, delimiter) } - while self.next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE { - let _ = self.next_tracks.pop_back(); + while self.next_tracks().len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE { + let _ = self.next_tracks_mut().pop(); } - let new_track = match self.prev_tracks.pop_back() { + let new_track = match self.prev_tracks_mut().pop() { None => return Ok(None), Some(t) => t, }; @@ -163,10 +201,9 @@ impl<'ct> ConnectState { } self.fill_up_next_tracks()?; + self.set_track(new_track); - self.player.track = MessageField::some(new_track); - - if self.player.index.track == 0 { + 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) @@ -174,18 +211,18 @@ impl<'ct> ConnectState { self.update_restrictions(); - Ok(Some(&self.player.track)) + Ok(Some(self.current_track(|t| t))) } pub fn current_track) -> R, R>( &'ct self, access: F, ) -> R { - access(&self.player.track) + access(&self.player().track) } pub fn set_track(&mut self, track: ProvidedTrack) { - self.player.track = MessageField::some(track) + self.player_mut().track = MessageField::some(track) } pub fn set_next_tracks(&mut self, mut tracks: Vec) { @@ -203,30 +240,34 @@ impl<'ct> ConnectState { self.queue_count += 1; }); - self.next_tracks = tracks.into(); + self.player_mut().next_tracks = tracks; + } + + pub fn set_prev_tracks(&mut self, tracks: Vec) { + self.player_mut().prev_tracks = tracks; } - pub fn set_prev_tracks(&mut self, tracks: impl Into>) { - self.prev_tracks = tracks.into(); + 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.clear(); + 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 + .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.pop_back().is_some() - { - } + while self.next_tracks().len() > non_queued_track + && self.next_tracks_mut().pop().is_some() + {} } } @@ -235,7 +276,7 @@ impl<'ct> ConnectState { 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 { + 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() => { @@ -280,7 +321,7 @@ impl<'ct> ConnectState { } }; - self.next_tracks.push_back(track); + self.next_tracks_mut().push(track); } self.update_context_index(self.fill_up_context, new_index)?; @@ -293,9 +334,9 @@ impl<'ct> ConnectState { pub fn preview_next_track(&mut self) -> Option { let next = if self.repeat_track() { - &self.player.track.uri + self.current_track(|t| &t.uri) } else { - &self.next_tracks.front()?.uri + &self.next_tracks().first()?.uri }; SpotifyId::from_uri(next).ok() @@ -303,22 +344,21 @@ impl<'ct> ConnectState { pub fn has_next_tracks(&self, min: Option) -> bool { if let Some(min) = min { - self.next_tracks.len() >= min + self.next_tracks().len() >= min } else { - !self.next_tracks.is_empty() + !self.next_tracks().is_empty() } } pub fn prev_autoplay_track_uris(&self) -> Vec { let mut prev = self - .prev_tracks + .prev_tracks() .iter() .flat_map(|t| t.is_autoplay().then_some(t.uri.clone())) .collect::>(); - let current = &self.player.track; - if current.is_autoplay() { - prev.push(current.uri.clone()); + if self.current_track(|t| t.is_autoplay()) { + prev.push(self.current_track(|t| t.uri.clone())); } prev @@ -329,28 +369,28 @@ impl<'ct> ConnectState { debug!("marking {uri} as unavailable"); - for next_track in &mut self.next_tracks { - Self::mark_as_unavailable_for_match(next_track, &uri) + 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 prev_track in &mut self.prev_tracks { - Self::mark_as_unavailable_for_match(prev_track, &uri) + for next_track in next_tracks { + Self::mark_as_unavailable_for_match(next_track, &uri) } - if self.player.track.uri != uri { - while let Some(pos) = self.next_tracks.iter().position(|t| t.uri == uri) { - let _ = self.next_tracks.remove(pos); - } - - while let Some(pos) = self.prev_tracks.iter().position(|t| t.uri == uri) { - let _ = self.prev_tracks.remove(pos); - } + let prev_tracks = self.prev_tracks_mut(); + while let Some(pos) = prev_tracks.iter().position(|t| t.uri == uri) { + let _ = prev_tracks.remove(pos); + } - self.unavailable_uri.push(uri); - self.fill_up_next_tracks()?; - self.update_queue_revision(); + 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(()) } @@ -363,16 +403,15 @@ impl<'ct> ConnectState { track.set_queued(true); } - if let Some(next_not_queued_track) = - self.next_tracks.iter().position(|track| !track.is_queue()) - { - self.next_tracks.insert(next_not_queued_track, track); + 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 { - self.next_tracks.push_back(track) + next_tracks.push(track) } - while self.next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE { - self.next_tracks.pop_back(); + while next_tracks.len() > SPOTIFY_MAX_NEXT_TRACKS_SIZE { + next_tracks.pop(); } if rev_update { diff --git a/connect/src/state/transfer.rs b/connect/src/state/transfer.rs index eb24d8dca..4a59e91c5 100644 --- a/connect/src/state/transfer.rs +++ b/connect/src/state/transfer.rs @@ -24,53 +24,52 @@ impl ConnectState { } pub fn handle_initial_transfer(&mut self, transfer: &mut TransferState, ctx_uri: String) { - self.player.is_buffering = false; + 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() { - self.player.options = MessageField::some(options); + player.options = MessageField::some(options); } - self.player.is_paused = transfer.playback.is_paused; - self.player.is_playing = !transfer.playback.is_paused; + player.is_paused = transfer.playback.is_paused; + player.is_playing = !transfer.playback.is_paused; if transfer.playback.playback_speed != 0. { - self.player.playback_speed = transfer.playback.playback_speed + player.playback_speed = transfer.playback.playback_speed } else { - self.player.playback_speed = 1.; + player.playback_speed = 1.; } - self.player.play_origin = transfer.current_session.play_origin.clone(); + player.play_origin = transfer.current_session.play_origin.clone(); if let Some(suppressions) = transfer.current_session.suppressions.as_ref() { - self.player.suppressions = MessageField::some(suppressions.clone()); + player.suppressions = MessageField::some(suppressions.clone()); } - self.player.context_url = format!("context://{ctx_uri}"); - self.player.context_uri = ctx_uri; + player.context_url = format!("context://{ctx_uri}"); + player.context_uri = ctx_uri; if let Some(context) = transfer.current_session.context.as_ref() { - self.player.context_restrictions = context.restrictions.clone(); + player.context_restrictions = context.restrictions.clone(); } for (key, value) in &transfer.current_session.context.metadata { - self.player - .context_metadata - .insert(key.clone(), value.clone()); + player.context_metadata.insert(key.clone(), value.clone()); } - if let Some(context) = &self.context { - for (key, value) in &context.metadata { - self.player - .context_metadata - .insert(key.clone(), value.clone()); + if let Some(metadata) = current_context_metadata { + for (key, value) in metadata { + player.context_metadata.insert(key, value); } } - self.prev_tracks.clear(); + self.clear_prev_track(); self.clear_next_tracks(false); } pub fn setup_state_from_transfer(&mut self, transfer: TransferState) -> Result<(), Error> { - let track = match self.player.track.as_ref() { + let track = match self.player().track.as_ref() { None => self.current_track_from_transfer(&transfer)?, Some(track) => track.clone(), }; @@ -91,8 +90,8 @@ impl ConnectState { ctx.map(|c| c.tracks.len()).unwrap_or_default() ); - if self.player.track.is_none() { - self.player.track = MessageField::some(track); + if self.player().track.is_none() { + self.set_track(track); } let current_index = current_index.ok(); diff --git a/core/src/spclient.rs b/core/src/spclient.rs index 2836c413f..216202664 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -535,13 +535,13 @@ impl SpClient { last_response } - pub async fn put_connect_state_request(&self, 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(CONNECTION_ID, self.session().connection_id().parse()?); - self.request_with_protobuf(&Method::PUT, &endpoint, Some(headers), &state) + self.request_with_protobuf(&Method::PUT, &endpoint, Some(headers), state) .await } From 18af5d2ee100c69a3c5afe7bd12b5b54480f236a Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 3 Dec 2024 21:01:24 +0100 Subject: [PATCH 127/138] connect: adjust handling of context metadata/restrictions --- connect/src/spirc.rs | 28 +++++--------- connect/src/state.rs | 4 +- connect/src/state/context.rs | 73 +++++++++++++++++++++++------------ connect/src/state/options.rs | 2 +- connect/src/state/tracks.rs | 10 ++--- connect/src/state/transfer.rs | 35 +++++++++++------ 6 files changed, 88 insertions(+), 64 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 8b04f5747..a10bee7d0 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -508,8 +508,7 @@ impl SpircTask { } if let Some(transfer_state) = self.transfer_state.take() { - self.connect_state - .setup_state_from_transfer(transfer_state)? + self.connect_state.finish_transfer(transfer_state)? } if matches!(self.connect_state.active_context, ContextType::Default) { @@ -570,7 +569,7 @@ impl SpircTask { let previous_tracks = self.connect_state.prev_autoplay_track_uris(); debug!( - "loading autoplay context <{resolve_uri}> with {} previous tracks", + "requesting autoplay context <{resolve_uri}> with {} previous tracks", previous_tracks.len() ); @@ -1089,8 +1088,6 @@ impl SpircTask { let autoplay = self.connect_state.current_track(|t| t.is_from_autoplay()); if autoplay { ctx_uri = ctx_uri.replace("station:", ""); - self.connect_state.active_context = ContextType::Autoplay; - self.connect_state.fill_up_context = ContextType::Autoplay; } let fallback = self.connect_state.current_track(|t| &t.uri).clone(); @@ -1117,19 +1114,15 @@ impl SpircTask { let is_playing = !transfer.playback.is_paused; - if self.connect_state.context.is_some() { - self.connect_state.setup_state_from_transfer(transfer)?; - } else { - if self.connect_state.current_track(|t| t.is_autoplay()) || autoplay { - debug!("currently in autoplay context, async resolving autoplay for {ctx_uri}"); + if self.connect_state.current_track(|t| t.is_autoplay()) || autoplay { + debug!("currently in autoplay context, async resolving autoplay for {ctx_uri}"); - self.resolve_context - .push(ResolveContext::from_uri(ctx_uri, fallback, true)) - } - - self.transfer_state = Some(transfer); + self.resolve_context + .push(ResolveContext::from_uri(ctx_uri, fallback, true)) } + self.transfer_state = Some(transfer); + self.load_track(is_playing, position.try_into()?) } @@ -1264,10 +1257,7 @@ impl SpircTask { self.handle_stop() } - if !self.connect_state.has_next_tracks(None) && self.session.autoplay() { - self.resolve_context - .push(ResolveContext::from_uri(&cmd.context_uri, fallback, true)) - } + self.preload_autoplay_when_required(); Ok(()) } diff --git a/connect/src/state.rs b/connect/src/state.rs index deaf783d5..28e57dadd 100644 --- a/connect/src/state.rs +++ b/connect/src/state.rs @@ -106,6 +106,7 @@ pub struct ConnectState { // 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, @@ -346,11 +347,8 @@ impl ConnectState { 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)?; - debug!("reset playback state to {new_index}"); - if !self.current_track(|t| t.is_queue()) { self.set_current_track(new_index)?; } diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 21935596b..570eb9071 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -1,8 +1,9 @@ -use crate::state::metadata::Metadata; -use crate::state::provider::Provider; -use crate::state::{ConnectState, StateError}; +use crate::state::{metadata::Metadata, provider::Provider, ConnectState, StateError}; use librespot_core::{Error, SpotifyId}; -use librespot_protocol::player::{Context, ContextIndex, ContextPage, ContextTrack, ProvidedTrack}; +use librespot_protocol::player::{ + Context, ContextIndex, ContextPage, ContextTrack, ProvidedTrack, Restrictions, +}; +use protobuf::MessageField; use std::collections::HashMap; use uuid::Uuid; @@ -13,6 +14,7 @@ const SEARCH_IDENTIFIER: &str = "spotify:search"; 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, } @@ -72,7 +74,9 @@ impl ConnectState { } pub fn reset_context(&mut self, mut reset_as: ResetContext) { - self.active_context = ContextType::Default; + if let Err(why) = self.set_active_context(ContextType::Default) { + warn!("switching to active context had issues: {why}") + } self.fill_up_context = ContextType::Default; if matches!(reset_as, ResetContext::WhenDifferent(ctx) if self.context_uri() != ctx) { @@ -113,7 +117,32 @@ impl ConnectState { .and_then(|p| p.tracks.first().map(|t| &t.uri)) } - pub fn update_context(&mut self, context: Context, ty: UpdateContext) -> Result<(), Error> { + pub fn set_active_context(&mut self, new_context: ContextType) -> Result<(), Error> { + self.active_context = new_context; + + let ctx = self.get_context(&new_context)?; + let mut restrictions = ctx.restrictions.clone(); + let metadata = ctx.metadata.clone(); + + let player = self.player_mut(); + + player.context_metadata.clear(); + player.context_restrictions.clear(); + player.restrictions.clear(); + + if let Some(restrictions) = restrictions.take() { + player.context_restrictions = MessageField::some(restrictions.clone()); + player.restrictions = MessageField::some(restrictions); + } + + for (key, value) in metadata { + player.context_metadata.insert(key, value); + } + + Ok(()) + } + + 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()); @@ -154,27 +183,20 @@ impl ConnectState { page.tracks.len() ); - let player = self.player_mut(); - if context.restrictions.is_some() { - player.restrictions = context.restrictions.clone(); - player.context_restrictions = context.restrictions; - } else { - player.context_restrictions = Default::default(); - player.restrictions = Default::default() - } - - player.context_metadata.clear(); - for (key, value) in context.metadata { - player.context_metadata.insert(key, value); - } - match ty { UpdateContext::Default => { - let mut new_context = self.state_context_from_page(page, Some(&context.uri), None); + 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() == &context.uri { + 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 }) { @@ -195,12 +217,13 @@ impl ConnectState { self.context = Some(new_context); - self.player_mut().context_url = format!("context://{}", &context.uri); + self.player_mut().context_url = context.url; 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), )) @@ -213,6 +236,7 @@ impl ConnectState { fn state_context_from_page( &mut self, page: ContextPage, + restrictions: Option, new_context_uri: Option<&str>, provider: Option, ) -> StateContext { @@ -235,6 +259,7 @@ impl ConnectState { StateContext { tracks, + restrictions, metadata: page.metadata, index: ContextIndex::new(), } @@ -350,7 +375,7 @@ impl ConnectState { } pub fn fill_context_from_page(&mut self, page: ContextPage) -> Result<(), Error> { - let context = self.state_context_from_page(page, None, None); + let context = self.state_context_from_page(page, None, None, None); let ctx = self .context .as_mut() diff --git a/connect/src/state/options.rs b/connect/src/state/options.rs index b42b41b17..fdbadcc8b 100644 --- a/connect/src/state/options.rs +++ b/connect/src/state/options.rs @@ -67,7 +67,7 @@ impl ConnectState { shuffle_context.index = ContextIndex::new(); self.shuffle_context = Some(shuffle_context); - self.active_context = ContextType::Shuffle; + self.set_active_context(ContextType::Shuffle)?; self.fill_up_context = ContextType::Shuffle; self.fill_up_next_tracks()?; diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index 6c301e94a..62ae0a26a 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -77,7 +77,7 @@ impl<'ct> ConnectState { debug!( "set track to: {} at {} of {} tracks", - index + 1, + index, new_track.uri, context.tracks.len() ); @@ -93,7 +93,7 @@ impl<'ct> ConnectState { /// /// 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, StateError> { + 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); @@ -129,7 +129,7 @@ impl<'ct> ConnectState { let update_index = if new_track.is_queue() { None } else if new_track.is_autoplay() { - self.active_context = ContextType::Autoplay; + self.set_active_context(ContextType::Autoplay)?; None } else { let ctx = self.context.as_ref(); @@ -160,7 +160,7 @@ impl<'ct> ConnectState { /// 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>, StateError> { + pub fn prev_track(&mut self) -> Result>, Error> { let old_track = self.player_mut().track.take(); if let Some(old_track) = old_track { @@ -197,7 +197,7 @@ impl<'ct> ConnectState { if matches!(self.active_context, ContextType::Autoplay if new_track.is_context()) { // transition back to default context - self.active_context = ContextType::Default; + self.set_active_context(ContextType::Default)?; } self.fill_up_next_tracks()?; diff --git a/connect/src/state/transfer.rs b/connect/src/state/transfer.rs index 4a59e91c5..352b68165 100644 --- a/connect/src/state/transfer.rs +++ b/connect/src/state/transfer.rs @@ -1,3 +1,5 @@ +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; @@ -23,6 +25,7 @@ impl ConnectState { ) } + /// handles the initially transferable data pub fn handle_initial_transfer(&mut self, transfer: &mut TransferState, ctx_uri: String) { let current_context_metadata = self.context.as_ref().map(|c| c.metadata.clone()); let player = self.player_mut(); @@ -41,23 +44,21 @@ impl ConnectState { player.playback_speed = 1.; } - player.play_origin = transfer.current_session.play_origin.clone(); + 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(suppressions) = transfer.current_session.suppressions.as_ref() { - player.suppressions = MessageField::some(suppressions.clone()); + if let Some(mut ctx) = session.context.take() { + player.context_restrictions = ctx.restrictions.take().into(); + for (key, value) in ctx.metadata { + player.context_metadata.insert(key, value); + } + } } player.context_url = format!("context://{ctx_uri}"); player.context_uri = ctx_uri; - if let Some(context) = transfer.current_session.context.as_ref() { - player.context_restrictions = context.restrictions.clone(); - } - - for (key, value) in &transfer.current_session.context.metadata { - player.context_metadata.insert(key.clone(), value.clone()); - } - if let Some(metadata) = current_context_metadata { for (key, value) in metadata { player.context_metadata.insert(key, value); @@ -68,12 +69,22 @@ impl ConnectState { self.clear_next_tracks(false); } - pub fn setup_state_from_transfer(&mut self, transfer: TransferState) -> Result<(), Error> { + /// 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() { From 4f534d16ba8823b657c44662f1765c278fa4968e Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 3 Dec 2024 22:01:45 +0100 Subject: [PATCH 128/138] connect: fix incorrect context states --- connect/src/spirc.rs | 2 ++ connect/src/state/context.rs | 9 +++++---- connect/src/state/transfer.rs | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index a10bee7d0..69c5d116a 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -540,6 +540,8 @@ impl SpircTask { 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()) { diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 570eb9071..5dd3e3b57 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -89,7 +89,6 @@ impl ConnectState { self.context = None; self.autoplay_context = None; self.next_contexts.clear(); - self.player_mut().context_restrictions.clear() } ResetContext::WhenDifferent(_) => debug!("context didn't change, no reset"), ResetContext::DefaultIndex => { @@ -127,11 +126,9 @@ impl ConnectState { let player = self.player_mut(); player.context_metadata.clear(); - player.context_restrictions.clear(); player.restrictions.clear(); if let Some(restrictions) = restrictions.take() { - player.context_restrictions = MessageField::some(restrictions.clone()); player.restrictions = MessageField::some(restrictions); } @@ -217,7 +214,11 @@ impl ConnectState { self.context = Some(new_context); - self.player_mut().context_url = context.url; + 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 => { diff --git a/connect/src/state/transfer.rs b/connect/src/state/transfer.rs index 352b68165..2d3f16251 100644 --- a/connect/src/state/transfer.rs +++ b/connect/src/state/transfer.rs @@ -49,14 +49,14 @@ impl ConnectState { player.suppressions = session.suppressions.take().into(); if let Some(mut ctx) = session.context.take() { - player.context_restrictions = ctx.restrictions.take().into(); + player.restrictions = ctx.restrictions.take().into(); for (key, value) in ctx.metadata { player.context_metadata.insert(key, value); } } } - player.context_url = format!("context://{ctx_uri}"); + player.context_url.clear(); player.context_uri = ctx_uri; if let Some(metadata) = current_context_metadata { From 2aa616fa628eeb6619d1ccf622a967e95c6c9c4a Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Tue, 3 Dec 2024 22:19:46 +0100 Subject: [PATCH 129/138] connect: become inactive when no cluster is reported --- connect/src/spirc.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 69c5d116a..ff1963777 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -944,6 +944,8 @@ impl SpircTask { // 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?; } Ok(()) From f9f7fc962e9104e702e6b8ac0cd31b9b5e80709e Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Fri, 6 Dec 2024 13:49:37 +0100 Subject: [PATCH 130/138] update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93a1304f8..f0c875610 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [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 From be81f2abdd91048b55bf8d73c99d4c4eca7ac838 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Fri, 6 Dec 2024 13:53:04 +0100 Subject: [PATCH 131/138] core/playback: preemptively fix clippy warnings --- core/src/connection/handshake.rs | 4 ++-- playback/src/audio_backend/rodio.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/connection/handshake.rs b/core/src/connection/handshake.rs index 03b355985..e0b639256 100644 --- a/core/src/connection/handshake.rs +++ b/core/src/connection/handshake.rs @@ -229,8 +229,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/playback/src/audio_backend/rodio.rs b/playback/src/audio_backend/rodio.rs index 2632f54a5..f63fdef07 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 From a0c416f47a062dc8fcde18b552ea41924f6b6a30 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 7 Dec 2024 13:28:17 +0100 Subject: [PATCH 132/138] connect: minor adjustment to session changed --- connect/src/spirc.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index ff1963777..1f494b3e2 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -836,7 +836,9 @@ impl SpircTask { self.session.device_id() ); - if !cluster.active_device_id.is_empty() || !cluster.player_state.session_id.is_empty() { + 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 From f258f7561b751a274423f886a901fe9ab36641d0 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 7 Dec 2024 18:58:46 +0100 Subject: [PATCH 133/138] connect: change return type changing active context --- connect/src/state/context.rs | 17 ++++++++++------- connect/src/state/options.rs | 2 +- connect/src/state/tracks.rs | 4 ++-- connect/src/state/transfer.rs | 2 +- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 5dd3e3b57..97d5fee05 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -74,9 +74,7 @@ impl ConnectState { } pub fn reset_context(&mut self, mut reset_as: ResetContext) { - if let Err(why) = self.set_active_context(ContextType::Default) { - warn!("switching to active context had issues: {why}") - } + self.set_active_context(ContextType::Default); self.fill_up_context = ContextType::Default; if matches!(reset_as, ResetContext::WhenDifferent(ctx) if self.context_uri() != ctx) { @@ -116,10 +114,17 @@ impl ConnectState { .and_then(|p| p.tracks.first().map(|t| &t.uri)) } - pub fn set_active_context(&mut self, new_context: ContextType) -> Result<(), Error> { + pub fn set_active_context(&mut self, new_context: ContextType) { self.active_context = new_context; - let ctx = self.get_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(); @@ -135,8 +140,6 @@ impl ConnectState { for (key, value) in metadata { player.context_metadata.insert(key, value); } - - Ok(()) } pub fn update_context(&mut self, mut context: Context, ty: UpdateContext) -> Result<(), Error> { diff --git a/connect/src/state/options.rs b/connect/src/state/options.rs index fdbadcc8b..b6bc331c9 100644 --- a/connect/src/state/options.rs +++ b/connect/src/state/options.rs @@ -67,7 +67,7 @@ impl ConnectState { shuffle_context.index = ContextIndex::new(); self.shuffle_context = Some(shuffle_context); - self.set_active_context(ContextType::Shuffle)?; + self.set_active_context(ContextType::Shuffle); self.fill_up_context = ContextType::Shuffle; self.fill_up_next_tracks()?; diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index 62ae0a26a..2dc1b9af4 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -129,7 +129,7 @@ impl<'ct> ConnectState { let update_index = if new_track.is_queue() { None } else if new_track.is_autoplay() { - self.set_active_context(ContextType::Autoplay)?; + self.set_active_context(ContextType::Autoplay); None } else { let ctx = self.context.as_ref(); @@ -197,7 +197,7 @@ impl<'ct> ConnectState { if matches!(self.active_context, ContextType::Autoplay if new_track.is_context()) { // transition back to default context - self.set_active_context(ContextType::Default)?; + self.set_active_context(ContextType::Default); } self.fill_up_next_tracks()?; diff --git a/connect/src/state/transfer.rs b/connect/src/state/transfer.rs index 2d3f16251..c09f02751 100644 --- a/connect/src/state/transfer.rs +++ b/connect/src/state/transfer.rs @@ -82,7 +82,7 @@ impl ConnectState { ContextType::Default }; - self.set_active_context(context_ty)?; + self.set_active_context(context_ty); self.fill_up_context = context_ty; let ctx = self.get_context(&self.active_context).ok(); From 1760f16c4830fbd656374a92a9ea90ce6d683731 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sat, 7 Dec 2024 18:59:16 +0100 Subject: [PATCH 134/138] connect: handle unavailable contexts --- connect/src/model.rs | 12 +++++++++ connect/src/spirc.rs | 61 +++++++++++++++++++++++++++++++++----------- core/src/spclient.rs | 26 ++++++++++++++++--- 3 files changed, 80 insertions(+), 19 deletions(-) diff --git a/connect/src/model.rs b/connect/src/model.rs index 59a9dff1d..f9165eaee 100644 --- a/connect/src/model.rs +++ b/connect/src/model.rs @@ -2,6 +2,7 @@ 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 { @@ -169,6 +170,17 @@ impl PartialEq for ResolveContext { } } +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 1f494b3e2..9d0877af8 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -34,6 +34,8 @@ use crate::{ }; use futures_util::{Stream, StreamExt}; use protobuf::MessageField; +use std::collections::HashMap; +use std::time::Instant; use std::{ future::Future, pin::Pin, @@ -98,6 +100,9 @@ struct SpircTask { shutdown: bool, session: Session, resolve_context: Vec, + /// contexts may not be resolvable at the moment so we should ignore any further request + unavailable_contexts: HashMap, + // is set when we receive a transfer state and are loading the context asynchronously pub transfer_state: Option, @@ -134,6 +139,8 @@ 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); @@ -262,6 +269,7 @@ impl Spirc { session, resolve_context: Vec::new(), + unavailable_contexts: HashMap::new(), transfer_state: None, update_volume: false, @@ -499,8 +507,8 @@ impl SpircTask { .await { error!("failed resolving context <{resolve}>: {why}"); - self.connect_state.reset_context(ResetContext::Completely); - self.handle_stop() + self.unavailable_contexts.insert(resolve, Instant::now()); + continue; } self.connect_state.merge_context(Some(resolve.into())); @@ -591,6 +599,33 @@ impl SpircTask { .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() { + // When in autoplay, keep topping up the playlist when it nears the end + debug!("Preloading autoplay: {resolve}"); + // resolve the next autoplay context + self.resolve_context.push(resolve); + } else { + debug!("tried loading unavailable context: {resolve}") + } + } + // todo: time_delta still necessary? fn now_ms(&self) -> i64 { let dur = SystemTime::now() @@ -993,8 +1028,10 @@ impl SpircTask { update_context.context.uri, self.connect_state.context_uri() ) } else { - self.resolve_context - .push(ResolveContext::from_context(update_context.context, false)); + self.add_resolve_context(ResolveContext::from_context( + update_context.context, + false, + )) } return Ok(()); } @@ -1099,8 +1136,7 @@ impl SpircTask { let fallback = self.connect_state.current_track(|t| &t.uri).clone(); debug!("async resolve context for <{}>", ctx_uri); - self.resolve_context - .push(ResolveContext::from_uri(ctx_uri.clone(), &fallback, false)); + self.add_resolve_context(ResolveContext::from_uri(ctx_uri.clone(), &fallback, false)); let timestamp = self.now_ms(); let state = &mut self.connect_state; @@ -1123,8 +1159,7 @@ impl SpircTask { if self.connect_state.current_track(|t| t.is_autoplay()) || autoplay { debug!("currently in autoplay context, async resolving autoplay for {ctx_uri}"); - self.resolve_context - .push(ResolveContext::from_uri(ctx_uri, fallback, true)) + self.add_resolve_context(ResolveContext::from_uri(ctx_uri, fallback, true)) } self.transfer_state = Some(transfer); @@ -1398,18 +1433,14 @@ impl SpircTask { match next { LoadNext::Done => info!("loaded next context"), LoadNext::PageUrl(page_url) => { - self.resolve_context - .push(ResolveContext::from_page_url(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); - // When in autoplay, keep topping up the playlist when it nears the end - debug!("Preloading autoplay: {resolve}"); - // resolve the next autoplay context - self.resolve_context.push(resolve); + self.add_resolve_context(resolve) } LoadNext::Empty => { debug!("next context is empty and autoplay isn't enabled, no preloading required") @@ -1514,7 +1545,7 @@ impl SpircTask { } debug!("playlist modification for current context: {uri}"); - self.resolve_context.push(ResolveContext::from_uri( + self.add_resolve_context(ResolveContext::from_uri( uri, self.connect_state.current_track(|t| &t.uri), false, diff --git a/core/src/spclient.rs b/core/src/spclient.rs index 216202664..c818570ac 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -62,6 +62,8 @@ const NO_METRICS_AND_SALT: RequestOptions = RequestOptions { pub enum SpClientError { #[error("missing attribute {0}")] Attribute(String), + #[error("expected data but received none")] + NoData, } impl From for Error { @@ -830,9 +832,17 @@ impl SpClient { .request_with_options(&Method::GET, &uri, None, None, &NO_METRICS_AND_SALT) .await?; let ctx_json = String::from_utf8(res.to_vec())?; - let ctx = protobuf_json_mapping::parse_from_str::(&ctx_json)?; + 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) + Ok(ctx?) } pub async fn get_autoplay_context( @@ -850,9 +860,17 @@ impl SpClient { .await?; let ctx_json = String::from_utf8(res.to_vec())?; - let ctx = protobuf_json_mapping::parse_from_str::(&ctx_json)?; + 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) + Ok(ctx?) } pub async fn get_rootlist(&self, from: usize, length: Option) -> SpClientResult { From e4528d8a7f74e5f6bf13ebe1a8f7b0ff2b4db8d3 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 8 Dec 2024 13:19:39 +0100 Subject: [PATCH 135/138] connect: fix previous restrictions blocking load with shuffle --- connect/src/spirc.rs | 1 + connect/src/state/restrictions.rs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 9d0877af8..15ed70196 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1257,6 +1257,7 @@ impl SpircTask { // 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); diff --git a/connect/src/state/restrictions.rs b/connect/src/state/restrictions.rs index f072fabd4..a0f269331 100644 --- a/connect/src/state/restrictions.rs +++ b/connect/src/state/restrictions.rs @@ -4,6 +4,13 @@ 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"; From cdbbfe0da9d0cb16929542500ccc605722ea4573 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 8 Dec 2024 14:48:59 +0100 Subject: [PATCH 136/138] connect: update comments and logging --- connect/src/spirc.rs | 12 ++++-------- connect/src/state/context.rs | 4 ++++ connect/src/state/transfer.rs | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 15ed70196..b2dfc2e50 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -617,9 +617,7 @@ impl SpircTask { }; if last_try.is_none() { - // When in autoplay, keep topping up the playlist when it nears the end - debug!("Preloading autoplay: {resolve}"); - // resolve the next autoplay context + debug!("add resolve request: {resolve}"); self.resolve_context.push(resolve); } else { debug!("tried loading unavailable context: {resolve}") @@ -1119,11 +1117,10 @@ impl SpircTask { let mut ctx_uri = transfer.current_session.context.uri.clone(); - debug!("trying to find initial track"); match self.connect_state.current_track_from_transfer(&transfer) { - Err(why) => warn!("{why}"), + Err(why) => warn!("didn't find initial track: {why}"), Ok(track) => { - debug!("found initial track"); + debug!("found initial track <{}>", track.uri); self.connect_state.set_track(track) } }; @@ -1135,14 +1132,13 @@ impl SpircTask { let fallback = self.connect_state.current_track(|t| &t.uri).clone(); - debug!("async resolve context for <{}>", ctx_uri); 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, ctx_uri.clone()); + state.handle_initial_transfer(&mut transfer); // update position if the track continued playing let position = if transfer.playback.is_paused { diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 97d5fee05..3e9d720e6 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -399,6 +399,10 @@ impl ConnectState { }; 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)); } diff --git a/connect/src/state/transfer.rs b/connect/src/state/transfer.rs index c09f02751..c310e0b9c 100644 --- a/connect/src/state/transfer.rs +++ b/connect/src/state/transfer.rs @@ -26,7 +26,7 @@ impl ConnectState { } /// handles the initially transferable data - pub fn handle_initial_transfer(&mut self, transfer: &mut TransferState, ctx_uri: String) { + 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(); @@ -57,7 +57,7 @@ impl ConnectState { } player.context_url.clear(); - player.context_uri = ctx_uri; + player.context_uri.clear(); if let Some(metadata) = current_context_metadata { for (key, value) in metadata { From 54a5cd29c73a43569adccc23098fb3a30a6f94e7 Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 8 Dec 2024 16:24:16 +0100 Subject: [PATCH 137/138] core/connect: reduce some more duplicate code --- connect/src/spirc.rs | 106 +++++++++++++------------------------ core/src/dealer/manager.rs | 31 ++++++++--- 2 files changed, 63 insertions(+), 74 deletions(-) diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index b2dfc2e50..87af20759 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -4,7 +4,7 @@ use crate::{ core::{ authentication::Credentials, dealer::{ - manager::{Reply, RequestReply}, + manager::{BoxedStream, BoxedStreamResult, Reply, RequestReply}, protocol::{Command, Message, Request}, }, session::UserAttributes, @@ -32,20 +32,18 @@ use crate::{ {ConnectState, ConnectStateConfig}, }, }; -use futures_util::{Stream, StreamExt}; +use futures_util::StreamExt; use protobuf::MessageField; use std::collections::HashMap; use std::time::Instant; use std::{ future::Future, - pin::Pin, sync::atomic::{AtomicUsize, Ordering}, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; use thiserror::Error; use tokio::{sync::mpsc, time::sleep}; -use tokio_stream::wrappers::UnboundedReceiverStream; #[derive(Debug, Error)] pub enum SpircError { @@ -72,9 +70,6 @@ impl From for Error { } } -type BoxedStream = Pin + Send>>; -type BoxedStreamResult = BoxedStream>; - struct SpircTask { player: Arc, mixer: Arc, @@ -156,80 +151,55 @@ impl Spirc { 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 connect_state = ConnectState::new(config, &session); - let connection_id_update = Box::pin( - session - .dealer() - .listen_for("hm://pusher/v1/connections/")? - .map(|response| -> Result { - let connection_id = response - .headers - .get("Spotify-Connection-Id") - .ok_or_else(|| SpircError::InvalidUri(response.uri.clone()))?; - Ok(connection_id.to_owned()) - }), - ); + let connection_id_update = session + .dealer() + .listen_for("hm://pusher/v1/connections/", extract_connection_id)?; - let connect_state_update = Box::pin( - session - .dealer() - .listen_for("hm://connect-state/v1/cluster")? - .map(Message::from_raw), - ); - - let connect_state_volume_update = Box::pin( - session - .dealer() - .listen_for("hm://connect-state/v1/connect/volume")? - .map(Message::from_raw), - ); + let connect_state_update = session + .dealer() + .listen_for("hm://connect-state/v1/cluster", Message::from_raw)?; - let connect_state_logout_request = Box::pin( - session - .dealer() - .listen_for("hm://connect-state/v1/connect/logout")? - .map(Message::from_raw), - ); + let connect_state_volume_update = session + .dealer() + .listen_for("hm://connect-state/v1/connect/volume", Message::from_raw)?; - let playlist_update = Box::pin( - session - .dealer() - .listen_for("hm://playlist/v2/playlist/")? - .map(Message::from_raw), - ); + let connect_state_logout_request = session + .dealer() + .listen_for("hm://connect-state/v1/connect/logout", Message::from_raw)?; - let session_update = Box::pin( - session - .dealer() - .listen_for("social-connect/v2/session_update")? - .map(Message::from_json), - ); + let playlist_update = session + .dealer() + .listen_for("hm://playlist/v2/playlist/", Message::from_raw)?; - let connect_state_command = Box::pin( - session - .dealer() - .handle_for("hm://connect-state/v1/player/command") - .map(UnboundedReceiverStream::new)?, - ); + let session_update = session + .dealer() + .listen_for("social-connect/v2/session_update", Message::from_json)?; - let user_attributes_update = Box::pin( - session - .dealer() - .listen_for("spotify:user:attributes:update")? - .map(Message::from_raw), - ); + 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 = Box::pin( - session - .dealer() - .listen_for("spotify:user:attributes:mutated")? - .map(Message::from_raw), - ); + 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?; diff --git a/core/src/dealer/manager.rs b/core/src/dealer/manager.rs index 2fd3e1624..792deca3e 100644 --- a/core/src/dealer/manager.rs +++ b/core/src/dealer/manager.rs @@ -1,12 +1,14 @@ -use std::cell::OnceCell; -use std::str::FromStr; - +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::{ - Builder, Dealer, GetUrlResult, Request, RequestHandler, Responder, Response, Subscription, + protocol::Message, Builder, Dealer, GetUrlResult, Request, RequestHandler, Responder, Response, + Subscription, }; use crate::{Error, Session}; @@ -17,6 +19,9 @@ component! { } } +pub type BoxedStream = Pin + Send>>; +pub type BoxedStreamResult = BoxedStream>; + #[derive(Error, Debug)] enum DealerError { #[error("Builder wasn't available")] @@ -85,7 +90,7 @@ impl DealerManager { Ok(url) } - pub fn listen_for(&self, url: impl Into) -> Result { + pub fn add_listen_for(&self, url: impl Into) -> Result { let url = url.into(); self.lock(|inner| { if let Some(dealer) = inner.dealer.get() { @@ -98,7 +103,15 @@ impl DealerManager { }) } - pub fn handle_for(&self, url: impl Into) -> Result { + 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(); @@ -113,6 +126,12 @@ impl DealerManager { }) } + 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() { From 521ea7400c68878ef3105a63d93626fd8b132aea Mon Sep 17 00:00:00 2001 From: Felix Prillwitz Date: Sun, 8 Dec 2024 23:50:23 +0100 Subject: [PATCH 138/138] more docs around the dealer --- connect/src/spirc.rs | 10 +++++- docs/dealer.md | 79 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 docs/dealer.md diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 87af20759..b9240851c 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -74,6 +74,7 @@ struct SpircTask { player: Arc, mixer: Arc, + /// the state management object connect_state: ConnectState, play_request_id: Option, @@ -94,13 +95,20 @@ struct SpircTask { shutdown: bool, session: Session, + + /// 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 we receive a transfer state and are loading the context asynchronously + /// 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, diff --git a/docs/dealer.md b/docs/dealer.md new file mode 100644 index 000000000..247042147 --- /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.