diff --git a/CHANGELOG.md b/CHANGELOG.md index c89b12c3..a11d932e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) since v0.2.0. +## [Unreleased] + +### Added + +- [core] Add `SpotifyUri` type to represent more types of URI than `SpotifyId` can + +### Changed + +- [playback] Changed type of `SpotifyId` fields in `PlayerEvent` members to `SpotifyUri` (breaking) +- [metadata] Changed arguments for `Metadata` trait from `&SpotifyId` to `&SpotifyUri` (breaking) +- [player] `load` function changed from accepting a `SpotifyId` to accepting a `SpotifyUri` (breaking) +- [player] `preload` function changed from accepting a `SpotifyId` to accepting a `SpotifyUri` (breaking) +- [spclient] `get_radio_for_track` function changed from accepting a `SpotifyId` to accepting a `SpotifyUri` (breaking) + + +### Removed + +- [core] Removed `SpotifyItemType` enum; the new `SpotifyUri` is an enum over all item types and so which variant it is + describes its item type (breaking) +- [core] Removed `NamedSpotifyId` struct; it was made obsolete by `SpotifyUri` (breaking) +- [core] The following methods have been removed from `SpotifyId` and moved to `SpotifyUri` (breaking): + - `is_playable` + - `from_uri` + - `to_uri` + ## [v0.7.1] - 2025-08-31 ### Changed diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 087384e9..43702d8a 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -2,7 +2,7 @@ use crate::{ LoadContextOptions, LoadRequestOptions, PlayContext, context_resolver::{ContextAction, ContextResolver, ResolveContext}, core::{ - Error, Session, SpotifyId, + Error, Session, SpotifyUri, authentication::Credentials, dealer::{ manager::{BoxedStream, BoxedStreamResult, Reply, RequestReply}, @@ -778,7 +778,7 @@ impl SpircTask { return Ok(()); } PlayerEvent::Unavailable { track_id, .. } => { - self.handle_unavailable(track_id)?; + self.handle_unavailable(&track_id)?; if self.connect_state.current_track(|t| &t.uri) == &track_id.to_uri()? { self.handle_next(None)? } @@ -1499,7 +1499,7 @@ impl SpircTask { } // Mark unavailable tracks so we can skip them later - fn handle_unavailable(&mut self, track_id: SpotifyId) -> Result<(), Error> { + fn handle_unavailable(&mut self, track_id: &SpotifyUri) -> Result<(), Error> { self.connect_state.mark_unavailable(track_id)?; self.handle_preload_next_track(); @@ -1704,7 +1704,7 @@ impl SpircTask { } let current_uri = self.connect_state.current_track(|t| &t.uri); - let id = SpotifyId::from_uri(current_uri)?; + let id = SpotifyUri::from_uri(current_uri)?; self.player.load(id, start_playing, position_ms); self.connect_state diff --git a/connect/src/state/context.rs b/connect/src/state/context.rs index 7f0fc640..e2b78720 100644 --- a/connect/src/state/context.rs +++ b/connect/src/state/context.rs @@ -1,5 +1,5 @@ use crate::{ - core::{Error, SpotifyId}, + core::{Error, SpotifyId, SpotifyUri}, protocol::{ context::Context, context_page::ContextPage, @@ -449,8 +449,10 @@ impl ConnectState { (Some(uri), _) if uri.contains(['?', '%']) => { Err(StateError::InvalidTrackUri(Some(uri.clone())))? } - (Some(uri), _) if !uri.is_empty() => SpotifyId::from_uri(uri)?, - (_, Some(gid)) if !gid.is_empty() => SpotifyId::from_raw(gid)?, + (Some(uri), _) if !uri.is_empty() => SpotifyUri::from_uri(uri)?, + (_, Some(gid)) if !gid.is_empty() => SpotifyUri::Track { + id: SpotifyId::from_raw(gid)?, + }, _ => Err(StateError::InvalidTrackUri(None))?, }; diff --git a/connect/src/state/tracks.rs b/connect/src/state/tracks.rs index 94bde92b..8619035c 100644 --- a/connect/src/state/tracks.rs +++ b/connect/src/state/tracks.rs @@ -1,5 +1,5 @@ use crate::{ - core::{Error, SpotifyId}, + core::{Error, SpotifyUri}, protocol::player::ProvidedTrack, state::{ ConnectState, SPOTIFY_MAX_NEXT_TRACKS_SIZE, SPOTIFY_MAX_PREV_TRACKS_SIZE, StateError, @@ -352,14 +352,14 @@ impl<'ct> ConnectState { Ok(()) } - pub fn preview_next_track(&mut self) -> Option { + pub fn preview_next_track(&mut self) -> Option { let next = if self.repeat_track() { self.current_track(|t| &t.uri) } else { &self.next_tracks().first()?.uri }; - SpotifyId::from_uri(next).ok() + SpotifyUri::from_uri(next).ok() } pub fn has_next_tracks(&self, min: Option) -> bool { @@ -381,7 +381,7 @@ impl<'ct> ConnectState { prev } - pub fn mark_unavailable(&mut self, id: SpotifyId) -> Result<(), Error> { + pub fn mark_unavailable(&mut self, id: &SpotifyUri) -> Result<(), Error> { let uri = id.to_uri()?; debug!("marking {uri} as unavailable"); diff --git a/core/src/lib.rs b/core/src/lib.rs index f2d6587e..f4ead234 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -32,6 +32,7 @@ mod socket; #[allow(dead_code)] pub mod spclient; pub mod spotify_id; +pub mod spotify_uri; pub mod token; #[doc(hidden)] pub mod util; @@ -42,3 +43,4 @@ pub use error::Error; pub use file_id::FileId; pub use session::Session; pub use spotify_id::SpotifyId; +pub use spotify_uri::SpotifyUri; diff --git a/core/src/spclient.rs b/core/src/spclient.rs index 7d3f39e9..7bc9d0b5 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -5,7 +5,7 @@ use std::{ use crate::config::{OS, os_version}; use crate::{ - Error, FileId, SpotifyId, + Error, FileId, SpotifyId, SpotifyUri, apresolve::SocketAddress, config::SessionConfig, error::ErrorKind, @@ -676,10 +676,10 @@ impl SpClient { .await } - pub async fn get_radio_for_track(&self, track_id: &SpotifyId) -> SpClientResult { + pub async fn get_radio_for_track(&self, track_uri: &SpotifyUri) -> SpClientResult { let endpoint = format!( "/inspiredby-mix/v2/seed_to_playlist/{}?response-format=json", - track_id.to_uri()? + track_uri.to_uri()? ); self.request_as_json(&Method::GET, &endpoint, None, None) diff --git a/core/src/spotify_id.rs b/core/src/spotify_id.rs index f7478f54..c627b551 100644 --- a/core/src/spotify_id.rs +++ b/core/src/spotify_id.rs @@ -1,70 +1,23 @@ -use std::{fmt, ops::Deref}; +use std::fmt; use thiserror::Error; -use crate::Error; - -use librespot_protocol as protocol; +use crate::{Error, SpotifyUri}; // re-export FileId for historic reasons, when it was part of this mod pub use crate::FileId; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum SpotifyItemType { - Album, - Artist, - Episode, - Playlist, - Show, - Track, - Local, - Unknown, -} - -impl From<&str> for SpotifyItemType { - fn from(v: &str) -> Self { - match v { - "album" => Self::Album, - "artist" => Self::Artist, - "episode" => Self::Episode, - "playlist" => Self::Playlist, - "show" => Self::Show, - "track" => Self::Track, - "local" => Self::Local, - _ => Self::Unknown, - } - } -} - -impl From for &str { - fn from(item_type: SpotifyItemType) -> &'static str { - match item_type { - SpotifyItemType::Album => "album", - SpotifyItemType::Artist => "artist", - SpotifyItemType::Episode => "episode", - SpotifyItemType::Playlist => "playlist", - SpotifyItemType::Show => "show", - SpotifyItemType::Track => "track", - SpotifyItemType::Local => "local", - _ => "unknown", - } - } -} - #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub struct SpotifyId { pub id: u128, - pub item_type: SpotifyItemType, } #[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] pub enum SpotifyIdError { #[error("ID cannot be parsed")] InvalidId, - #[error("not a valid Spotify URI")] + #[error("not a valid Spotify ID")] InvalidFormat, - #[error("URI does not belong to Spotify")] - InvalidRoot, } impl From for Error { @@ -74,7 +27,6 @@ impl From for Error { } pub type SpotifyIdResult = Result; -pub type NamedSpotifyIdResult = Result; const BASE62_DIGITS: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; const BASE16_DIGITS: &[u8; 16] = b"0123456789abcdef"; @@ -84,14 +36,6 @@ impl SpotifyId { const SIZE_BASE16: usize = 32; const SIZE_BASE62: usize = 22; - /// Returns whether this `SpotifyId` is for a playable audio item, if known. - pub fn is_playable(&self) -> bool { - matches!( - self.item_type, - SpotifyItemType::Episode | SpotifyItemType::Track - ) - } - /// Parses a base16 (hex) encoded [Spotify ID] into a `SpotifyId`. /// /// `src` is expected to be 32 bytes long and encoded using valid characters. @@ -114,10 +58,7 @@ impl SpotifyId { dst += p; } - Ok(Self { - id: dst, - item_type: SpotifyItemType::Unknown, - }) + Ok(Self { id: dst }) } /// Parses a base62 encoded [Spotify ID] into a `u128`. @@ -126,7 +67,7 @@ impl SpotifyId { /// /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids pub fn from_base62(src: &str) -> SpotifyIdResult { - if src.len() != 22 { + if src.len() != Self::SIZE_BASE62 { return Err(SpotifyIdError::InvalidId.into()); } let mut dst: u128 = 0; @@ -143,10 +84,7 @@ impl SpotifyId { dst = dst.checked_add(p).ok_or(SpotifyIdError::InvalidId)?; } - Ok(Self { - id: dst, - item_type: SpotifyItemType::Unknown, - }) + Ok(Self { id: dst }) } /// Creates a `u128` from a copy of `SpotifyId::SIZE` (16) bytes in big-endian order. @@ -156,65 +94,11 @@ impl SpotifyId { match src.try_into() { Ok(dst) => Ok(Self { id: u128::from_be_bytes(dst), - item_type: SpotifyItemType::Unknown, }), Err(_) => Err(SpotifyIdError::InvalidId.into()), } } - /// Parses a [Spotify URI] into a `SpotifyId`. - /// - /// `uri` is expected to be in the canonical form `spotify:{type}:{id}`, where `{type}` - /// can be arbitrary while `{id}` is a 22-character long, base62 encoded Spotify ID. - /// - /// Note that this should not be used for playlists, which have the form of - /// `spotify:playlist:{id}`. - /// - /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids - pub fn from_uri(src: &str) -> SpotifyIdResult { - // Basic: `spotify:{type}:{id}` - // Named: `spotify:user:{user}:{type}:{id}` - // Local: `spotify:local:{artist}:{album_title}:{track_title}:{duration_in_seconds}` - let mut parts = src.split(':'); - - let scheme = parts.next().ok_or(SpotifyIdError::InvalidFormat)?; - - let item_type = { - let next = parts.next().ok_or(SpotifyIdError::InvalidFormat)?; - if next == "user" { - let _username = parts.next().ok_or(SpotifyIdError::InvalidFormat)?; - parts.next().ok_or(SpotifyIdError::InvalidFormat)? - } else { - next - } - }; - - let id = parts.next().ok_or(SpotifyIdError::InvalidFormat)?; - - if scheme != "spotify" { - return Err(SpotifyIdError::InvalidRoot.into()); - } - - let item_type = item_type.into(); - - // Local files have a variable-length ID: https://developer.spotify.com/documentation/general/guides/local-files-spotify-playlists/ - // TODO: find a way to add this local file ID to SpotifyId. - // One possible solution would be to copy the contents of `id` to a new String field in SpotifyId, - // but then we would need to remove the derived Copy trait, which would be a breaking change. - if item_type == SpotifyItemType::Local { - return Ok(Self { item_type, id: 0 }); - } - - if id.len() != Self::SIZE_BASE62 { - return Err(SpotifyIdError::InvalidId.into()); - } - - Ok(Self { - item_type, - ..Self::from_base62(id)? - }) - } - /// Returns the `SpotifyId` as a base16 (hex) encoded, `SpotifyId::SIZE_BASE16` (32) /// character long `String`. #[allow(clippy::wrong_self_convention)] @@ -274,124 +158,19 @@ impl SpotifyId { pub fn to_raw(&self) -> [u8; Self::SIZE] { self.id.to_be_bytes() } - - /// Returns the `SpotifyId` as a [Spotify URI] in the canonical form `spotify:{type}:{id}`, - /// where `{type}` is an arbitrary string and `{id}` is a 22-character long, base62 encoded - /// Spotify ID. - /// - /// If the `SpotifyId` has an associated type unrecognized by the library, `{type}` will - /// be encoded as `unknown`. - /// - /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids - #[allow(clippy::wrong_self_convention)] - pub fn to_uri(&self) -> Result { - // 8 chars for the "spotify:" prefix + 1 colon + 22 chars base62 encoded ID = 31 - // + unknown size item_type. - let item_type: &str = self.item_type.into(); - let mut dst = String::with_capacity(31 + item_type.len()); - dst.push_str("spotify:"); - dst.push_str(item_type); - dst.push(':'); - let base_62 = self.to_base62()?; - dst.push_str(&base_62); - - Ok(dst) - } } impl fmt::Debug for SpotifyId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_tuple("SpotifyId") - .field(&self.to_uri().unwrap_or_else(|_| "invalid uri".into())) + .field(&self.to_base62().unwrap_or_else(|_| "invalid uri".into())) .finish() } } impl fmt::Display for SpotifyId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.to_uri().unwrap_or_else(|_| "invalid uri".into())) - } -} - -#[derive(Clone, PartialEq, Eq, Hash)] -pub struct NamedSpotifyId { - pub inner_id: SpotifyId, - pub username: String, -} - -impl NamedSpotifyId { - pub fn from_uri(src: &str) -> NamedSpotifyIdResult { - let uri_parts: Vec<&str> = src.split(':').collect(); - - // At minimum, should be `spotify:user:{username}:{type}:{id}` - if uri_parts.len() < 5 { - return Err(SpotifyIdError::InvalidFormat.into()); - } - - if uri_parts[0] != "spotify" { - return Err(SpotifyIdError::InvalidRoot.into()); - } - - if uri_parts[1] != "user" { - return Err(SpotifyIdError::InvalidFormat.into()); - } - - Ok(Self { - inner_id: SpotifyId::from_uri(src)?, - username: uri_parts[2].to_owned(), - }) - } - - pub fn to_uri(&self) -> Result { - let item_type: &str = self.inner_id.item_type.into(); - let mut dst = String::with_capacity(37 + self.username.len() + item_type.len()); - dst.push_str("spotify:user:"); - dst.push_str(&self.username); - dst.push(':'); - dst.push_str(item_type); - dst.push(':'); - let base_62 = self.to_base62()?; - dst.push_str(&base_62); - - Ok(dst) - } - - pub fn from_spotify_id(id: SpotifyId, username: &str) -> Self { - Self { - inner_id: id, - username: username.to_owned(), - } - } -} - -impl Deref for NamedSpotifyId { - type Target = SpotifyId; - fn deref(&self) -> &Self::Target { - &self.inner_id - } -} - -impl fmt::Debug for NamedSpotifyId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_tuple("NamedSpotifyId") - .field( - &self - .inner_id - .to_uri() - .unwrap_or_else(|_| "invalid id".into()), - ) - .finish() - } -} - -impl fmt::Display for NamedSpotifyId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str( - &self - .inner_id - .to_uri() - .unwrap_or_else(|_| "invalid id".into()), - ) + f.write_str(&self.to_base62().unwrap_or_else(|_| "invalid uri".into())) } } @@ -423,104 +202,20 @@ impl TryFrom<&Vec> for SpotifyId { } } -impl TryFrom<&protocol::metadata::Album> for SpotifyId { +impl TryFrom<&SpotifyUri> for SpotifyId { type Error = crate::Error; - fn try_from(album: &protocol::metadata::Album) -> Result { - Ok(Self { - item_type: SpotifyItemType::Album, - ..Self::from_raw(album.gid())? - }) - } -} - -impl TryFrom<&protocol::metadata::Artist> for SpotifyId { - type Error = crate::Error; - fn try_from(artist: &protocol::metadata::Artist) -> Result { - Ok(Self { - item_type: SpotifyItemType::Artist, - ..Self::from_raw(artist.gid())? - }) - } -} - -impl TryFrom<&protocol::metadata::Episode> for SpotifyId { - type Error = crate::Error; - fn try_from(episode: &protocol::metadata::Episode) -> Result { - Ok(Self { - item_type: SpotifyItemType::Episode, - ..Self::from_raw(episode.gid())? - }) - } -} - -impl TryFrom<&protocol::metadata::Track> for SpotifyId { - type Error = crate::Error; - fn try_from(track: &protocol::metadata::Track) -> Result { - Ok(Self { - item_type: SpotifyItemType::Track, - ..Self::from_raw(track.gid())? - }) - } -} - -impl TryFrom<&protocol::metadata::Show> for SpotifyId { - type Error = crate::Error; - fn try_from(show: &protocol::metadata::Show) -> Result { - Ok(Self { - item_type: SpotifyItemType::Show, - ..Self::from_raw(show.gid())? - }) - } -} - -impl TryFrom<&protocol::metadata::ArtistWithRole> for SpotifyId { - type Error = crate::Error; - fn try_from(artist: &protocol::metadata::ArtistWithRole) -> Result { - Ok(Self { - item_type: SpotifyItemType::Artist, - ..Self::from_raw(artist.artist_gid())? - }) - } -} - -impl TryFrom<&protocol::playlist4_external::Item> for SpotifyId { - type Error = crate::Error; - fn try_from(item: &protocol::playlist4_external::Item) -> Result { - Ok(Self { - item_type: SpotifyItemType::Track, - ..Self::from_uri(item.uri())? - }) - } -} - -// Note that this is the unique revision of an item's metadata on a playlist, -// not the ID of that item or playlist. -impl TryFrom<&protocol::playlist4_external::MetaItem> for SpotifyId { - type Error = crate::Error; - fn try_from(item: &protocol::playlist4_external::MetaItem) -> Result { - Self::try_from(item.revision()) - } -} - -// Note that this is the unique revision of a playlist, not the ID of that playlist. -impl TryFrom<&protocol::playlist4_external::SelectedListContent> for SpotifyId { - type Error = crate::Error; - fn try_from( - playlist: &protocol::playlist4_external::SelectedListContent, - ) -> Result { - Self::try_from(playlist.revision()) - } -} - -// TODO: check meaning and format of this field in the wild. This might be a FileId, -// which is why we now don't create a separate `Playlist` enum value yet and choose -// to discard any item type. -impl TryFrom<&protocol::playlist_annotate3::TranscodedPicture> for SpotifyId { - type Error = crate::Error; - fn try_from( - picture: &protocol::playlist_annotate3::TranscodedPicture, - ) -> Result { - Self::from_base62(picture.uri()) + fn try_from(value: &SpotifyUri) -> Result { + match value { + SpotifyUri::Album { id } + | SpotifyUri::Artist { id } + | SpotifyUri::Episode { id } + | SpotifyUri::Playlist { id, .. } + | SpotifyUri::Show { id } + | SpotifyUri::Track { id } => Ok(*id), + SpotifyUri::Local { .. } | SpotifyUri::Unknown { .. } => { + Err(SpotifyIdError::InvalidFormat.into()) + } + } } } @@ -541,8 +236,6 @@ mod tests { struct ConversionCase { id: u128, - kind: SpotifyItemType, - uri: &'static str, base16: &'static str, base62: &'static str, raw: &'static [u8], @@ -551,8 +244,6 @@ mod tests { static CONV_VALID: [ConversionCase; 5] = [ ConversionCase { id: 238762092608182713602505436543891614649, - kind: SpotifyItemType::Track, - uri: "spotify:track:5sWHDYs0csV6RS48xBl0tH", base16: "b39fe8081e1f4c54be38e8d6f9f12bb9", base62: "5sWHDYs0csV6RS48xBl0tH", raw: &[ @@ -561,8 +252,6 @@ mod tests { }, ConversionCase { id: 204841891221366092811751085145916697048, - kind: SpotifyItemType::Track, - uri: "spotify:track:4GNcXTGWmnZ3ySrqvol3o4", base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", base62: "4GNcXTGWmnZ3ySrqvol3o4", raw: &[ @@ -571,8 +260,6 @@ mod tests { }, ConversionCase { id: 204841891221366092811751085145916697048, - kind: SpotifyItemType::Episode, - uri: "spotify:episode:4GNcXTGWmnZ3ySrqvol3o4", base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", base62: "4GNcXTGWmnZ3ySrqvol3o4", raw: &[ @@ -581,8 +268,6 @@ mod tests { }, ConversionCase { id: 204841891221366092811751085145916697048, - kind: SpotifyItemType::Show, - uri: "spotify:show:4GNcXTGWmnZ3ySrqvol3o4", base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", base62: "4GNcXTGWmnZ3ySrqvol3o4", raw: &[ @@ -591,8 +276,6 @@ mod tests { }, ConversionCase { id: 0, - kind: SpotifyItemType::Local, - uri: "spotify:local:0000000000000000000000", base16: "00000000000000000000000000000000", base62: "0000000000000000000000", raw: &[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], @@ -602,9 +285,6 @@ mod tests { static CONV_INVALID: [ConversionCase; 5] = [ ConversionCase { id: 0, - kind: SpotifyItemType::Unknown, - // Invalid ID in the URI. - uri: "spotify:arbitrarywhatever:5sWHDYs0Bl0tH", base16: "ZZZZZ8081e1f4c54be38e8d6f9f12bb9", base62: "!!!!!Ys0csV6RS48xBl0tH", raw: &[ @@ -614,9 +294,6 @@ mod tests { }, ConversionCase { id: 0, - kind: SpotifyItemType::Unknown, - // Missing colon between ID and type. - uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH", base16: "--------------------", base62: "....................", raw: &[ @@ -626,9 +303,6 @@ mod tests { }, ConversionCase { id: 0, - kind: SpotifyItemType::Unknown, - // Uri too short - uri: "spotify:azb:aRS48xBl0tH", // too long, should return error but not panic overflow base16: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // too long, should return error but not panic overflow @@ -640,9 +314,6 @@ mod tests { }, ConversionCase { id: 0, - kind: SpotifyItemType::Unknown, - // Uri too short - uri: "spotify:azb:aRS48xBl0tH", base16: "--------------------", // too short to encode a 128 bits int base62: "aa", @@ -653,8 +324,6 @@ mod tests { }, ConversionCase { id: 0, - kind: SpotifyItemType::Unknown, - uri: "cleary invalid uri", base16: "--------------------", // too high of a value, this would need a 132 bits int base62: "ZZZZZZZZZZZZZZZZZZZZZZ", @@ -679,10 +348,7 @@ mod tests { #[test] fn to_base62() { for c in &CONV_VALID { - let id = SpotifyId { - id: c.id, - item_type: c.kind, - }; + let id = SpotifyId { id: c.id }; assert_eq!(id.to_base62().unwrap(), c.base62); } @@ -702,60 +368,12 @@ mod tests { #[test] fn to_base16() { for c in &CONV_VALID { - let id = SpotifyId { - id: c.id, - item_type: c.kind, - }; + let id = SpotifyId { id: c.id }; assert_eq!(id.to_base16().unwrap(), c.base16); } } - #[test] - fn from_uri() { - for c in &CONV_VALID { - let actual = SpotifyId::from_uri(c.uri).unwrap(); - - assert_eq!(actual.id, c.id); - assert_eq!(actual.item_type, c.kind); - } - - for c in &CONV_INVALID { - assert!(SpotifyId::from_uri(c.uri).is_err()); - } - } - - #[test] - fn from_local_uri() { - let actual = SpotifyId::from_uri("spotify:local:xyz:123").unwrap(); - - assert_eq!(actual.id, 0); - assert_eq!(actual.item_type, SpotifyItemType::Local); - } - - #[test] - fn from_named_uri() { - let actual = - NamedSpotifyId::from_uri("spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI") - .unwrap(); - - assert_eq!(actual.id, 136159921382084734723401526672209703396); - assert_eq!(actual.item_type, SpotifyItemType::Playlist); - assert_eq!(actual.username, "spotify"); - } - - #[test] - fn to_uri() { - for c in &CONV_VALID { - let id = SpotifyId { - id: c.id, - item_type: c.kind, - }; - - assert_eq!(id.to_uri().unwrap(), c.uri); - } - } - #[test] fn from_raw() { for c in &CONV_VALID { diff --git a/core/src/spotify_uri.rs b/core/src/spotify_uri.rs new file mode 100644 index 00000000..647ec652 --- /dev/null +++ b/core/src/spotify_uri.rs @@ -0,0 +1,583 @@ +use crate::{Error, SpotifyId}; +use std::{borrow::Cow, fmt}; +use thiserror::Error; + +use librespot_protocol as protocol; + +const SPOTIFY_ITEM_TYPE_ALBUM: &str = "album"; +const SPOTIFY_ITEM_TYPE_ARTIST: &str = "artist"; +const SPOTIFY_ITEM_TYPE_EPISODE: &str = "episode"; +const SPOTIFY_ITEM_TYPE_PLAYLIST: &str = "playlist"; +const SPOTIFY_ITEM_TYPE_SHOW: &str = "show"; +const SPOTIFY_ITEM_TYPE_TRACK: &str = "track"; +const SPOTIFY_ITEM_TYPE_LOCAL: &str = "local"; +const SPOTIFY_ITEM_TYPE_UNKNOWN: &str = "unknown"; + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum SpotifyUriError { + #[error("not a valid Spotify URI")] + InvalidFormat, + #[error("URI does not belong to Spotify")] + InvalidRoot, +} + +impl From for Error { + fn from(err: SpotifyUriError) -> Self { + Error::invalid_argument(err) + } +} + +pub type SpotifyUriResult = Result; + +#[derive(Clone, PartialEq, Eq, Hash)] +pub enum SpotifyUri { + Album { + id: SpotifyId, + }, + Artist { + id: SpotifyId, + }, + Episode { + id: SpotifyId, + }, + Playlist { + user: Option, + id: SpotifyId, + }, + Show { + id: SpotifyId, + }, + Track { + id: SpotifyId, + }, + Local { + artist: String, + album_title: String, + track_title: String, + duration: std::time::Duration, + }, + Unknown { + kind: Cow<'static, str>, + id: String, + }, +} + +impl SpotifyUri { + /// Returns whether this `SpotifyUri` is for a playable audio item, if known. + pub fn is_playable(&self) -> bool { + matches!(self, SpotifyUri::Episode { .. } | SpotifyUri::Track { .. }) + } + + /// Gets the item type of this URI as a static string + pub fn item_type(&self) -> &'static str { + match &self { + SpotifyUri::Album { .. } => SPOTIFY_ITEM_TYPE_ALBUM, + SpotifyUri::Artist { .. } => SPOTIFY_ITEM_TYPE_ARTIST, + SpotifyUri::Episode { .. } => SPOTIFY_ITEM_TYPE_EPISODE, + SpotifyUri::Playlist { .. } => SPOTIFY_ITEM_TYPE_PLAYLIST, + SpotifyUri::Show { .. } => SPOTIFY_ITEM_TYPE_SHOW, + SpotifyUri::Track { .. } => SPOTIFY_ITEM_TYPE_TRACK, + SpotifyUri::Local { .. } => SPOTIFY_ITEM_TYPE_LOCAL, + SpotifyUri::Unknown { .. } => SPOTIFY_ITEM_TYPE_UNKNOWN, + } + } + + /// Gets the ID of this URI. The resource ID is the component of the URI that identifies + /// the resource after its type label. If `self` is a named ID, the user will be omitted. + pub fn to_id(&self) -> Result { + match &self { + SpotifyUri::Album { id } + | SpotifyUri::Artist { id } + | SpotifyUri::Episode { id } + | SpotifyUri::Playlist { id, .. } + | SpotifyUri::Show { id } + | SpotifyUri::Track { id } => id.to_base62(), + SpotifyUri::Local { + artist, + album_title, + track_title, + duration, + } => { + let duration_secs = duration.as_secs(); + Ok(format!( + "{artist}:{album_title}:{track_title}:{duration_secs}" + )) + } + SpotifyUri::Unknown { id, .. } => Ok(id.clone()), + } + } + + /// Parses a [Spotify URI] into a `SpotifyUri`. + /// + /// `uri` is expected to be in the canonical form `spotify:{type}:{id}`, where `{type}` + /// can be arbitrary while `{id}` is in a format that varies based on the `{type}`: + /// + /// - For most item types, a 22-character long, base62 encoded Spotify ID is expected. + /// - For local files, an arbitrary length string with the fields + /// `{artist}:{album_title}:{track_title}:{duration_in_seconds}` is expected. + /// + /// Spotify URI: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids + pub fn from_uri(src: &str) -> SpotifyUriResult { + // Basic: `spotify:{type}:{id}` + // Named: `spotify:user:{user}:{type}:{id}` + // Local: `spotify:local:{artist}:{album_title}:{track_title}:{duration_in_seconds}` + let mut parts = src.split(':'); + + let scheme = parts.next().ok_or(SpotifyUriError::InvalidFormat)?; + + if scheme != "spotify" { + return Err(SpotifyUriError::InvalidRoot.into()); + } + + let mut username: Option = None; + + let item_type = { + let next = parts.next().ok_or(SpotifyUriError::InvalidFormat)?; + if next == "user" { + username.replace( + parts + .next() + .ok_or(SpotifyUriError::InvalidFormat)? + .to_owned(), + ); + parts.next().ok_or(SpotifyUriError::InvalidFormat)? + } else { + next + } + }; + + let name = parts.next().ok_or(SpotifyUriError::InvalidFormat)?; + match item_type { + SPOTIFY_ITEM_TYPE_ALBUM => Ok(Self::Album { + id: SpotifyId::from_base62(name)?, + }), + SPOTIFY_ITEM_TYPE_ARTIST => Ok(Self::Artist { + id: SpotifyId::from_base62(name)?, + }), + SPOTIFY_ITEM_TYPE_EPISODE => Ok(Self::Episode { + id: SpotifyId::from_base62(name)?, + }), + SPOTIFY_ITEM_TYPE_PLAYLIST => Ok(Self::Playlist { + id: SpotifyId::from_base62(name)?, + user: username, + }), + SPOTIFY_ITEM_TYPE_SHOW => Ok(Self::Show { + id: SpotifyId::from_base62(name)?, + }), + SPOTIFY_ITEM_TYPE_TRACK => Ok(Self::Track { + id: SpotifyId::from_base62(name)?, + }), + SPOTIFY_ITEM_TYPE_LOCAL => Ok(Self::Local { + artist: "unimplemented".to_owned(), + album_title: "unimplemented".to_owned(), + track_title: "unimplemented".to_owned(), + duration: Default::default(), + }), + _ => Ok(Self::Unknown { + kind: item_type.to_owned().into(), + id: name.to_owned(), + }), + } + } + + /// Returns the `SpotifyUri` as a [Spotify URI] in the canonical form `spotify:{type}:{id}`, + /// where `{type}` is an arbitrary string and `{id}` is a 22-character long, base62 encoded + /// Spotify ID. + /// + /// If the `SpotifyUri` has an associated type unrecognized by the library, `{type}` will + /// be encoded as `unknown`. + /// + /// If the `SpotifyUri` is named, it will be returned in the form + /// `spotify:user:{user}:{type}:{id}`. + /// + /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids + pub fn to_uri(&self) -> Result { + let item_type = self.item_type(); + let name = self.to_id()?; + + if let SpotifyUri::Playlist { + id, + user: Some(user), + } = self + { + Ok(format!("spotify:user:{user}:{item_type}:{id}")) + } else { + Ok(format!("spotify:{item_type}:{name}")) + } + } + + /// Gets the name of this URI. The resource name is the component of the URI that identifies + /// the resource after its type label. If `self` is a named ID, the user will be omitted. + /// + /// Deprecated: not all IDs can be represented in Base62, so this function has been renamed to + /// [SpotifyUri::to_id], which this implementation forwards to. + #[deprecated(since = "0.8.0", note = "use to_name instead")] + pub fn to_base62(&self) -> Result { + self.to_id() + } +} + +impl fmt::Debug for SpotifyUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("SpotifyUri") + .field(&self.to_uri().unwrap_or_else(|_| "invalid uri".into())) + .finish() + } +} + +impl fmt::Display for SpotifyUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_uri().unwrap_or_else(|_| "invalid uri".into())) + } +} + +impl TryFrom<&protocol::metadata::Album> for SpotifyUri { + type Error = crate::Error; + fn try_from(album: &protocol::metadata::Album) -> Result { + Ok(Self::Album { + id: SpotifyId::from_raw(album.gid())?, + }) + } +} + +impl TryFrom<&protocol::metadata::Artist> for SpotifyUri { + type Error = crate::Error; + fn try_from(artist: &protocol::metadata::Artist) -> Result { + Ok(Self::Artist { + id: SpotifyId::from_raw(artist.gid())?, + }) + } +} + +impl TryFrom<&protocol::metadata::Episode> for SpotifyUri { + type Error = crate::Error; + fn try_from(episode: &protocol::metadata::Episode) -> Result { + Ok(Self::Episode { + id: SpotifyId::from_raw(episode.gid())?, + }) + } +} + +impl TryFrom<&protocol::metadata::Track> for SpotifyUri { + type Error = crate::Error; + fn try_from(track: &protocol::metadata::Track) -> Result { + Ok(Self::Track { + id: SpotifyId::from_raw(track.gid())?, + }) + } +} + +impl TryFrom<&protocol::metadata::Show> for SpotifyUri { + type Error = crate::Error; + fn try_from(show: &protocol::metadata::Show) -> Result { + Ok(Self::Show { + id: SpotifyId::from_raw(show.gid())?, + }) + } +} + +impl TryFrom<&protocol::metadata::ArtistWithRole> for SpotifyUri { + type Error = crate::Error; + fn try_from(artist: &protocol::metadata::ArtistWithRole) -> Result { + Ok(Self::Artist { + id: SpotifyId::from_raw(artist.artist_gid())?, + }) + } +} + +impl TryFrom<&protocol::playlist4_external::Item> for SpotifyUri { + type Error = crate::Error; + fn try_from(item: &protocol::playlist4_external::Item) -> Result { + Self::from_uri(item.uri()) + } +} + +// Note that this is the unique revision of an item's metadata on a playlist, +// not the ID of that item or playlist. +impl TryFrom<&protocol::playlist4_external::MetaItem> for SpotifyUri { + type Error = crate::Error; + fn try_from(item: &protocol::playlist4_external::MetaItem) -> Result { + Ok(Self::Unknown { + kind: "MetaItem".into(), + id: SpotifyId::try_from(item.revision())?.to_base62()?, + }) + } +} + +// Note that this is the unique revision of a playlist, not the ID of that playlist. +impl TryFrom<&protocol::playlist4_external::SelectedListContent> for SpotifyUri { + type Error = crate::Error; + fn try_from( + playlist: &protocol::playlist4_external::SelectedListContent, + ) -> Result { + Ok(Self::Unknown { + kind: "SelectedListContent".into(), + id: SpotifyId::try_from(playlist.revision())?.to_base62()?, + }) + } +} + +// TODO: check meaning and format of this field in the wild. This might be a FileId, +// which is why we now don't create a separate `Playlist` enum value yet and choose +// to discard any item type. +impl TryFrom<&protocol::playlist_annotate3::TranscodedPicture> for SpotifyUri { + type Error = crate::Error; + fn try_from( + picture: &protocol::playlist_annotate3::TranscodedPicture, + ) -> Result { + Ok(Self::Unknown { + kind: "TranscodedPicture".into(), + id: picture.uri().to_owned(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct ConversionCase { + parsed: SpotifyUri, + uri: &'static str, + base62: &'static str, + } + + static CONV_VALID: [ConversionCase; 4] = [ + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { + id: 238762092608182713602505436543891614649, + }, + }, + uri: "spotify:track:5sWHDYs0csV6RS48xBl0tH", + base62: "5sWHDYs0csV6RS48xBl0tH", + }, + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { + id: 204841891221366092811751085145916697048, + }, + }, + uri: "spotify:track:4GNcXTGWmnZ3ySrqvol3o4", + base62: "4GNcXTGWmnZ3ySrqvol3o4", + }, + ConversionCase { + parsed: SpotifyUri::Episode { + id: SpotifyId { + id: 204841891221366092811751085145916697048, + }, + }, + uri: "spotify:episode:4GNcXTGWmnZ3ySrqvol3o4", + base62: "4GNcXTGWmnZ3ySrqvol3o4", + }, + ConversionCase { + parsed: SpotifyUri::Show { + id: SpotifyId { + id: 204841891221366092811751085145916697048, + }, + }, + uri: "spotify:show:4GNcXTGWmnZ3ySrqvol3o4", + base62: "4GNcXTGWmnZ3ySrqvol3o4", + }, + ]; + + static CONV_INVALID: [ConversionCase; 5] = [ + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + // Invalid ID in the URI. + uri: "spotify:track:5sWHDYs0Bl0tH", + base62: "!!!!!Ys0csV6RS48xBl0tH", + }, + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + // Missing colon between ID and type. + uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH", + base62: "....................", + }, + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + // Uri too short + uri: "spotify:track:aRS48xBl0tH", + // too long, should return error but not panic overflow + base62: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + // Uri too short + uri: "spotify:track:aRS48xBl0tH", + // too short to encode a 128 bits int + base62: "aa", + }, + ConversionCase { + parsed: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + uri: "cleary invalid uri", + // too high of a value, this would need a 132 bits int + base62: "ZZZZZZZZZZZZZZZZZZZZZZ", + }, + ]; + + struct ItemTypeCase { + uri: SpotifyUri, + expected_type: &'static str, + } + + static ITEM_TYPES: [ItemTypeCase; 6] = [ + ItemTypeCase { + uri: SpotifyUri::Album { + id: SpotifyId { id: 0 }, + }, + expected_type: "album", + }, + ItemTypeCase { + uri: SpotifyUri::Artist { + id: SpotifyId { id: 0 }, + }, + expected_type: "artist", + }, + ItemTypeCase { + uri: SpotifyUri::Episode { + id: SpotifyId { id: 0 }, + }, + expected_type: "episode", + }, + ItemTypeCase { + uri: SpotifyUri::Playlist { + user: None, + id: SpotifyId { id: 0 }, + }, + expected_type: "playlist", + }, + ItemTypeCase { + uri: SpotifyUri::Show { + id: SpotifyId { id: 0 }, + }, + expected_type: "show", + }, + ItemTypeCase { + uri: SpotifyUri::Track { + id: SpotifyId { id: 0 }, + }, + expected_type: "track", + }, + ]; + + #[test] + fn to_id() { + for c in &CONV_VALID { + assert_eq!(c.parsed.to_id().unwrap(), c.base62); + } + } + + #[test] + fn item_type() { + for i in &ITEM_TYPES { + assert_eq!(i.uri.item_type(), i.expected_type); + } + + // These need to use methods that can't be used in the static context like to_owned() and + // into(). + + let local_file = SpotifyUri::Local { + artist: "".to_owned(), + album_title: "".to_owned(), + track_title: "".to_owned(), + duration: Default::default(), + }; + + assert_eq!(local_file.item_type(), "local"); + + let unknown = SpotifyUri::Unknown { + kind: "not used".into(), + id: "".to_owned(), + }; + + assert_eq!(unknown.item_type(), "unknown"); + } + + #[test] + fn from_uri() { + for c in &CONV_VALID { + let actual = SpotifyUri::from_uri(c.uri).unwrap(); + + assert_eq!(actual, c.parsed); + } + + for c in &CONV_INVALID { + assert!(SpotifyUri::from_uri(c.uri).is_err()); + } + } + + #[test] + fn from_invalid_type_uri() { + let actual = + SpotifyUri::from_uri("spotify:arbitrarywhatever:5sWHDYs0csV6RS48xBl0tH").unwrap(); + + assert_eq!( + actual, + SpotifyUri::Unknown { + kind: "arbitrarywhatever".into(), + id: "5sWHDYs0csV6RS48xBl0tH".to_owned() + } + ) + } + + #[test] + fn from_local_uri() { + let actual = SpotifyUri::from_uri("spotify:local:xyz:123").unwrap(); + + assert_eq!( + actual, + SpotifyUri::Local { + artist: "unimplemented".to_owned(), + album_title: "unimplemented".to_owned(), + track_title: "unimplemented".to_owned(), + duration: Default::default(), + } + ); + } + + #[test] + fn from_named_uri() { + let actual = + SpotifyUri::from_uri("spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI").unwrap(); + + let SpotifyUri::Playlist { ref user, id } = actual else { + panic!("wrong id type"); + }; + + assert_eq!(*user, Some("spotify".to_owned())); + assert_eq!( + id, + SpotifyId { + id: 136159921382084734723401526672209703396 + }, + ); + } + + #[test] + fn to_uri() { + for c in &CONV_VALID { + assert_eq!(c.parsed.to_uri().unwrap(), c.uri); + } + } + + #[test] + fn to_named_uri() { + let string = "spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI"; + + let actual = + SpotifyUri::from_uri("spotify:user:spotify:playlist:37i9dQZF1DWSw8liJZcPOI").unwrap(); + + assert_eq!(actual.to_uri().unwrap(), string); + } +} diff --git a/examples/play.rs b/examples/play.rs index fa751cbb..32a86069 100644 --- a/examples/play.rs +++ b/examples/play.rs @@ -2,10 +2,8 @@ use std::{env, process::exit}; use librespot::{ core::{ - authentication::Credentials, - config::SessionConfig, - session::Session, - spotify_id::{SpotifyId, SpotifyItemType}, + SpotifyUri, authentication::Credentials, config::SessionConfig, session::Session, + spotify_id::SpotifyId, }, playback::{ audio_backend, @@ -28,8 +26,9 @@ async fn main() { } let credentials = Credentials::with_access_token(&args[1]); - let mut track = SpotifyId::from_base62(&args[2]).unwrap(); - track.item_type = SpotifyItemType::Track; + let track = SpotifyUri::Track { + id: SpotifyId::from_base62(&args[2]).unwrap(), + }; let backend = audio_backend::find(None).unwrap(); diff --git a/examples/playlist_tracks.rs b/examples/playlist_tracks.rs index 1d6a4266..a1b5cad5 100644 --- a/examples/playlist_tracks.rs +++ b/examples/playlist_tracks.rs @@ -2,7 +2,8 @@ use std::{env, process::exit}; use librespot::{ core::{ - authentication::Credentials, config::SessionConfig, session::Session, spotify_id::SpotifyId, + authentication::Credentials, config::SessionConfig, session::Session, + spotify_uri::SpotifyUri, }, metadata::{Metadata, Playlist, Track}, }; @@ -19,7 +20,7 @@ async fn main() { } let credentials = Credentials::with_access_token(&args[1]); - let plist_uri = SpotifyId::from_uri(&args[2]).unwrap_or_else(|_| { + let plist_uri = SpotifyUri::from_uri(&args[2]).unwrap_or_else(|_| { eprintln!( "PLAYLIST should be a playlist URI such as: \ \"spotify:playlist:37i9dQZF1DXec50AjHrNTq\"" diff --git a/metadata/src/album.rs b/metadata/src/album.rs index 9be9364c..b1b26468 100644 --- a/metadata/src/album.rs +++ b/metadata/src/album.rs @@ -17,7 +17,7 @@ use crate::{ util::{impl_deref_wrapped, impl_try_from_repeated}, }; -use librespot_core::{Error, Session, SpotifyId, date::Date}; +use librespot_core::{Error, Session, SpotifyUri, date::Date}; use librespot_protocol as protocol; use protocol::metadata::Disc as DiscMessage; @@ -25,7 +25,7 @@ pub use protocol::metadata::album::Type as AlbumType; #[derive(Debug, Clone)] pub struct Album { - pub id: SpotifyId, + pub id: SpotifyUri, pub name: String, pub artists: Artists, pub album_type: AlbumType, @@ -48,9 +48,9 @@ pub struct Album { } #[derive(Debug, Clone, Default)] -pub struct Albums(pub Vec); +pub struct Albums(pub Vec); -impl_deref_wrapped!(Albums, Vec); +impl_deref_wrapped!(Albums, Vec); #[derive(Debug, Clone)] pub struct Disc { @@ -65,7 +65,7 @@ pub struct Discs(pub Vec); impl_deref_wrapped!(Discs, Vec); impl Album { - pub fn tracks(&self) -> impl Iterator { + pub fn tracks(&self) -> impl Iterator { self.discs.iter().flat_map(|disc| disc.tracks.iter()) } } @@ -74,11 +74,15 @@ impl Album { impl Metadata for Album { type Message = protocol::metadata::Album; - async fn request(session: &Session, album_id: &SpotifyId) -> RequestResult { + async fn request(session: &Session, album_uri: &SpotifyUri) -> RequestResult { + let SpotifyUri::Album { id: album_id } = album_uri else { + return Err(Error::invalid_argument("album_uri")); + }; + session.spclient().get_album_metadata(album_id).await } - fn parse(msg: &Self::Message, _: &SpotifyId) -> Result { + fn parse(msg: &Self::Message, _: &SpotifyUri) -> Result { Self::try_from(msg) } } diff --git a/metadata/src/artist.rs b/metadata/src/artist.rs index e875a985..5f443719 100644 --- a/metadata/src/artist.rs +++ b/metadata/src/artist.rs @@ -16,7 +16,7 @@ use crate::{ util::{impl_deref_wrapped, impl_from_repeated, impl_try_from_repeated}, }; -use librespot_core::{Error, Session, SpotifyId}; +use librespot_core::{Error, Session, SpotifyUri}; use librespot_protocol as protocol; pub use protocol::metadata::artist_with_role::ArtistRole; @@ -29,7 +29,7 @@ use protocol::metadata::TopTracks as TopTracksMessage; #[derive(Debug, Clone)] pub struct Artist { - pub id: SpotifyId, + pub id: SpotifyUri, pub name: String, pub popularity: i32, pub top_tracks: CountryTopTracks, @@ -56,7 +56,7 @@ impl_deref_wrapped!(Artists, Vec); #[derive(Debug, Clone)] pub struct ArtistWithRole { - pub id: SpotifyId, + pub id: SpotifyUri, pub name: String, pub role: ArtistRole, } @@ -140,14 +140,14 @@ impl Artist { /// Get the full list of albums, not containing duplicate variants of the same albums. /// /// See also [`AlbumGroups`](struct@AlbumGroups) and [`AlbumGroups::current_releases`] - pub fn albums_current(&self) -> impl Iterator { + pub fn albums_current(&self) -> impl Iterator { self.albums.current_releases() } /// Get the full list of singles, not containing duplicate variants of the same singles. /// /// See also [`AlbumGroups`](struct@AlbumGroups) and [`AlbumGroups::current_releases`] - pub fn singles_current(&self) -> impl Iterator { + pub fn singles_current(&self) -> impl Iterator { self.singles.current_releases() } @@ -155,14 +155,14 @@ impl Artist { /// compilations. /// /// See also [`AlbumGroups`](struct@AlbumGroups) and [`AlbumGroups::current_releases`] - pub fn compilations_current(&self) -> impl Iterator { + pub fn compilations_current(&self) -> impl Iterator { self.compilations.current_releases() } /// Get the full list of albums, not containing duplicate variants of the same albums. /// /// See also [`AlbumGroups`](struct@AlbumGroups) and [`AlbumGroups::current_releases`] - pub fn appears_on_albums_current(&self) -> impl Iterator { + pub fn appears_on_albums_current(&self) -> impl Iterator { self.appears_on_albums.current_releases() } } @@ -171,11 +171,15 @@ impl Artist { impl Metadata for Artist { type Message = protocol::metadata::Artist; - async fn request(session: &Session, artist_id: &SpotifyId) -> RequestResult { + async fn request(session: &Session, artist_uri: &SpotifyUri) -> RequestResult { + let SpotifyUri::Artist { id: artist_id } = artist_uri else { + return Err(Error::invalid_argument("artist_uri")); + }; + session.spclient().get_artist_metadata(artist_id).await } - fn parse(msg: &Self::Message, _: &SpotifyId) -> Result { + fn parse(msg: &Self::Message, _: &SpotifyUri) -> Result { Self::try_from(msg) } } @@ -249,7 +253,7 @@ impl AlbumGroups { /// Get the contained albums. This will only use the latest release / variant of an album if /// multiple variants are available. This should be used if multiple variants of the same album /// are not explicitely desired. - pub fn current_releases(&self) -> impl Iterator { + pub fn current_releases(&self) -> impl Iterator { self.iter().filter_map(|agrp| agrp.first()) } } diff --git a/metadata/src/audio/item.rs b/metadata/src/audio/item.rs index d398c8a0..3df63d9e 100644 --- a/metadata/src/audio/item.rs +++ b/metadata/src/audio/item.rs @@ -13,9 +13,7 @@ use crate::{ use super::file::AudioFiles; -use librespot_core::{ - Error, Session, SpotifyId, date::Date, session::UserData, spotify_id::SpotifyItemType, -}; +use librespot_core::{Error, Session, SpotifyUri, date::Date, session::UserData}; pub type AudioItemResult = Result; @@ -29,7 +27,7 @@ pub struct CoverImage { #[derive(Debug, Clone)] pub struct AudioItem { - pub track_id: SpotifyId, + pub track_id: SpotifyUri, pub uri: String, pub files: AudioFiles, pub name: String, @@ -60,14 +58,14 @@ pub enum UniqueFields { } impl AudioItem { - pub async fn get_file(session: &Session, id: SpotifyId) -> AudioItemResult { + pub async fn get_file(session: &Session, uri: SpotifyUri) -> AudioItemResult { let image_url = session .get_user_attribute("image-url") .unwrap_or_else(|| String::from("https://i.scdn.co/image/{file_id}")); - match id.item_type { - SpotifyItemType::Track => { - let track = Track::get(session, &id).await?; + match uri { + SpotifyUri::Track { .. } => { + let track = Track::get(session, &uri).await?; if track.duration <= 0 { return Err(Error::unavailable(MetadataError::InvalidDuration( @@ -79,8 +77,7 @@ impl AudioItem { return Err(Error::unavailable(MetadataError::ExplicitContentFiltered)); } - let track_id = track.id; - let uri = track_id.to_uri()?; + let uri_string = uri.to_uri()?; let album = track.album.name; let album_artists = track @@ -123,8 +120,8 @@ impl AudioItem { }; Ok(Self { - track_id, - uri, + track_id: uri, + uri: uri_string, files: track.files, name: track.name, covers, @@ -136,8 +133,8 @@ impl AudioItem { unique_fields, }) } - SpotifyItemType::Episode => { - let episode = Episode::get(session, &id).await?; + SpotifyUri::Episode { .. } => { + let episode = Episode::get(session, &uri).await?; if episode.duration <= 0 { return Err(Error::unavailable(MetadataError::InvalidDuration( @@ -149,8 +146,7 @@ impl AudioItem { return Err(Error::unavailable(MetadataError::ExplicitContentFiltered)); } - let track_id = episode.id; - let uri = track_id.to_uri()?; + let uri_string = uri.to_uri()?; let covers = get_covers(episode.covers, image_url); @@ -167,8 +163,8 @@ impl AudioItem { }; Ok(Self { - track_id, - uri, + track_id: uri, + uri: uri_string, files: episode.audio, name: episode.name, covers, diff --git a/metadata/src/episode.rs b/metadata/src/episode.rs index 4ba0a0da..847e8941 100644 --- a/metadata/src/episode.rs +++ b/metadata/src/episode.rs @@ -15,14 +15,14 @@ use crate::{ video::VideoFiles, }; -use librespot_core::{Error, Session, SpotifyId, date::Date}; +use librespot_core::{Error, Session, SpotifyUri, date::Date}; use librespot_protocol as protocol; pub use protocol::metadata::episode::EpisodeType; #[derive(Debug, Clone)] pub struct Episode { - pub id: SpotifyId, + pub id: SpotifyUri, pub name: String, pub duration: i32, pub audio: AudioFiles, @@ -49,19 +49,23 @@ pub struct Episode { } #[derive(Debug, Clone, Default)] -pub struct Episodes(pub Vec); +pub struct Episodes(pub Vec); -impl_deref_wrapped!(Episodes, Vec); +impl_deref_wrapped!(Episodes, Vec); #[async_trait] impl Metadata for Episode { type Message = protocol::metadata::Episode; - async fn request(session: &Session, episode_id: &SpotifyId) -> RequestResult { + async fn request(session: &Session, episode_uri: &SpotifyUri) -> RequestResult { + let SpotifyUri::Episode { id: episode_id } = episode_uri else { + return Err(Error::invalid_argument("episode_uri")); + }; + session.spclient().get_episode_metadata(episode_id).await } - fn parse(msg: &Self::Message, _: &SpotifyId) -> Result { + fn parse(msg: &Self::Message, _: &SpotifyUri) -> Result { Self::try_from(msg) } } diff --git a/metadata/src/image.rs b/metadata/src/image.rs index 30a1f4ed..4d201218 100644 --- a/metadata/src/image.rs +++ b/metadata/src/image.rs @@ -5,7 +5,7 @@ use std::{ use crate::util::{impl_deref_wrapped, impl_from_repeated, impl_try_from_repeated}; -use librespot_core::{FileId, SpotifyId}; +use librespot_core::{FileId, SpotifyUri}; use librespot_protocol as protocol; use protocol::metadata::Image as ImageMessage; @@ -47,7 +47,7 @@ impl_deref_wrapped!(PictureSizes, Vec); #[derive(Debug, Clone)] pub struct TranscodedPicture { pub target_name: String, - pub uri: SpotifyId, + pub uri: SpotifyUri, } #[derive(Debug, Clone)] diff --git a/metadata/src/lib.rs b/metadata/src/lib.rs index 1bb5b9f3..b097f98c 100644 --- a/metadata/src/lib.rs +++ b/metadata/src/lib.rs @@ -6,7 +6,7 @@ extern crate async_trait; use protobuf::Message; -use librespot_core::{Error, Session, SpotifyId}; +use librespot_core::{Error, Session, SpotifyUri}; pub mod album; pub mod artist; @@ -44,15 +44,15 @@ pub trait Metadata: Send + Sized + 'static { type Message: protobuf::Message + std::fmt::Debug; // Request a protobuf - async fn request(session: &Session, id: &SpotifyId) -> RequestResult; + async fn request(session: &Session, id: &SpotifyUri) -> RequestResult; // Request a metadata struct - async fn get(session: &Session, id: &SpotifyId) -> Result { + async fn get(session: &Session, id: &SpotifyUri) -> Result { let response = Self::request(session, id).await?; let msg = Self::Message::parse_from_bytes(&response)?; trace!("Received metadata: {msg:#?}"); Self::parse(&msg, id) } - fn parse(msg: &Self::Message, _: &SpotifyId) -> Result; + fn parse(msg: &Self::Message, _: &SpotifyUri) -> Result; } diff --git a/metadata/src/playlist/annotation.rs b/metadata/src/playlist/annotation.rs index bd703ee2..b11d34da 100644 --- a/metadata/src/playlist/annotation.rs +++ b/metadata/src/playlist/annotation.rs @@ -8,8 +8,7 @@ use crate::{ request::{MercuryRequest, RequestResult}, }; -use librespot_core::{Error, Session, SpotifyId}; - +use librespot_core::{Error, Session, SpotifyId, SpotifyUri}; use librespot_protocol as protocol; pub use protocol::playlist_annotate3::AbuseReportState; @@ -26,12 +25,20 @@ pub struct PlaylistAnnotation { impl Metadata for PlaylistAnnotation { type Message = protocol::playlist_annotate3::PlaylistAnnotation; - async fn request(session: &Session, playlist_id: &SpotifyId) -> RequestResult { + async fn request(session: &Session, playlist_uri: &SpotifyUri) -> RequestResult { let current_user = session.username(); + + let SpotifyUri::Playlist { + id: playlist_id, .. + } = playlist_uri + else { + return Err(Error::invalid_argument("playlist_uri")); + }; + Self::request_for_user(session, ¤t_user, playlist_id).await } - fn parse(msg: &Self::Message, _: &SpotifyId) -> Result { + fn parse(msg: &Self::Message, _: &SpotifyUri) -> Result { Ok(Self { description: msg.description().to_owned(), picture: msg.picture().to_owned(), // TODO: is this a URL or Spotify URI? @@ -60,11 +67,18 @@ impl PlaylistAnnotation { async fn get_for_user( session: &Session, username: &str, - playlist_id: &SpotifyId, + playlist_uri: &SpotifyUri, ) -> Result { + let SpotifyUri::Playlist { + id: playlist_id, .. + } = playlist_uri + else { + return Err(Error::invalid_argument("playlist_uri")); + }; + let response = Self::request_for_user(session, username, playlist_id).await?; let msg = ::Message::parse_from_bytes(&response)?; - Self::parse(&msg, playlist_id) + Self::parse(&msg, playlist_uri) } } diff --git a/metadata/src/playlist/item.rs b/metadata/src/playlist/item.rs index ce03f0de..1746857b 100644 --- a/metadata/src/playlist/item.rs +++ b/metadata/src/playlist/item.rs @@ -10,7 +10,7 @@ use super::{ permission::Capabilities, }; -use librespot_core::{SpotifyId, date::Date}; +use librespot_core::{SpotifyUri, date::Date}; use librespot_protocol as protocol; use protocol::playlist4_external::Item as PlaylistItemMessage; @@ -19,7 +19,7 @@ use protocol::playlist4_external::MetaItem as PlaylistMetaItemMessage; #[derive(Debug, Clone)] pub struct PlaylistItem { - pub id: SpotifyId, + pub id: SpotifyUri, pub attributes: PlaylistItemAttributes, } @@ -38,7 +38,7 @@ pub struct PlaylistItemList { #[derive(Debug, Clone)] pub struct PlaylistMetaItem { - pub revision: SpotifyId, + pub revision: SpotifyUri, pub attributes: PlaylistAttributes, pub length: i32, pub timestamp: Date, diff --git a/metadata/src/playlist/list.rs b/metadata/src/playlist/list.rs index 49ff1188..1052afd8 100644 --- a/metadata/src/playlist/list.rs +++ b/metadata/src/playlist/list.rs @@ -14,12 +14,7 @@ use super::{ permission::Capabilities, }; -use librespot_core::{ - Error, Session, - date::Date, - spotify_id::{NamedSpotifyId, SpotifyId}, -}; - +use librespot_core::{Error, Session, SpotifyUri, date::Date, spotify_id::SpotifyId}; use librespot_protocol as protocol; use protocol::playlist4_external::GeoblockBlockingType as Geoblock; @@ -30,7 +25,7 @@ impl_deref_wrapped!(Geoblocks, Vec); #[derive(Debug, Clone)] pub struct Playlist { - pub id: NamedSpotifyId, + pub id: SpotifyUri, pub revision: Vec, pub length: i32, pub attributes: PlaylistAttributes, @@ -72,7 +67,7 @@ pub struct SelectedListContent { } impl Playlist { - pub fn tracks(&self) -> impl ExactSizeIterator { + pub fn tracks(&self) -> impl ExactSizeIterator { let tracks = self.contents.items.iter().map(|item| &item.id); let length = tracks.len(); @@ -93,17 +88,35 @@ impl Playlist { impl Metadata for Playlist { type Message = protocol::playlist4_external::SelectedListContent; - async fn request(session: &Session, playlist_id: &SpotifyId) -> RequestResult { + async fn request(session: &Session, playlist_uri: &SpotifyUri) -> RequestResult { + let SpotifyUri::Playlist { + id: playlist_id, .. + } = playlist_uri + else { + return Err(Error::invalid_argument("playlist_uri")); + }; + session.spclient().get_playlist(playlist_id).await } - fn parse(msg: &Self::Message, id: &SpotifyId) -> Result { + fn parse(msg: &Self::Message, uri: &SpotifyUri) -> Result { + let SpotifyUri::Playlist { + id: playlist_id, .. + } = uri + else { + return Err(Error::invalid_argument("playlist_uri")); + }; + // the playlist proto doesn't contain the id so we decorate it let playlist = SelectedListContent::try_from(msg)?; - let id = NamedSpotifyId::from_spotify_id(*id, &playlist.owner_username); + + let new_uri = SpotifyUri::Playlist { + id: *playlist_id, + user: Some(playlist.owner_username), + }; Ok(Self { - id, + id: new_uri, revision: playlist.revision, length: playlist.length, attributes: playlist.attributes, diff --git a/metadata/src/show.rs b/metadata/src/show.rs index b326c652..01a55c2d 100644 --- a/metadata/src/show.rs +++ b/metadata/src/show.rs @@ -5,7 +5,7 @@ use crate::{ episode::Episodes, image::Images, restriction::Restrictions, }; -use librespot_core::{Error, Session, SpotifyId}; +use librespot_core::{Error, Session, SpotifyUri}; use librespot_protocol as protocol; pub use protocol::metadata::show::ConsumptionOrder as ShowConsumptionOrder; @@ -13,7 +13,7 @@ pub use protocol::metadata::show::MediaType as ShowMediaType; #[derive(Debug, Clone)] pub struct Show { - pub id: SpotifyId, + pub id: SpotifyUri, pub name: String, pub description: String, pub publisher: String, @@ -27,7 +27,7 @@ pub struct Show { pub media_type: ShowMediaType, pub consumption_order: ShowConsumptionOrder, pub availability: Availabilities, - pub trailer_uri: Option, + pub trailer_uri: Option, pub has_music_and_talk: bool, pub is_audiobook: bool, } @@ -36,11 +36,15 @@ pub struct Show { impl Metadata for Show { type Message = protocol::metadata::Show; - async fn request(session: &Session, show_id: &SpotifyId) -> RequestResult { + async fn request(session: &Session, show_uri: &SpotifyUri) -> RequestResult { + let SpotifyUri::Show { id: show_id } = show_uri else { + return Err(Error::invalid_argument("show_uri")); + }; + session.spclient().get_show_metadata(show_id).await } - fn parse(msg: &Self::Message, _: &SpotifyId) -> Result { + fn parse(msg: &Self::Message, _: &SpotifyUri) -> Result { Self::try_from(msg) } } @@ -67,7 +71,7 @@ impl TryFrom<&::Message> for Show { .trailer_uri .as_deref() .filter(|s| !s.is_empty()) - .map(SpotifyId::from_uri) + .map(SpotifyUri::from_uri) .transpose()?, has_music_and_talk: show.music_and_talk(), is_audiobook: show.is_audiobook(), diff --git a/metadata/src/track.rs b/metadata/src/track.rs index 78ea5481..5893ca15 100644 --- a/metadata/src/track.rs +++ b/metadata/src/track.rs @@ -17,12 +17,12 @@ use crate::{ util::{impl_deref_wrapped, impl_try_from_repeated}, }; -use librespot_core::{Error, Session, SpotifyId, date::Date}; +use librespot_core::{Error, Session, SpotifyUri, date::Date}; use librespot_protocol as protocol; #[derive(Debug, Clone)] pub struct Track { - pub id: SpotifyId, + pub id: SpotifyUri, pub name: String, pub album: Album, pub artists: Artists, @@ -50,19 +50,23 @@ pub struct Track { } #[derive(Debug, Clone, Default)] -pub struct Tracks(pub Vec); +pub struct Tracks(pub Vec); -impl_deref_wrapped!(Tracks, Vec); +impl_deref_wrapped!(Tracks, Vec); #[async_trait] impl Metadata for Track { type Message = protocol::metadata::Track; - async fn request(session: &Session, track_id: &SpotifyId) -> RequestResult { + async fn request(session: &Session, track_uri: &SpotifyUri) -> RequestResult { + let SpotifyUri::Track { id: track_id } = track_uri else { + return Err(Error::invalid_argument("track_uri")); + }; + session.spclient().get_track_metadata(track_id).await } - fn parse(msg: &Self::Message, _: &SpotifyId) -> Result { + fn parse(msg: &Self::Message, _: &SpotifyUri) -> Result { Self::try_from(msg) } } diff --git a/playback/src/player.rs b/playback/src/player.rs index ba2e5a4c..886c9cf3 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -15,27 +15,26 @@ use std::{ time::{Duration, Instant}, }; -use futures_util::{ - StreamExt, TryFutureExt, future, future::FusedFuture, - stream::futures_unordered::FuturesUnordered, -}; -use parking_lot::Mutex; -use symphonia::core::io::MediaSource; -use tokio::sync::{mpsc, oneshot}; - +#[cfg(feature = "passthrough-decoder")] +use crate::decoder::PassthroughDecoder; use crate::{ audio::{AudioDecrypt, AudioFetchParams, AudioFile, StreamLoaderController}, audio_backend::Sink, config::{Bitrate, NormalisationMethod, NormalisationType, PlayerConfig}, convert::Converter, - core::{Error, Session, SpotifyId, util::SeqGenerator}, + core::{Error, Session, SpotifyId, SpotifyUri, util::SeqGenerator}, decoder::{AudioDecoder, AudioPacket, AudioPacketPosition, SymphoniaDecoder}, metadata::audio::{AudioFileFormat, AudioFiles, AudioItem}, mixer::VolumeGetter, }; - -#[cfg(feature = "passthrough-decoder")] -use crate::decoder::PassthroughDecoder; +use futures_util::{ + StreamExt, TryFutureExt, future, future::FusedFuture, + stream::futures_unordered::FuturesUnordered, +}; +use librespot_metadata::track::Tracks; +use parking_lot::Mutex; +use symphonia::core::io::MediaSource; +use tokio::sync::{mpsc, oneshot}; use crate::SAMPLES_PER_SECOND; @@ -94,12 +93,12 @@ static PLAYER_COUNTER: AtomicUsize = AtomicUsize::new(0); enum PlayerCommand { Load { - track_id: SpotifyId, + track_id: SpotifyUri, play: bool, position_ms: u32, }, Preload { - track_id: SpotifyId, + track_id: SpotifyUri, }, Play, Pause, @@ -142,17 +141,17 @@ pub enum PlayerEvent { // Fired when the player is stopped (e.g. by issuing a "stop" command to the player). Stopped { play_request_id: u64, - track_id: SpotifyId, + track_id: SpotifyUri, }, // The player is delayed by loading a track. Loading { play_request_id: u64, - track_id: SpotifyId, + track_id: SpotifyUri, position_ms: u32, }, // The player is preloading a track. Preloading { - track_id: SpotifyId, + track_id: SpotifyUri, }, // The player is playing a track. // This event is issued at the start of playback of whenever the position must be communicated @@ -163,31 +162,31 @@ pub enum PlayerEvent { // after a buffer-underrun Playing { play_request_id: u64, - track_id: SpotifyId, + track_id: SpotifyUri, position_ms: u32, }, // The player entered a paused state. Paused { play_request_id: u64, - track_id: SpotifyId, + track_id: SpotifyUri, position_ms: u32, }, // The player thinks it's a good idea to issue a preload command for the next track now. // This event is intended for use within spirc. TimeToPreloadNextTrack { play_request_id: u64, - track_id: SpotifyId, + track_id: SpotifyUri, }, // The player reached the end of a track. // This event is intended for use within spirc. Spirc will respond by issuing another command. EndOfTrack { play_request_id: u64, - track_id: SpotifyId, + track_id: SpotifyUri, }, // The player was unable to load the requested track. Unavailable { play_request_id: u64, - track_id: SpotifyId, + track_id: SpotifyUri, }, // The mixer volume was set to a new level. VolumeChanged { @@ -195,7 +194,7 @@ pub enum PlayerEvent { }, PositionCorrection { play_request_id: u64, - track_id: SpotifyId, + track_id: SpotifyUri, position_ms: u32, }, /// Requires `PlayerConfig::position_update_interval` to be set to Some. @@ -203,12 +202,12 @@ pub enum PlayerEvent { /// current playback position PositionChanged { play_request_id: u64, - track_id: SpotifyId, + track_id: SpotifyUri, position_ms: u32, }, Seeked { play_request_id: u64, - track_id: SpotifyId, + track_id: SpotifyUri, position_ms: u32, }, TrackChanged { @@ -526,7 +525,7 @@ impl Player { } } - pub fn load(&self, track_id: SpotifyId, start_playing: bool, position_ms: u32) { + pub fn load(&self, track_id: SpotifyUri, start_playing: bool, position_ms: u32) { self.command(PlayerCommand::Load { track_id, play: start_playing, @@ -534,7 +533,7 @@ impl Player { }); } - pub fn preload(&self, track_id: SpotifyId) { + pub fn preload(&self, track_id: SpotifyUri) { self.command(PlayerCommand::Preload { track_id }); } @@ -660,11 +659,11 @@ struct PlayerLoadedTrackData { enum PlayerPreload { None, Loading { - track_id: SpotifyId, + track_id: SpotifyUri, loader: Pin> + Send>>, }, Ready { - track_id: SpotifyId, + track_id: SpotifyUri, loaded_track: Box, }, } @@ -674,13 +673,13 @@ type Decoder = Box; enum PlayerState { Stopped, Loading { - track_id: SpotifyId, + track_id: SpotifyUri, play_request_id: u64, start_playback: bool, loader: Pin> + Send>>, }, Paused { - track_id: SpotifyId, + track_id: SpotifyUri, play_request_id: u64, decoder: Decoder, audio_item: AudioItem, @@ -694,7 +693,7 @@ enum PlayerState { is_explicit: bool, }, Playing { - track_id: SpotifyId, + track_id: SpotifyUri, play_request_id: u64, decoder: Decoder, normalisation_data: NormalisationData, @@ -709,7 +708,7 @@ enum PlayerState { is_explicit: bool, }, EndOfTrack { - track_id: SpotifyId, + track_id: SpotifyUri, play_request_id: u64, loaded_track: PlayerLoadedTrackData, }, @@ -893,10 +892,12 @@ impl PlayerTrackLoader { None } else if !audio_item.files.is_empty() { Some(audio_item) - } else if let Some(alternatives) = &audio_item.alternatives { - let alternatives: FuturesUnordered<_> = alternatives - .iter() - .map(|alt_id| AudioItem::get_file(&self.session, *alt_id)) + } else if let Some(alternatives) = audio_item.alternatives { + let Tracks(alternatives_vec) = alternatives; // required to make `into_iter` able to move + + let alternatives: FuturesUnordered<_> = alternatives_vec + .into_iter() + .map(|alt_id| AudioItem::get_file(&self.session, alt_id)) .collect(); alternatives @@ -938,16 +939,40 @@ impl PlayerTrackLoader { async fn load_track( &self, - spotify_id: SpotifyId, + track_uri: SpotifyUri, position_ms: u32, ) -> Option { - let audio_item = match AudioItem::get_file(&self.session, spotify_id).await { + match track_uri { + SpotifyUri::Track { .. } | SpotifyUri::Episode { .. } => { + self.load_remote_track(track_uri, position_ms).await + } + _ => { + error!("Cannot handle load of track with URI: <{track_uri}>",); + None + } + } + } + + async fn load_remote_track( + &self, + track_uri: SpotifyUri, + position_ms: u32, + ) -> Option { + let track_id: SpotifyId = match (&track_uri).try_into() { + Ok(id) => id, + Err(_) => { + warn!("<{track_uri}> could not be converted to a base62 ID"); + return None; + } + }; + + let audio_item = match AudioItem::get_file(&self.session, track_uri).await { Ok(audio) => match self.find_available_alternative(audio).await { Some(audio) => audio, None => { warn!( - "<{}> is not available", - spotify_id.to_uri().unwrap_or_default() + "spotify:track:<{}> is not available", + track_id.to_base62().unwrap_or_default() ); return None; } @@ -1033,13 +1058,14 @@ impl PlayerTrackLoader { // Not all audio files are encrypted. If we can't get a key, try loading the track // without decryption. If the file was encrypted after all, the decoder will fail // parsing and bail out, so we should be safe from outputting ear-piercing noise. - let key = match self.session.audio_key().request(spotify_id, file_id).await { + let key = match self.session.audio_key().request(track_id, file_id).await { Ok(key) => Some(key), Err(e) => { warn!("Unable to load key, continuing without decryption: {e}"); None } }; + let mut decrypted_file = AudioDecrypt::new(key, encrypted_file); let is_ogg_vorbis = AudioFiles::is_ogg_vorbis(format); @@ -1195,13 +1221,15 @@ impl Future for PlayerInternal { // Handle loading of a new track to play if let PlayerState::Loading { ref mut loader, - track_id, + ref track_id, start_playback, play_request_id, } = self.state { // The loader may be terminated if we are trying to load the same track // as before, and that track failed to open before. + let track_id = track_id.clone(); + if !loader.as_mut().is_terminated() { match loader.as_mut().poll(cx) { Poll::Ready(Ok(loaded_track)) => { @@ -1233,12 +1261,15 @@ impl Future for PlayerInternal { // handle pending preload requests. if let PlayerPreload::Loading { ref mut loader, - track_id, + ref track_id, } = self.preload { + let track_id = track_id.clone(); match loader.as_mut().poll(cx) { Poll::Ready(Ok(loaded_track)) => { - self.send_event(PlayerEvent::Preloading { track_id }); + self.send_event(PlayerEvent::Preloading { + track_id: track_id.clone(), + }); self.preload = PlayerPreload::Ready { track_id, loaded_track: Box::new(loaded_track), @@ -1269,7 +1300,7 @@ impl Future for PlayerInternal { self.ensure_sink_running(); if let PlayerState::Playing { - track_id, + ref track_id, play_request_id, ref mut decoder, normalisation_factor, @@ -1278,6 +1309,7 @@ impl Future for PlayerInternal { .. } = self.state { + let track_id = track_id.clone(); match decoder.next_packet() { Ok(result) => { if let Some((ref packet_position, ref packet)) = result { @@ -1338,7 +1370,7 @@ impl Future for PlayerInternal { now.checked_sub(new_stream_position); self.send_event(PlayerEvent::PositionCorrection { play_request_id, - track_id, + track_id: track_id.clone(), position_ms: new_stream_position_ms, }); } @@ -1391,7 +1423,7 @@ impl Future for PlayerInternal { } if let PlayerState::Playing { - track_id, + ref track_id, play_request_id, duration_ms, stream_position_ms, @@ -1400,7 +1432,7 @@ impl Future for PlayerInternal { .. } | PlayerState::Paused { - track_id, + ref track_id, play_request_id, duration_ms, stream_position_ms, @@ -1409,6 +1441,8 @@ impl Future for PlayerInternal { .. } = self.state { + let track_id = track_id.clone(); + if (!*suggested_to_preload_next_track) && ((duration_ms as i64 - stream_position_ms as i64) < PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS as i64) @@ -1482,25 +1516,27 @@ impl PlayerInternal { fn handle_player_stop(&mut self) { match self.state { PlayerState::Playing { - track_id, + ref track_id, play_request_id, .. } | PlayerState::Paused { - track_id, + ref track_id, play_request_id, .. } | PlayerState::EndOfTrack { - track_id, + ref track_id, play_request_id, .. } | PlayerState::Loading { - track_id, + ref track_id, play_request_id, .. } => { + let track_id = track_id.clone(); + self.ensure_sink_stopped(false); self.send_event(PlayerEvent::Stopped { track_id, @@ -1519,11 +1555,13 @@ impl PlayerInternal { fn handle_play(&mut self) { match self.state { PlayerState::Paused { - track_id, + ref track_id, play_request_id, stream_position_ms, .. } => { + let track_id = track_id.clone(); + self.state.paused_to_playing(); self.send_event(PlayerEvent::Playing { track_id, @@ -1546,11 +1584,13 @@ impl PlayerInternal { match self.state { PlayerState::Paused { .. } => self.ensure_sink_stopped(false), PlayerState::Playing { - track_id, + ref track_id, play_request_id, stream_position_ms, .. } => { + let track_id = track_id.clone(); + self.state.playing_to_paused(); self.ensure_sink_stopped(false); @@ -1681,13 +1721,13 @@ impl PlayerInternal { None => { self.state.playing_to_end_of_track(); if let PlayerState::EndOfTrack { - track_id, + ref track_id, play_request_id, .. } = self.state { self.send_event(PlayerEvent::EndOfTrack { - track_id, + track_id: track_id.clone(), play_request_id, }) } else { @@ -1700,7 +1740,7 @@ impl PlayerInternal { fn start_playback( &mut self, - track_id: SpotifyId, + track_id: SpotifyUri, play_request_id: u64, loaded_track: PlayerLoadedTrackData, start_playback: bool, @@ -1725,7 +1765,7 @@ impl PlayerInternal { if start_playback { self.ensure_sink_running(); self.send_event(PlayerEvent::Playing { - track_id, + track_id: track_id.clone(), play_request_id, position_ms, }); @@ -1750,7 +1790,7 @@ impl PlayerInternal { self.ensure_sink_stopped(false); self.state = PlayerState::Paused { - track_id, + track_id: track_id.clone(), play_request_id, decoder: loaded_track.decoder, audio_item: loaded_track.audio_item, @@ -1774,7 +1814,7 @@ impl PlayerInternal { fn handle_command_load( &mut self, - track_id: SpotifyId, + track_id: SpotifyUri, play_request_id_option: Option, play: bool, position_ms: u32, @@ -1803,9 +1843,9 @@ impl PlayerInternal { if let PlayerState::EndOfTrack { track_id: previous_track_id, .. - } = self.state + } = &self.state { - if previous_track_id == track_id { + if *previous_track_id == track_id { let mut loaded_track = match mem::replace(&mut self.state, PlayerState::Invalid) { PlayerState::EndOfTrack { loaded_track, .. } => loaded_track, _ => { @@ -1834,19 +1874,19 @@ impl PlayerInternal { // Check if we are already playing the track. If so, just do a seek and update our info. if let PlayerState::Playing { - track_id: current_track_id, + track_id: ref current_track_id, ref mut stream_position_ms, ref mut decoder, .. } | PlayerState::Paused { - track_id: current_track_id, + track_id: ref current_track_id, ref mut stream_position_ms, ref mut decoder, .. } = self.state { - if current_track_id == track_id { + if *current_track_id == track_id { // we can use the current decoder. Ensure it's at the correct position. if position_ms != *stream_position_ms { // This may be blocking. @@ -1915,9 +1955,9 @@ impl PlayerInternal { if let PlayerPreload::Ready { track_id: loaded_track_id, .. - } = self.preload + } = &self.preload { - if track_id == loaded_track_id { + if track_id == *loaded_track_id { let preload = std::mem::replace(&mut self.preload, PlayerPreload::None); if let PlayerPreload::Ready { track_id, @@ -1940,7 +1980,7 @@ impl PlayerInternal { } self.send_event(PlayerEvent::Loading { - track_id, + track_id: track_id.clone(), play_request_id, position_ms, }); @@ -1949,9 +1989,9 @@ impl PlayerInternal { let loader = if let PlayerPreload::Loading { track_id: loaded_track_id, .. - } = self.preload + } = &self.preload { - if (track_id == loaded_track_id) && (position_ms == 0) { + if (track_id == *loaded_track_id) && (position_ms == 0) { let mut preload = PlayerPreload::None; std::mem::swap(&mut preload, &mut self.preload); if let PlayerPreload::Loading { loader, .. } = preload { @@ -1969,7 +2009,8 @@ impl PlayerInternal { self.preload = PlayerPreload::None; // If we don't have a loader yet, create one from scratch. - let loader = loader.unwrap_or_else(|| Box::pin(self.load_track(track_id, position_ms))); + let loader = + loader.unwrap_or_else(|| Box::pin(self.load_track(track_id.clone(), position_ms))); // Set ourselves to a loading state. self.state = PlayerState::Loading { @@ -1982,7 +2023,7 @@ impl PlayerInternal { Ok(()) } - fn handle_command_preload(&mut self, track_id: SpotifyId) { + fn handle_command_preload(&mut self, track_id: SpotifyUri) { debug!("Preloading track"); let mut preload_track = true; // check whether the track is already loaded somewhere or being loaded. @@ -1993,9 +2034,9 @@ impl PlayerInternal { | PlayerPreload::Ready { track_id: currently_loading, .. - } = self.preload + } = &self.preload { - if currently_loading == track_id { + if *currently_loading == track_id { // we're already preloading the requested track. preload_track = false; } else { @@ -2015,9 +2056,9 @@ impl PlayerInternal { | PlayerState::EndOfTrack { track_id: current_track_id, .. - } = self.state + } = &self.state { - if current_track_id == track_id { + if *current_track_id == track_id { // we already have the requested track loaded. preload_track = false; } @@ -2025,7 +2066,7 @@ impl PlayerInternal { // schedule the preload of the current track if desired. if preload_track { - let loader = self.load_track(track_id, 0); + let loader = self.load_track(track_id.clone(), 0); self.preload = PlayerPreload::Loading { track_id, loader: Box::pin(loader), @@ -2039,14 +2080,14 @@ impl PlayerInternal { // that. In this case just restart the loading process but // with the requested position. if let PlayerState::Loading { - track_id, + ref track_id, play_request_id, start_playback, .. } = self.state { return self.handle_command_load( - track_id, + track_id.clone(), Some(play_request_id), start_playback, position_ms, @@ -2058,13 +2099,13 @@ impl PlayerInternal { Ok(new_position_ms) => { if let PlayerState::Playing { ref mut stream_position_ms, - track_id, + ref track_id, play_request_id, .. } | PlayerState::Paused { ref mut stream_position_ms, - track_id, + ref track_id, play_request_id, .. } = self.state @@ -2073,7 +2114,7 @@ impl PlayerInternal { self.send_event(PlayerEvent::Seeked { play_request_id, - track_id, + track_id: track_id.clone(), position_ms: new_position_ms, }); } @@ -2177,18 +2218,20 @@ impl PlayerInternal { if filter { if let PlayerState::Playing { - track_id, + ref track_id, play_request_id, is_explicit, .. } | PlayerState::Paused { - track_id, + ref track_id, play_request_id, is_explicit, .. } = self.state { + let track_id = track_id.clone(); + if is_explicit { warn!( "Currently loaded track is explicit, which client setting forbids -- skipping to next track." @@ -2213,7 +2256,7 @@ impl PlayerInternal { fn load_track( &mut self, - spotify_id: SpotifyId, + spotify_uri: SpotifyUri, position_ms: u32, ) -> impl FusedFuture> + Send + 'static { // This method creates a future that returns the loaded stream and associated info. @@ -2231,8 +2274,9 @@ impl PlayerInternal { let load_handles_clone = self.load_handles.clone(); let handle = tokio::runtime::Handle::current(); + let load_handle = thread::spawn(move || { - let data = handle.block_on(loader.load_track(spotify_id, position_ms)); + let data = handle.block_on(loader.load_track(spotify_uri, position_ms)); if let Some(data) = data { let _ = result_tx.send(data); } @@ -2376,7 +2420,7 @@ impl fmt::Debug for PlayerCommand { impl fmt::Debug for PlayerState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { use PlayerState::*; - match *self { + match self { Stopped => f.debug_struct("Stopped").finish(), Loading { track_id, diff --git a/src/player_event_handler.rs b/src/player_event_handler.rs index 36695c99..51495932 100644 --- a/src/player_event_handler.rs +++ b/src/player_event_handler.rs @@ -28,7 +28,7 @@ impl EventHandler { env_vars.insert("PLAY_REQUEST_ID", play_request_id.to_string()); } PlayerEvent::TrackChanged { audio_item } => { - match audio_item.track_id.to_base62() { + match audio_item.track_id.to_id() { Err(e) => { warn!("PlayerEvent::TrackChanged: Invalid track id: {e}") } @@ -104,7 +104,7 @@ impl EventHandler { } } } - PlayerEvent::Stopped { track_id, .. } => match track_id.to_base62() { + PlayerEvent::Stopped { track_id, .. } => match track_id.to_id() { Err(e) => warn!("PlayerEvent::Stopped: Invalid track id: {e}"), Ok(id) => { env_vars.insert("PLAYER_EVENT", "stopped".to_string()); @@ -115,7 +115,7 @@ impl EventHandler { track_id, position_ms, .. - } => match track_id.to_base62() { + } => match track_id.to_id() { Err(e) => warn!("PlayerEvent::Playing: Invalid track id: {e}"), Ok(id) => { env_vars.insert("PLAYER_EVENT", "playing".to_string()); @@ -127,7 +127,7 @@ impl EventHandler { track_id, position_ms, .. - } => match track_id.to_base62() { + } => match track_id.to_id() { Err(e) => warn!("PlayerEvent::Paused: Invalid track id: {e}"), Ok(id) => { env_vars.insert("PLAYER_EVENT", "paused".to_string()); @@ -135,26 +135,24 @@ impl EventHandler { env_vars.insert("POSITION_MS", position_ms.to_string()); } }, - PlayerEvent::Loading { track_id, .. } => match track_id.to_base62() { + PlayerEvent::Loading { track_id, .. } => match track_id.to_id() { Err(e) => warn!("PlayerEvent::Loading: Invalid track id: {e}"), Ok(id) => { env_vars.insert("PLAYER_EVENT", "loading".to_string()); env_vars.insert("TRACK_ID", id); } }, - PlayerEvent::Preloading { track_id, .. } => { - match track_id.to_base62() { - Err(e) => { - warn!("PlayerEvent::Preloading: Invalid track id: {e}") - } - Ok(id) => { - env_vars.insert("PLAYER_EVENT", "preloading".to_string()); - env_vars.insert("TRACK_ID", id); - } + PlayerEvent::Preloading { track_id, .. } => match track_id.to_id() { + Err(e) => { + warn!("PlayerEvent::Preloading: Invalid track id: {e}") } - } + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "preloading".to_string()); + env_vars.insert("TRACK_ID", id); + } + }, PlayerEvent::TimeToPreloadNextTrack { track_id, .. } => { - match track_id.to_base62() { + match track_id.to_id() { Err(e) => warn!( "PlayerEvent::TimeToPreloadNextTrack: Invalid track id: {e}" ), @@ -164,19 +162,16 @@ impl EventHandler { } } } - PlayerEvent::EndOfTrack { track_id, .. } => { - match track_id.to_base62() { - Err(e) => { - warn!("PlayerEvent::EndOfTrack: Invalid track id: {e}") - } - Ok(id) => { - env_vars.insert("PLAYER_EVENT", "end_of_track".to_string()); - env_vars.insert("TRACK_ID", id); - } + PlayerEvent::EndOfTrack { track_id, .. } => match track_id.to_id() { + Err(e) => { + warn!("PlayerEvent::EndOfTrack: Invalid track id: {e}") } - } - PlayerEvent::Unavailable { track_id, .. } => match track_id.to_base62() - { + Ok(id) => { + env_vars.insert("PLAYER_EVENT", "end_of_track".to_string()); + env_vars.insert("TRACK_ID", id); + } + }, + PlayerEvent::Unavailable { track_id, .. } => match track_id.to_id() { Err(e) => warn!("PlayerEvent::Unavailable: Invalid track id: {e}"), Ok(id) => { env_vars.insert("PLAYER_EVENT", "unavailable".to_string()); @@ -191,7 +186,7 @@ impl EventHandler { track_id, position_ms, .. - } => match track_id.to_base62() { + } => match track_id.to_id() { Err(e) => warn!("PlayerEvent::Seeked: Invalid track id: {e}"), Ok(id) => { env_vars.insert("PLAYER_EVENT", "seeked".to_string()); @@ -203,7 +198,7 @@ impl EventHandler { track_id, position_ms, .. - } => match track_id.to_base62() { + } => match track_id.to_id() { Err(e) => { warn!("PlayerEvent::PositionCorrection: Invalid track id: {e}") }