mirror of
https://github.com/librespot-org/librespot.git
synced 2025-10-03 09:49:31 +02:00
Dealer: Rework context retrieval (#1414)
* connect: simplify `handle_command` for SpircCommand * connect: simplify `handle_player_event` * connect: `handle_player_event` update log entries * connect: set `playback_speed` according to player state * connect: reduce/group state updates by delaying them slightly * connect: load entire context at once * connect: use is_playing from connect_state * connect: move `ResolveContext` in own file * connect: handle metadata correct * connect: resolve context rework - resolved contexts independently, by that we don't block our main loop - move resolve logic into own file - polish handling for play and transfer * connect: rework aftermath * general logging and comment fixups * connect: fix incorrect stopping * connect: always handle player seek event * connect: adjust behavior - rename `handle_context` to `handle_next_context` - disconnect should only pause the playback - find_next should not exceed queue length * fix typo and `find_next` * connect: fixes for context and transfer - fix context_metadata and restriction incorrect reset - do some state updates earlier - add more logging * revert removal of state setup * `clear_next_tracks` should never clear queued items just mimics official client behavior * connect: make `playing_track` optional and handle it correctly * connect: adjust finish of context resolving * connect: set track position when shuffling * example: adjust to model change * connect: remove duplicate track * connect: provide all recently played tracks to autoplay request - removes previously added workaround * connect: apply review suggestions - use drain instead of for with pop - use for instead of loop - use or_else instead of match - use Self::Error instead of the value - free memory for metadata and restrictions * connect: impl trait for player context * connect: fix incorrect playing and paused * connect: apply options as official clients * protocol: move trait impls into impl_trait mod
This commit is contained in:
parent
c288cf7106
commit
f3bb380851
18 changed files with 1004 additions and 734 deletions
|
@ -7,10 +7,15 @@ use crate::{
|
|||
player::{ContextIndex, ProvidedTrack},
|
||||
restrictions::Restrictions,
|
||||
},
|
||||
state::{metadata::Metadata, provider::Provider, ConnectState, StateError},
|
||||
state::{
|
||||
metadata::Metadata,
|
||||
provider::{IsProvider, Provider},
|
||||
ConnectState, StateError, SPOTIFY_MAX_NEXT_TRACKS_SIZE,
|
||||
},
|
||||
};
|
||||
use protobuf::MessageField;
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Deref;
|
||||
use uuid::Uuid;
|
||||
|
||||
const LOCAL_FILES_IDENTIFIER: &str = "spotify:local-files";
|
||||
|
@ -25,7 +30,7 @@ pub struct StateContext {
|
|||
pub index: ContextIndex,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Copy, Clone)]
|
||||
#[derive(Default, Debug, Copy, Clone, PartialEq)]
|
||||
pub enum ContextType {
|
||||
#[default]
|
||||
Default,
|
||||
|
@ -33,57 +38,81 @@ pub enum ContextType {
|
|||
Autoplay,
|
||||
}
|
||||
|
||||
pub enum LoadNext {
|
||||
Done,
|
||||
PageUrl(String),
|
||||
Empty,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Hash, Copy, Clone, PartialEq, Eq)]
|
||||
pub enum UpdateContext {
|
||||
Default,
|
||||
Autoplay,
|
||||
}
|
||||
|
||||
impl Deref for UpdateContext {
|
||||
type Target = ContextType;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
match self {
|
||||
UpdateContext::Default => &ContextType::Default,
|
||||
UpdateContext::Autoplay => &ContextType::Autoplay,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum ResetContext<'s> {
|
||||
Completely,
|
||||
DefaultIndex,
|
||||
WhenDifferent(&'s str),
|
||||
}
|
||||
|
||||
/// Extracts the spotify uri from a given page_url
|
||||
///
|
||||
/// Just extracts "spotify/album/5LFzwirfFwBKXJQGfwmiMY" and replaces the slash's with colon's
|
||||
///
|
||||
/// Expected `page_url` should look something like the following:
|
||||
/// `hm://artistplaycontext/v1/page/spotify/album/5LFzwirfFwBKXJQGfwmiMY/km_artist`
|
||||
fn page_url_to_uri(page_url: &str) -> String {
|
||||
let split = if let Some(rest) = page_url.strip_prefix("hm://") {
|
||||
rest.split('/')
|
||||
} else {
|
||||
warn!("page_url didn't start with hm://. got page_url: {page_url}");
|
||||
page_url.split('/')
|
||||
};
|
||||
|
||||
split
|
||||
.skip_while(|s| s != &"spotify")
|
||||
.take(3)
|
||||
.collect::<Vec<&str>>()
|
||||
.join(":")
|
||||
}
|
||||
|
||||
impl ConnectState {
|
||||
pub fn find_index_in_context<F: Fn(&ProvidedTrack) -> bool>(
|
||||
context: Option<&StateContext>,
|
||||
ctx: &StateContext,
|
||||
f: F,
|
||||
) -> Result<usize, StateError> {
|
||||
let ctx = context
|
||||
.as_ref()
|
||||
.ok_or(StateError::NoContext(ContextType::Default))?;
|
||||
|
||||
ctx.tracks
|
||||
.iter()
|
||||
.position(f)
|
||||
.ok_or(StateError::CanNotFindTrackInContext(None, ctx.tracks.len()))
|
||||
}
|
||||
|
||||
pub(super) fn get_context(&self, ty: &ContextType) -> Result<&StateContext, StateError> {
|
||||
pub fn get_context(&self, ty: ContextType) -> Result<&StateContext, StateError> {
|
||||
match ty {
|
||||
ContextType::Default => self.context.as_ref(),
|
||||
ContextType::Shuffle => self.shuffle_context.as_ref(),
|
||||
ContextType::Autoplay => self.autoplay_context.as_ref(),
|
||||
}
|
||||
.ok_or(StateError::NoContext(*ty))
|
||||
.ok_or(StateError::NoContext(ty))
|
||||
}
|
||||
|
||||
pub fn context_uri(&self) -> &String {
|
||||
&self.player().context_uri
|
||||
}
|
||||
|
||||
pub fn reset_context(&mut self, mut reset_as: ResetContext) {
|
||||
self.set_active_context(ContextType::Default);
|
||||
self.fill_up_context = ContextType::Default;
|
||||
fn different_context_uri(&self, uri: &str) -> bool {
|
||||
// search identifier is always different
|
||||
self.context_uri() != uri || uri.starts_with(SEARCH_IDENTIFIER)
|
||||
}
|
||||
|
||||
if matches!(reset_as, ResetContext::WhenDifferent(ctx) if self.context_uri() != ctx) {
|
||||
pub fn reset_context(&mut self, mut reset_as: ResetContext) {
|
||||
if matches!(reset_as, ResetContext::WhenDifferent(ctx) if self.different_context_uri(ctx)) {
|
||||
reset_as = ResetContext::Completely
|
||||
}
|
||||
self.shuffle_context = None;
|
||||
|
@ -92,7 +121,6 @@ impl ConnectState {
|
|||
ResetContext::Completely => {
|
||||
self.context = None;
|
||||
self.autoplay_context = None;
|
||||
self.next_contexts.clear();
|
||||
}
|
||||
ResetContext::WhenDifferent(_) => debug!("context didn't change, no reset"),
|
||||
ResetContext::DefaultIndex => {
|
||||
|
@ -106,28 +134,40 @@ impl ConnectState {
|
|||
}
|
||||
}
|
||||
|
||||
self.fill_up_context = ContextType::Default;
|
||||
self.set_active_context(ContextType::Default);
|
||||
self.update_restrictions()
|
||||
}
|
||||
|
||||
pub fn get_context_uri_from_context(context: &Context) -> Option<&String> {
|
||||
let context_uri = context.uri.as_ref()?;
|
||||
|
||||
if !context_uri.starts_with(SEARCH_IDENTIFIER) {
|
||||
return Some(context_uri);
|
||||
pub fn valid_resolve_uri(uri: &str) -> Option<&str> {
|
||||
if uri.is_empty() || uri.starts_with(SEARCH_IDENTIFIER) {
|
||||
None
|
||||
} else {
|
||||
Some(uri)
|
||||
}
|
||||
}
|
||||
|
||||
context
|
||||
.pages
|
||||
.first()
|
||||
.and_then(|p| p.tracks.first().and_then(|t| t.uri.as_ref()))
|
||||
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 set_active_context(&mut self, new_context: ContextType) {
|
||||
self.active_context = new_context;
|
||||
|
||||
let ctx = match self.get_context(&new_context) {
|
||||
let player = self.player_mut();
|
||||
|
||||
player.context_metadata = Default::default();
|
||||
player.restrictions = Some(Default::default()).into();
|
||||
|
||||
let ctx = match self.get_context(new_context) {
|
||||
Err(why) => {
|
||||
debug!("couldn't load context info because: {why}");
|
||||
warn!("couldn't load context info because: {why}");
|
||||
return;
|
||||
}
|
||||
Ok(ctx) => ctx,
|
||||
|
@ -138,9 +178,6 @@ impl ConnectState {
|
|||
|
||||
let player = self.player_mut();
|
||||
|
||||
player.context_metadata.clear();
|
||||
player.restrictions.clear();
|
||||
|
||||
if let Some(restrictions) = restrictions.take() {
|
||||
player.restrictions = MessageField::some(restrictions.into());
|
||||
}
|
||||
|
@ -150,24 +187,25 @@ impl ConnectState {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn update_context(&mut self, mut context: Context, ty: UpdateContext) -> Result<(), Error> {
|
||||
pub fn update_context(
|
||||
&mut self,
|
||||
mut context: Context,
|
||||
ty: UpdateContext,
|
||||
) -> Result<Option<Vec<String>>, Error> {
|
||||
if context.pages.iter().all(|p| p.tracks.is_empty()) {
|
||||
error!("context didn't have any tracks: {context:#?}");
|
||||
return Err(StateError::ContextHasNoTracks.into());
|
||||
Err(StateError::ContextHasNoTracks)?;
|
||||
} else if matches!(context.uri, Some(ref uri) if uri.starts_with(LOCAL_FILES_IDENTIFIER)) {
|
||||
return Err(StateError::UnsupportedLocalPlayBack.into());
|
||||
}
|
||||
|
||||
if matches!(ty, UpdateContext::Default) {
|
||||
self.next_contexts.clear();
|
||||
Err(StateError::UnsupportedLocalPlayBack)?;
|
||||
}
|
||||
|
||||
let mut next_contexts = Vec::new();
|
||||
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)
|
||||
next_contexts.push(page)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -176,17 +214,8 @@ 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.context_uri(),
|
||||
prev_context
|
||||
.map(|c| c.tracks.len().to_string())
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
"updated context {ty:?} to <{:?}> ({} tracks)",
|
||||
context.uri,
|
||||
page.tracks.len()
|
||||
);
|
||||
|
@ -195,32 +224,32 @@ impl ConnectState {
|
|||
UpdateContext::Default => {
|
||||
let mut new_context = self.state_context_from_page(
|
||||
page,
|
||||
context.metadata,
|
||||
context.restrictions.take(),
|
||||
context.uri.as_deref(),
|
||||
None,
|
||||
);
|
||||
|
||||
// when we update the same context, we should try to preserve the previous position
|
||||
// otherwise we might load the entire context twice
|
||||
if !self.context_uri().contains(SEARCH_IDENTIFIER)
|
||||
// otherwise we might load the entire context twice, unless it's the search context
|
||||
if !self.context_uri().starts_with(SEARCH_IDENTIFIER)
|
||||
&& matches!(context.uri, Some(ref uri) if uri == self.context_uri())
|
||||
{
|
||||
match Self::find_index_in_context(Some(&new_context), |t| {
|
||||
self.current_track(|t| &t.uri) == &t.uri
|
||||
}) {
|
||||
Ok(new_pos) => {
|
||||
debug!("found new index of current track, updating new_context index to {new_pos}");
|
||||
new_context.index.track = (new_pos + 1) as u32;
|
||||
if let Some(new_index) = self.find_last_index_in_new_context(&new_context) {
|
||||
new_context.index.track = match new_index {
|
||||
Ok(i) => i,
|
||||
Err(i) => {
|
||||
self.player_mut().index = MessageField::none();
|
||||
i
|
||||
}
|
||||
};
|
||||
|
||||
// enforce reloading the context
|
||||
if let Some(autoplay_ctx) = self.autoplay_context.as_mut() {
|
||||
autoplay_ctx.index.track = 0
|
||||
}
|
||||
// the track isn't anymore in the context
|
||||
Err(_) if matches!(self.active_context, ContextType::Default) => {
|
||||
warn!("current track was removed, setting pos to last known index");
|
||||
new_context.index.track = self.player().index.track
|
||||
}
|
||||
Err(_) => {}
|
||||
self.clear_next_tracks();
|
||||
}
|
||||
// enforce reloading the context
|
||||
self.clear_next_tracks(true);
|
||||
}
|
||||
|
||||
self.context = Some(new_context);
|
||||
|
@ -235,6 +264,7 @@ impl ConnectState {
|
|||
UpdateContext::Autoplay => {
|
||||
self.autoplay_context = Some(self.state_context_from_page(
|
||||
page,
|
||||
context.metadata,
|
||||
context.restrictions.take(),
|
||||
context.uri.as_deref(),
|
||||
Some(Provider::Autoplay),
|
||||
|
@ -242,12 +272,81 @@ impl ConnectState {
|
|||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
if next_contexts.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// load remaining contexts
|
||||
let next_contexts = next_contexts
|
||||
.into_iter()
|
||||
.flat_map(|page| {
|
||||
if !page.tracks.is_empty() {
|
||||
self.fill_context_from_page(page).ok()?;
|
||||
None
|
||||
} else if matches!(page.page_url, Some(ref url) if !url.is_empty()) {
|
||||
Some(page_url_to_uri(
|
||||
&page.page_url.expect("checked by precondition"),
|
||||
))
|
||||
} else {
|
||||
warn!("unhandled context page: {page:#?}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Some(next_contexts))
|
||||
}
|
||||
|
||||
fn find_first_prev_track_index(&self, ctx: &StateContext) -> Option<usize> {
|
||||
let prev_tracks = self.prev_tracks();
|
||||
for i in (0..prev_tracks.len()).rev() {
|
||||
let prev_track = prev_tracks.get(i)?;
|
||||
if let Ok(idx) = Self::find_index_in_context(ctx, |t| prev_track.uri == t.uri) {
|
||||
return Some(idx);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_last_index_in_new_context(
|
||||
&self,
|
||||
new_context: &StateContext,
|
||||
) -> Option<Result<u32, u32>> {
|
||||
let ctx = self.context.as_ref()?;
|
||||
|
||||
let is_queued_item = self.current_track(|t| t.is_queue() || t.is_from_queue());
|
||||
|
||||
let new_index = if ctx.index.track as usize >= SPOTIFY_MAX_NEXT_TRACKS_SIZE {
|
||||
Some(ctx.index.track as usize - SPOTIFY_MAX_NEXT_TRACKS_SIZE)
|
||||
} else if is_queued_item {
|
||||
self.find_first_prev_track_index(new_context)
|
||||
} else {
|
||||
Self::find_index_in_context(new_context, |current| {
|
||||
self.current_track(|t| t.uri == current.uri)
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
.map(|i| i as u32 + 1);
|
||||
|
||||
Some(new_index.ok_or_else(|| {
|
||||
info!(
|
||||
"couldn't distinguish index from current or previous tracks in the updated context"
|
||||
);
|
||||
let fallback_index = self
|
||||
.player()
|
||||
.index
|
||||
.as_ref()
|
||||
.map(|i| i.track)
|
||||
.unwrap_or_default();
|
||||
info!("falling back to index {fallback_index}");
|
||||
fallback_index
|
||||
}))
|
||||
}
|
||||
|
||||
fn state_context_from_page(
|
||||
&mut self,
|
||||
page: ContextPage,
|
||||
metadata: HashMap<String, String>,
|
||||
restrictions: Option<Restrictions>,
|
||||
new_context_uri: Option<&str>,
|
||||
provider: Option<Provider>,
|
||||
|
@ -258,8 +357,12 @@ impl ConnectState {
|
|||
.tracks
|
||||
.iter()
|
||||
.flat_map(|track| {
|
||||
match self.context_to_provided_track(track, Some(new_context_uri), provider.clone())
|
||||
{
|
||||
match self.context_to_provided_track(
|
||||
track,
|
||||
Some(new_context_uri),
|
||||
Some(&page.metadata),
|
||||
provider.clone(),
|
||||
) {
|
||||
Ok(t) => Some(t),
|
||||
Err(why) => {
|
||||
error!("couldn't convert {track:#?} into ProvidedTrack: {why}");
|
||||
|
@ -272,7 +375,7 @@ impl ConnectState {
|
|||
StateContext {
|
||||
tracks,
|
||||
restrictions,
|
||||
metadata: page.metadata,
|
||||
metadata,
|
||||
index: ContextIndex::new(),
|
||||
}
|
||||
}
|
||||
|
@ -293,12 +396,11 @@ impl ConnectState {
|
|||
|
||||
let new_track_uri = new_track.uri.unwrap_or_default();
|
||||
if let Ok(position) =
|
||||
Self::find_index_in_context(Some(current_context), |t| t.uri == new_track_uri)
|
||||
Self::find_index_in_context(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);
|
||||
}
|
||||
|
||||
|
@ -334,10 +436,10 @@ impl ConnectState {
|
|||
&self,
|
||||
ctx_track: &ContextTrack,
|
||||
context_uri: Option<&str>,
|
||||
page_metadata: Option<&HashMap<String, String>>,
|
||||
provider: Option<Provider>,
|
||||
) -> Result<ProvidedTrack, Error> {
|
||||
let id = match (ctx_track.uri.as_ref(), ctx_track.gid.as_ref()) {
|
||||
(None, None) => Err(StateError::InvalidTrackUri(None))?,
|
||||
(Some(uri), _) if uri.contains(['?', '%']) => {
|
||||
Err(StateError::InvalidTrackUri(Some(uri.clone())))?
|
||||
}
|
||||
|
@ -363,7 +465,7 @@ impl ConnectState {
|
|||
_ => Uuid::new_v4().as_simple().to_string(),
|
||||
};
|
||||
|
||||
let mut metadata = HashMap::new();
|
||||
let mut metadata = page_metadata.cloned().unwrap_or_default();
|
||||
for (k, v) in &ctx_track.metadata {
|
||||
metadata.insert(k.to_string(), v.to_string());
|
||||
}
|
||||
|
@ -389,7 +491,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, None);
|
||||
let context = self.state_context_from_page(page, HashMap::new(), None, None, None);
|
||||
let ctx = self
|
||||
.context
|
||||
.as_mut()
|
||||
|
@ -401,26 +503,4 @@ impl ConnectState {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn try_load_next_context(&mut self) -> Result<LoadNext, Error> {
|
||||
let next = match self.next_contexts.first() {
|
||||
None => return Ok(LoadNext::Empty),
|
||||
Some(_) => self.next_contexts.remove(0),
|
||||
};
|
||||
|
||||
if next.tracks.is_empty() {
|
||||
let next_page_url = match next.page_url {
|
||||
Some(page_url) if !page_url.is_empty() => page_url,
|
||||
_ => Err(StateError::NoContext(ContextType::Default))?,
|
||||
};
|
||||
|
||||
self.update_current_index(|i| i.page += 1);
|
||||
return Ok(LoadNext::PageUrl(next_page_url));
|
||||
}
|
||||
|
||||
self.fill_context_from_page(next)?;
|
||||
self.fill_up_next_tracks()?;
|
||||
|
||||
Ok(LoadNext::Done)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue