diff --git a/connect/src/context_resolver.rs b/connect/src/context_resolver.rs index 79d48973..b566e8d7 100644 --- a/connect/src/context_resolver.rs +++ b/connect/src/context_resolver.rs @@ -76,7 +76,9 @@ impl ResolveContext { // otherwise we might not even check if we need to fallback and just use the fallback uri match self.resolve { Resolve::Uri(ref uri) => ConnectState::valid_resolve_uri(uri), - Resolve::Context(ref ctx) => ConnectState::get_context_uri_from_context(ctx), + Resolve::Context(ref ctx) => { + ConnectState::find_valid_uri(ctx.uri.as_deref(), ctx.pages.first()) + } } .or(self.fallback.as_deref()) } @@ -260,7 +262,7 @@ impl ContextResolver { ContextAction::Replace => { let remaining = state.update_context(context, next.update); if let Resolve::Context(ref ctx) = next.resolve { - state.merge_context(Some(ctx.clone())); + state.merge_context(ctx.pages.clone().pop()); } remaining diff --git a/connect/src/model.rs b/connect/src/model.rs index 5e15b01a..10f25f1b 100644 --- a/connect/src/model.rs +++ b/connect/src/model.rs @@ -5,9 +5,9 @@ use crate::{ use std::ops::Deref; /// Request for loading playback -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct LoadRequest { - pub(super) context_uri: String, + pub(super) context: PlayContext, pub(super) options: LoadRequestOptions, } @@ -19,8 +19,14 @@ impl Deref for LoadRequest { } } +#[derive(Debug, Clone)] +pub(super) enum PlayContext { + Uri(String), + Tracks(Vec), +} + /// The parameters for creating a load request -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub struct LoadRequestOptions { /// Whether the given tracks should immediately start playing, or just be initially loaded. pub start_playing: bool, @@ -44,7 +50,7 @@ pub struct LoadRequestOptions { /// /// Separated into an `enum` to exclude the other variants from being used /// simultaneously, as they are not compatible. -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum LoadContextOptions { /// Starts the context with options Options(Options), @@ -56,7 +62,7 @@ pub enum LoadContextOptions { } /// The available options that indicate how to start the context -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub struct Options { /// Start the context in shuffle mode pub shuffle: bool, @@ -80,16 +86,30 @@ impl LoadRequest { /// Create a load request from a `context_uri` /// /// For supported `context_uri` see [`SpClient::get_context`](librespot_core::spclient::SpClient::get_context) + /// + /// Equivalent to using [`/me/player/play`](https://developer.spotify.com/documentation/web-api/reference/start-a-users-playback) + /// and providing `context_uri` pub fn from_context_uri(context_uri: String, options: LoadRequestOptions) -> Self { Self { - context_uri, + context: PlayContext::Uri(context_uri), + options, + } + } + + /// Create a load request from a set of `tracks` + /// + /// Equivalent to using [`/me/player/play`](https://developer.spotify.com/documentation/web-api/reference/start-a-users-playback) + /// and providing `uris` + pub fn from_tracks(tracks: Vec, options: LoadRequestOptions) -> Self { + Self { + context: PlayContext::Tracks(tracks), options, } } } /// An item that represent a track to play -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum PlayingTrack { /// Represent the track at a given index. Index(u32), diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 911c58ed..46a3bde2 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -28,9 +28,10 @@ use crate::{ provider::IsProvider, {ConnectConfig, ConnectState}, }, - LoadContextOptions, LoadRequestOptions, + LoadContextOptions, LoadRequestOptions, PlayContext, }; use futures_util::StreamExt; +use librespot_protocol::context_page::ContextPage; use protobuf::MessageField; use std::{ future::Future, @@ -975,12 +976,21 @@ impl SpircTask { self.handle_transfer(transfer.data.expect("by condition checked"))?; return self.notify().await; } - Play(play) => { - let context_uri = play - .context - .uri - .clone() - .ok_or(SpircError::NoUri("context"))?; + Play(mut play) => { + let first_page = play.context.pages.pop(); + let context = match play.context.uri { + Some(s) => PlayContext::Uri(s), + None if !play.context.pages.is_empty() => PlayContext::Tracks( + play.context + .pages + .iter() + .cloned() + .flat_map(|p| p.tracks) + .flat_map(|t| t.uri) + .collect(), + ), + None => Err(SpircError::NoUri("context"))?, + }; let context_options = play .options @@ -989,16 +999,16 @@ impl SpircTask { .map(LoadContextOptions::Options); self.handle_load( - LoadRequest::from_context_uri( - context_uri, - LoadRequestOptions { + LoadRequest { + context, + options: LoadRequestOptions { start_playing: true, seek_to: play.options.seek_to.unwrap_or_default(), playing_track: play.options.skip_to.and_then(|s| s.try_into().ok()), context_options, }, - ), - Some(play.context), + }, + first_page, ) .await?; @@ -1046,6 +1056,8 @@ impl SpircTask { fn handle_transfer(&mut self, mut transfer: TransferState) -> Result<(), Error> { let mut ctx_uri = match transfer.current_session.context.uri { None => Err(SpircError::NoUri("transfer context"))?, + // can apparently happen when a state is transferred stared with "uris" via the api + Some(ref uri) if uri == "-" => String::new(), Some(ref uri) => uri.clone(), }; @@ -1066,6 +1078,27 @@ impl SpircTask { } let fallback = self.connect_state.current_track(|t| &t.uri).clone(); + let load_from_context_uri = !ctx_uri.is_empty(); + + if load_from_context_uri { + self.context_resolver.add(ResolveContext::from_uri( + ctx_uri.clone(), + &fallback, + ContextType::Default, + ContextAction::Replace, + )); + } else { + self.load_context_from_tracks( + transfer + .current_session + .context + .pages + .iter() + .cloned() + .flat_map(|p| p.tracks) + .collect::>(), + )? + } self.context_resolver.add(ResolveContext::from_uri( ctx_uri.clone(), @@ -1112,7 +1145,15 @@ impl SpircTask { )) } - self.transfer_state = Some(transfer); + if load_from_context_uri { + self.transfer_state = Some(transfer); + } else { + let ctx = self.connect_state.get_context(ContextType::Default)?; + let idx = ConnectState::find_index_in_context(ctx, |pt| { + self.connect_state.current_track(|t| pt.uri == t.uri) + })?; + self.connect_state.reset_playback_to_position(Some(idx))?; + } self.load_track(is_playing, position.try_into()?) } @@ -1181,61 +1222,41 @@ impl SpircTask { async fn handle_load( &mut self, cmd: LoadRequest, - context: Option, + page: Option, ) -> Result<(), Error> { self.connect_state - .reset_context(ResetContext::WhenDifferent(&cmd.context_uri)); + .reset_context(if let PlayContext::Uri(ref uri) = cmd.context { + ResetContext::WhenDifferent(uri) + } else { + ResetContext::Completely + }); self.connect_state.reset_options(); - if !self.connect_state.is_active() { - self.handle_activate(); - } - - 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()))?, + let autoplay = matches!(cmd.context_options, Some(LoadContextOptions::Autoplay)); + match cmd.context { + PlayContext::Uri(uri) => { + self.load_context_from_uri(uri, page.as_ref(), autoplay) + .await? } - } else { - &cmd.context_uri - }; - - let update_context = if matches!(cmd.context_options, Some(LoadContextOptions::Autoplay)) { - ContextType::Autoplay - } else { - ContextType::Default - }; - - self.connect_state.set_active_context(update_context); - - let current_context_uri = self.connect_state.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.context_resolver.clear(); - self.context_resolver.add(ResolveContext::from_uri( - &cmd.context_uri, - fallback, - update_context, - ContextAction::Replace, - )); - let context = self.context_resolver.get_next_context(Vec::new).await; - self.handle_next_context(context); + PlayContext::Tracks(tracks) => self.load_context_from_tracks(tracks)?, } + let cmd_options = cmd.options; + + self.connect_state.set_active_context(ContextType::Default); + // 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.merge_context(page); // load here, so that we clear the queue only after we definitely retrieved a new context self.connect_state.clear_next_tracks(); self.connect_state.clear_restrictions(); - debug!("play track <{:?}>", cmd.playing_track); + debug!("play track <{:?}>", cmd_options.playing_track); - let index = match cmd.playing_track { + let index = match cmd_options.playing_track { None => None, Some(ref playing_track) => Some(match playing_track { PlayingTrack::Index(i) => *i as usize, @@ -1250,7 +1271,7 @@ impl SpircTask { }), }; - if let Some(LoadContextOptions::Options(ref options)) = cmd.context_options { + if let Some(LoadContextOptions::Options(ref options)) = cmd_options.context_options { debug!( "loading with shuffle: <{}>, repeat track: <{}> context: <{}>", options.shuffle, options.repeat, options.repeat_track @@ -1261,7 +1282,8 @@ impl SpircTask { self.connect_state.set_repeat_track(options.repeat_track); } - if matches!(cmd.context_options, Some(LoadContextOptions::Options(ref o)) if o.shuffle) { + if matches!(cmd_options.context_options, Some(LoadContextOptions::Options(ref o)) if o.shuffle) + { if let Some(index) = index { self.connect_state.set_current_track(index)?; } else { @@ -1282,7 +1304,7 @@ impl SpircTask { } if self.connect_state.current_track(MessageField::is_some) { - self.load_track(cmd.start_playing, cmd.seek_to)?; + self.load_track(cmd_options.start_playing, cmd_options.seek_to)?; } else { info!("No active track, stopping"); self.handle_stop() @@ -1291,6 +1313,67 @@ impl SpircTask { Ok(()) } + async fn load_context_from_uri( + &mut self, + context_uri: String, + page: Option<&ContextPage>, + autoplay: bool, + ) -> Result<(), Error> { + if !self.connect_state.is_active() { + self.handle_activate(); + } + + let update_context = if autoplay { + ContextType::Autoplay + } else { + ContextType::Default + }; + + self.connect_state.set_active_context(update_context); + + let fallback = match page { + // check that the uri is valid or the page has a valid uri that can be used + Some(page) => match ConnectState::find_valid_uri(Some(&context_uri), Some(page)) { + Some(ctx_uri) => ctx_uri, + None => return Err(SpircError::InvalidUri(context_uri).into()), + }, + // when there is no page, the uri should be valid + None => &context_uri, + }; + + let current_context_uri = self.connect_state.context_uri(); + + if current_context_uri == &context_uri && fallback == context_uri { + debug!("context <{current_context_uri}> didn't change, no resolving required") + } else { + debug!("resolving context for load command"); + self.context_resolver.clear(); + self.context_resolver.add(ResolveContext::from_uri( + &context_uri, + fallback, + update_context, + ContextAction::Replace, + )); + let context = self.context_resolver.get_next_context(Vec::new).await; + self.handle_next_context(context); + } + + Ok(()) + } + + fn load_context_from_tracks(&mut self, tracks: impl Into) -> Result<(), Error> { + let ctx = Context { + pages: vec![tracks.into()], + ..Default::default() + }; + + let _ = self + .connect_state + .update_context(ctx, ContextType::Default)?; + + Ok(()) + } + fn handle_play(&mut self) { match self.play_status { SpircPlayStatus::Paused { @@ -1433,7 +1516,8 @@ impl SpircTask { let require_load_new = !self .connect_state .has_next_tracks(Some(CONTEXT_FETCH_THRESHOLD)) - && self.session.autoplay(); + && self.session.autoplay() + && !self.connect_state.context_uri().is_empty(); if !require_load_new { return; diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 5233795e..c9afbb64 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -116,6 +116,10 @@ impl ConnectState { ResetContext::Completely => { self.context = None; self.autoplay_context = None; + + let player = self.player_mut(); + player.context_uri.clear(); + player.context_url.clear(); } ResetContext::DefaultIndex => { for ctx in [self.context.as_mut(), self.autoplay_context.as_mut()] @@ -141,14 +145,13 @@ impl ConnectState { } } - pub fn get_context_uri_from_context(context: &Context) -> Option<&str> { - let uri = context.uri.as_deref().unwrap_or_default(); - Self::valid_resolve_uri(uri).or_else(|| { - context - .pages - .first() - .and_then(|p| p.tracks.first().and_then(|t| t.uri.as_deref())) - }) + pub fn find_valid_uri<'s>( + context_uri: Option<&'s str>, + first_page: Option<&'s ContextPage>, + ) -> Option<&'s str> { + context_uri + .and_then(Self::valid_resolve_uri) + .or_else(|| first_page.and_then(|p| p.tracks.first().and_then(|t| t.uri.as_deref()))) } pub fn set_active_context(&mut self, new_context: ContextType) { @@ -157,7 +160,8 @@ impl ConnectState { let player = self.player_mut(); player.context_metadata = Default::default(); - player.restrictions = Some(Default::default()).into(); + player.context_restrictions = MessageField::some(Default::default()); + player.restrictions = MessageField::some(Default::default()); let ctx = match self.get_context(new_context) { Err(why) => { @@ -387,16 +391,10 @@ impl ConnectState { .unwrap_or(false) } - pub fn merge_context(&mut self, context: Option) -> Option<()> { - let mut context = context?; - if matches!(context.uri, Some(ref uri) if uri != self.context_uri()) { - return None; - } - + pub fn merge_context(&mut self, new_page: Option) -> Option<()> { let current_context = self.get_context_mut(ContextType::Default).ok()?; - let new_page = context.pages.pop()?; - for new_track in new_page.tracks { + for new_track in new_page?.tracks { if new_track.uri.is_none() || matches!(new_track.uri, Some(ref uri) if uri.is_empty()) { continue; } diff --git a/connect/src/state/restrictions.rs b/connect/src/state/restrictions.rs index 03495c68..e4604c54 100644 --- a/connect/src/state/restrictions.rs +++ b/connect/src/state/restrictions.rs @@ -7,8 +7,8 @@ impl ConnectState { pub fn clear_restrictions(&mut self) { let player = self.player_mut(); - player.restrictions.clear(); - player.context_restrictions.clear(); + player.context_restrictions = Some(Default::default()).into(); + player.restrictions = Some(Default::default()).into(); } pub fn update_restrictions(&mut self) { diff --git a/protocol/src/impl_trait/context.rs b/protocol/src/impl_trait/context.rs index 875ef9ad..782a41dc 100644 --- a/protocol/src/impl_trait/context.rs +++ b/protocol/src/impl_trait/context.rs @@ -1,4 +1,4 @@ -use crate::context::Context; +use crate::{context::Context, context_page::ContextPage, context_track::ContextTrack}; use protobuf::Message; use std::hash::{Hash, Hasher}; @@ -11,3 +11,27 @@ impl Hash for Context { } impl Eq for Context {} + +impl From> for ContextPage { + fn from(value: Vec) -> Self { + ContextPage { + tracks: value + .into_iter() + .map(|uri| ContextTrack { + uri: Some(uri), + ..Default::default() + }) + .collect(), + ..Default::default() + } + } +} + +impl From> for ContextPage { + fn from(tracks: Vec) -> Self { + ContextPage { + tracks, + ..Default::default() + } + } +}