diff --git a/Cargo.lock b/Cargo.lock index 57e50c03..1b537099 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1350,11 +1350,13 @@ dependencies = [ "async-trait", "byteorder", "bytes", + "chrono", "librespot-core", "librespot-protocol", "log", "protobuf", "thiserror", + "uuid", ] [[package]] diff --git a/connect/src/spirc.rs b/connect/src/spirc.rs index 57dc4cdd..e033b91d 100644 --- a/connect/src/spirc.rs +++ b/connect/src/spirc.rs @@ -1,3 +1,4 @@ +use std::convert::TryFrom; use std::future::Future; use std::pin::Pin; use std::time::{SystemTime, UNIX_EPOCH}; @@ -6,7 +7,7 @@ use crate::context::StationContext; use crate::core::config::ConnectConfig; use crate::core::mercury::{MercuryError, MercurySender}; use crate::core::session::Session; -use crate::core::spotify_id::{SpotifyAudioType, SpotifyId, SpotifyIdError}; +use crate::core::spotify_id::SpotifyId; use crate::core::util::SeqGenerator; use crate::core::version; use crate::playback::mixer::Mixer; @@ -1099,15 +1100,6 @@ impl SpircTask { } } - // should this be a method of SpotifyId directly? - fn get_spotify_id_for_track(&self, track_ref: &TrackRef) -> Result { - SpotifyId::from_raw(track_ref.get_gid()).or_else(|_| { - let uri = track_ref.get_uri(); - debug!("Malformed or no gid, attempting to parse URI <{}>", uri); - SpotifyId::from_uri(uri) - }) - } - // Helper to find corresponding index(s) for track_id fn get_track_index_for_spotify_id( &self, @@ -1146,11 +1138,8 @@ impl SpircTask { // E.g - context based frames sometimes contain tracks with let mut track_ref = self.state.get_track()[new_playlist_index].clone(); - let mut track_id = self.get_spotify_id_for_track(&track_ref); - while self.track_ref_is_unavailable(&track_ref) - || track_id.is_err() - || track_id.unwrap().audio_type == SpotifyAudioType::NonPlayable - { + let mut track_id = SpotifyId::try_from(&track_ref); + while self.track_ref_is_unavailable(&track_ref) || track_id.is_err() { warn!( "Skipping track <{:?}> at position [{}] of {}", track_ref, new_playlist_index, tracks_len @@ -1166,7 +1155,7 @@ impl SpircTask { return None; } track_ref = self.state.get_track()[new_playlist_index].clone(); - track_id = self.get_spotify_id_for_track(&track_ref); + track_id = SpotifyId::try_from(&track_ref); } match track_id { diff --git a/core/src/connection/handshake.rs b/core/src/connection/handshake.rs index 82ec7672..6b144ca0 100644 --- a/core/src/connection/handshake.rs +++ b/core/src/connection/handshake.rs @@ -49,7 +49,7 @@ where packet .mut_build_info() .set_platform(protocol::keyexchange::Platform::PLATFORM_LINUX_X86); - packet.mut_build_info().set_version(109800078); + packet.mut_build_info().set_version(999999999); packet .mut_cryptosuites_supported() .push(protocol::keyexchange::Cryptosuite::CRYPTO_SUITE_SHANNON); diff --git a/core/src/spclient.rs b/core/src/spclient.rs index 686d3012..a3bfe9c5 100644 --- a/core/src/spclient.rs +++ b/core/src/spclient.rs @@ -227,7 +227,6 @@ impl SpClient { self.get_metadata("show", show_id).await } - // TODO: Not working at the moment, always returns 400. pub async fn get_lyrics(&self, track_id: SpotifyId, image_id: FileId) -> SpClientResult { let endpoint = format!( "/color-lyrics/v2/track/{}/image/spotify:image:{}", diff --git a/core/src/spotify_id.rs b/core/src/spotify_id.rs index e6e2bae0..c03382a2 100644 --- a/core/src/spotify_id.rs +++ b/core/src/spotify_id.rs @@ -1,31 +1,46 @@ -#![allow(clippy::wrong_self_convention)] +use librespot_protocol as protocol; -use std::convert::TryInto; +use thiserror::Error; + +use std::convert::{TryFrom, TryInto}; use std::fmt; +use std::ops::Deref; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum SpotifyAudioType { +pub enum SpotifyItemType { + Album, + Artist, + Episode, + Playlist, + Show, Track, - Podcast, - NonPlayable, + Unknown, } -impl From<&str> for SpotifyAudioType { +impl From<&str> for SpotifyItemType { fn from(v: &str) -> Self { match v { - "track" => SpotifyAudioType::Track, - "episode" => SpotifyAudioType::Podcast, - _ => SpotifyAudioType::NonPlayable, + "album" => Self::Album, + "artist" => Self::Artist, + "episode" => Self::Episode, + "playlist" => Self::Playlist, + "show" => Self::Show, + "track" => Self::Track, + _ => Self::Unknown, } } } -impl From for &str { - fn from(audio_type: SpotifyAudioType) -> &'static str { - match audio_type { - SpotifyAudioType::Track => "track", - SpotifyAudioType::Podcast => "episode", - SpotifyAudioType::NonPlayable => "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", + _ => "unknown", } } } @@ -33,11 +48,21 @@ impl From for &str { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct SpotifyId { pub id: u128, - pub audio_type: SpotifyAudioType, + pub item_type: SpotifyItemType, } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -pub struct SpotifyIdError; +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum SpotifyIdError { + #[error("ID cannot be parsed")] + InvalidId, + #[error("not a valid Spotify URI")] + InvalidFormat, + #[error("URI does not belong to Spotify")] + InvalidRoot, +} + +pub type SpotifyIdResult = Result; +pub type NamedSpotifyIdResult = Result; const BASE62_DIGITS: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; const BASE16_DIGITS: &[u8; 16] = b"0123456789abcdef"; @@ -47,11 +72,12 @@ impl SpotifyId { const SIZE_BASE16: usize = 32; const SIZE_BASE62: usize = 22; - fn track(n: u128) -> SpotifyId { - SpotifyId { - id: n, - audio_type: SpotifyAudioType::Track, - } + /// Returns whether this `SpotifyId` is for a playable audio item, if known. + pub fn is_playable(&self) -> bool { + return matches!( + self.item_type, + SpotifyItemType::Episode | SpotifyItemType::Track + ); } /// Parses a base16 (hex) encoded [Spotify ID] into a `SpotifyId`. @@ -59,29 +85,32 @@ impl SpotifyId { /// `src` is expected to be 32 bytes long and encoded using valid characters. /// /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids - pub fn from_base16(src: &str) -> Result { + pub fn from_base16(src: &str) -> SpotifyIdResult { let mut dst: u128 = 0; for c in src.as_bytes() { let p = match c { b'0'..=b'9' => c - b'0', b'a'..=b'f' => c - b'a' + 10, - _ => return Err(SpotifyIdError), + _ => return Err(SpotifyIdError::InvalidId), } as u128; dst <<= 4; dst += p; } - Ok(SpotifyId::track(dst)) + Ok(Self { + id: dst, + item_type: SpotifyItemType::Unknown, + }) } - /// Parses a base62 encoded [Spotify ID] into a `SpotifyId`. + /// Parses a base62 encoded [Spotify ID] into a `u128`. /// /// `src` is expected to be 22 bytes long and encoded using valid characters. /// /// [Spotify ID]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids - pub fn from_base62(src: &str) -> Result { + pub fn from_base62(src: &str) -> SpotifyIdResult { let mut dst: u128 = 0; for c in src.as_bytes() { @@ -89,23 +118,29 @@ impl SpotifyId { b'0'..=b'9' => c - b'0', b'a'..=b'z' => c - b'a' + 10, b'A'..=b'Z' => c - b'A' + 36, - _ => return Err(SpotifyIdError), + _ => return Err(SpotifyIdError::InvalidId), } as u128; dst *= 62; dst += p; } - Ok(SpotifyId::track(dst)) + Ok(Self { + id: dst, + item_type: SpotifyItemType::Unknown, + }) } - /// Creates a `SpotifyId` from a copy of `SpotifyId::SIZE` (16) bytes in big-endian order. + /// Creates a `u128` from a copy of `SpotifyId::SIZE` (16) bytes in big-endian order. /// - /// The resulting `SpotifyId` will default to a `SpotifyAudioType::TRACK`. - pub fn from_raw(src: &[u8]) -> Result { + /// The resulting `SpotifyId` will default to a `SpotifyItemType::Unknown`. + pub fn from_raw(src: &[u8]) -> SpotifyIdResult { match src.try_into() { - Ok(dst) => Ok(SpotifyId::track(u128::from_be_bytes(dst))), - Err(_) => Err(SpotifyIdError), + Ok(dst) => Ok(Self { + id: u128::from_be_bytes(dst), + item_type: SpotifyItemType::Unknown, + }), + Err(_) => Err(SpotifyIdError::InvalidId), } } @@ -114,30 +149,37 @@ impl 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:user:{owner_username}:playlist:{id}`. + /// /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids - pub fn from_uri(src: &str) -> Result { - let src = src.strip_prefix("spotify:").ok_or(SpotifyIdError)?; + pub fn from_uri(src: &str) -> SpotifyIdResult { + let mut uri_parts: Vec<&str> = src.split(':').collect(); - if src.len() <= SpotifyId::SIZE_BASE62 { - return Err(SpotifyIdError); + // At minimum, should be `spotify:{type}:{id}` + if uri_parts.len() < 3 { + return Err(SpotifyIdError::InvalidFormat); } - let colon_index = src.len() - SpotifyId::SIZE_BASE62 - 1; - - if src.as_bytes()[colon_index] != b':' { - return Err(SpotifyIdError); + if uri_parts[0] != "spotify" { + return Err(SpotifyIdError::InvalidRoot); } - let mut id = SpotifyId::from_base62(&src[colon_index + 1..])?; - id.audio_type = src[..colon_index].into(); + let id = uri_parts.pop().unwrap(); + if id.len() != Self::SIZE_BASE62 { + return Err(SpotifyIdError::InvalidId); + } - Ok(id) + Ok(Self { + item_type: uri_parts.pop().unwrap().into(), + ..Self::from_base62(id)? + }) } /// Returns the `SpotifyId` as a base16 (hex) encoded, `SpotifyId::SIZE_BASE16` (32) /// character long `String`. pub fn to_base16(&self) -> String { - to_base16(&self.to_raw(), &mut [0u8; SpotifyId::SIZE_BASE16]) + to_base16(&self.to_raw(), &mut [0u8; Self::SIZE_BASE16]) } /// Returns the `SpotifyId` as a [canonically] base62 encoded, `SpotifyId::SIZE_BASE62` (22) @@ -190,7 +232,7 @@ impl SpotifyId { /// Returns a copy of the `SpotifyId` as an array of `SpotifyId::SIZE` (16) bytes in /// big-endian order. - pub fn to_raw(&self) -> [u8; SpotifyId::SIZE] { + pub fn to_raw(&self) -> [u8; Self::SIZE] { self.id.to_be_bytes() } @@ -204,11 +246,11 @@ impl SpotifyId { /// [Spotify URI]: https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids pub fn to_uri(&self) -> String { // 8 chars for the "spotify:" prefix + 1 colon + 22 chars base62 encoded ID = 31 - // + unknown size audio_type. - let audio_type: &str = self.audio_type.into(); - let mut dst = String::with_capacity(31 + audio_type.len()); + // + 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(audio_type); + dst.push_str(item_type); dst.push(':'); dst.push_str(&self.to_base62()); @@ -216,10 +258,214 @@ impl SpotifyId { } } +#[derive(Debug, 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); + } + + if uri_parts[0] != "spotify" { + return Err(SpotifyIdError::InvalidRoot); + } + + if uri_parts[1] != "user" { + return Err(SpotifyIdError::InvalidFormat); + } + + Ok(Self { + inner_id: SpotifyId::from_uri(src)?, + username: uri_parts[2].to_owned(), + }) + } + + pub fn to_uri(&self) -> String { + 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_str(item_type); + dst.push(':'); + dst.push_str(&self.to_base62()); + + dst + } + + pub fn from_spotify_id(id: SpotifyId, username: String) -> Self { + Self { + inner_id: id, + username, + } + } +} + +impl Deref for NamedSpotifyId { + type Target = SpotifyId; + fn deref(&self) -> &Self::Target { + &self.inner_id + } +} + +impl TryFrom<&[u8]> for SpotifyId { + type Error = SpotifyIdError; + fn try_from(src: &[u8]) -> Result { + Self::from_raw(src) + } +} + +impl TryFrom<&str> for SpotifyId { + type Error = SpotifyIdError; + fn try_from(src: &str) -> Result { + Self::from_base62(src) + } +} + +impl TryFrom for SpotifyId { + type Error = SpotifyIdError; + fn try_from(src: String) -> Result { + Self::try_from(src.as_str()) + } +} + +impl TryFrom<&Vec> for SpotifyId { + type Error = SpotifyIdError; + fn try_from(src: &Vec) -> Result { + Self::try_from(src.as_slice()) + } +} + +impl TryFrom<&protocol::spirc::TrackRef> for SpotifyId { + type Error = SpotifyIdError; + fn try_from(track: &protocol::spirc::TrackRef) -> Result { + match SpotifyId::from_raw(track.get_gid()) { + Ok(mut id) => { + id.item_type = SpotifyItemType::Track; + Ok(id) + } + Err(_) => SpotifyId::from_uri(track.get_uri()), + } + } +} + +impl TryFrom<&protocol::metadata::Album> for SpotifyId { + type Error = SpotifyIdError; + fn try_from(album: &protocol::metadata::Album) -> Result { + Ok(Self { + item_type: SpotifyItemType::Album, + ..Self::from_raw(album.get_gid())? + }) + } +} + +impl TryFrom<&protocol::metadata::Artist> for SpotifyId { + type Error = SpotifyIdError; + fn try_from(artist: &protocol::metadata::Artist) -> Result { + Ok(Self { + item_type: SpotifyItemType::Artist, + ..Self::from_raw(artist.get_gid())? + }) + } +} + +impl TryFrom<&protocol::metadata::Episode> for SpotifyId { + type Error = SpotifyIdError; + fn try_from(episode: &protocol::metadata::Episode) -> Result { + Ok(Self { + item_type: SpotifyItemType::Episode, + ..Self::from_raw(episode.get_gid())? + }) + } +} + +impl TryFrom<&protocol::metadata::Track> for SpotifyId { + type Error = SpotifyIdError; + fn try_from(track: &protocol::metadata::Track) -> Result { + Ok(Self { + item_type: SpotifyItemType::Track, + ..Self::from_raw(track.get_gid())? + }) + } +} + +impl TryFrom<&protocol::metadata::Show> for SpotifyId { + type Error = SpotifyIdError; + fn try_from(show: &protocol::metadata::Show) -> Result { + Ok(Self { + item_type: SpotifyItemType::Show, + ..Self::from_raw(show.get_gid())? + }) + } +} + +impl TryFrom<&protocol::metadata::ArtistWithRole> for SpotifyId { + type Error = SpotifyIdError; + fn try_from(artist: &protocol::metadata::ArtistWithRole) -> Result { + Ok(Self { + item_type: SpotifyItemType::Artist, + ..Self::from_raw(artist.get_artist_gid())? + }) + } +} + +impl TryFrom<&protocol::playlist4_external::Item> for SpotifyId { + type Error = SpotifyIdError; + fn try_from(item: &protocol::playlist4_external::Item) -> Result { + Ok(Self { + item_type: SpotifyItemType::Track, + ..Self::from_uri(item.get_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 = SpotifyIdError; + fn try_from(item: &protocol::playlist4_external::MetaItem) -> Result { + Self::try_from(item.get_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 = SpotifyIdError; + fn try_from( + playlist: &protocol::playlist4_external::SelectedListContent, + ) -> Result { + Self::try_from(playlist.get_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 = SpotifyIdError; + fn try_from( + picture: &protocol::playlist_annotate3::TranscodedPicture, + ) -> Result { + Self::from_base62(picture.get_uri()) + } +} + #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct FileId(pub [u8; 20]); impl FileId { + pub fn from_raw(src: &[u8]) -> FileId { + let mut dst = [0u8; 20]; + dst.clone_from_slice(src); + FileId(dst) + } + pub fn to_base16(&self) -> String { to_base16(&self.0, &mut [0u8; 40]) } @@ -237,6 +483,29 @@ impl fmt::Display for FileId { } } +impl From<&[u8]> for FileId { + fn from(src: &[u8]) -> Self { + Self::from_raw(src) + } +} +impl From<&protocol::metadata::Image> for FileId { + fn from(image: &protocol::metadata::Image) -> Self { + Self::from(image.get_file_id()) + } +} + +impl From<&protocol::metadata::AudioFile> for FileId { + fn from(file: &protocol::metadata::AudioFile) -> Self { + Self::from(file.get_file_id()) + } +} + +impl From<&protocol::metadata::VideoFile> for FileId { + fn from(video: &protocol::metadata::VideoFile) -> Self { + Self::from(video.get_file_id()) + } +} + #[inline] fn to_base16(src: &[u8], buf: &mut [u8]) -> String { let mut i = 0; @@ -258,7 +527,8 @@ mod tests { struct ConversionCase { id: u128, - kind: SpotifyAudioType, + kind: SpotifyItemType, + uri_error: Option, uri: &'static str, base16: &'static str, base62: &'static str, @@ -268,7 +538,8 @@ mod tests { static CONV_VALID: [ConversionCase; 4] = [ ConversionCase { id: 238762092608182713602505436543891614649, - kind: SpotifyAudioType::Track, + kind: SpotifyItemType::Track, + uri_error: None, uri: "spotify:track:5sWHDYs0csV6RS48xBl0tH", base16: "b39fe8081e1f4c54be38e8d6f9f12bb9", base62: "5sWHDYs0csV6RS48xBl0tH", @@ -278,7 +549,8 @@ mod tests { }, ConversionCase { id: 204841891221366092811751085145916697048, - kind: SpotifyAudioType::Track, + kind: SpotifyItemType::Track, + uri_error: None, uri: "spotify:track:4GNcXTGWmnZ3ySrqvol3o4", base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", base62: "4GNcXTGWmnZ3ySrqvol3o4", @@ -288,7 +560,8 @@ mod tests { }, ConversionCase { id: 204841891221366092811751085145916697048, - kind: SpotifyAudioType::Podcast, + kind: SpotifyItemType::Episode, + uri_error: None, uri: "spotify:episode:4GNcXTGWmnZ3ySrqvol3o4", base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", base62: "4GNcXTGWmnZ3ySrqvol3o4", @@ -298,8 +571,9 @@ mod tests { }, ConversionCase { id: 204841891221366092811751085145916697048, - kind: SpotifyAudioType::NonPlayable, - uri: "spotify:unknown:4GNcXTGWmnZ3ySrqvol3o4", + kind: SpotifyItemType::Show, + uri_error: None, + uri: "spotify:show:4GNcXTGWmnZ3ySrqvol3o4", base16: "9a1b1cfbc6f244569ae0356c77bbe9d8", base62: "4GNcXTGWmnZ3ySrqvol3o4", raw: &[ @@ -311,8 +585,9 @@ mod tests { static CONV_INVALID: [ConversionCase; 3] = [ ConversionCase { id: 0, - kind: SpotifyAudioType::NonPlayable, + kind: SpotifyItemType::Unknown, // Invalid ID in the URI. + uri_error: Some(SpotifyIdError::InvalidId), uri: "spotify:arbitrarywhatever:5sWHDYs0Bl0tH", base16: "ZZZZZ8081e1f4c54be38e8d6f9f12bb9", base62: "!!!!!Ys0csV6RS48xBl0tH", @@ -323,8 +598,9 @@ mod tests { }, ConversionCase { id: 0, - kind: SpotifyAudioType::NonPlayable, + kind: SpotifyItemType::Unknown, // Missing colon between ID and type. + uri_error: Some(SpotifyIdError::InvalidFormat), uri: "spotify:arbitrarywhatever5sWHDYs0csV6RS48xBl0tH", base16: "--------------------", base62: "....................", @@ -335,8 +611,9 @@ mod tests { }, ConversionCase { id: 0, - kind: SpotifyAudioType::NonPlayable, + kind: SpotifyItemType::Unknown, // Uri too short + uri_error: Some(SpotifyIdError::InvalidId), uri: "spotify:azb:aRS48xBl0tH", base16: "--------------------", base62: "....................", @@ -354,7 +631,10 @@ mod tests { } for c in &CONV_INVALID { - assert_eq!(SpotifyId::from_base62(c.base62), Err(SpotifyIdError)); + assert_eq!( + SpotifyId::from_base62(c.base62), + Err(SpotifyIdError::InvalidId) + ); } } @@ -363,7 +643,7 @@ mod tests { for c in &CONV_VALID { let id = SpotifyId { id: c.id, - audio_type: c.kind, + item_type: c.kind, }; assert_eq!(id.to_base62(), c.base62); @@ -377,7 +657,10 @@ mod tests { } for c in &CONV_INVALID { - assert_eq!(SpotifyId::from_base16(c.base16), Err(SpotifyIdError)); + assert_eq!( + SpotifyId::from_base16(c.base16), + Err(SpotifyIdError::InvalidId) + ); } } @@ -386,7 +669,7 @@ mod tests { for c in &CONV_VALID { let id = SpotifyId { id: c.id, - audio_type: c.kind, + item_type: c.kind, }; assert_eq!(id.to_base16(), c.base16); @@ -399,11 +682,11 @@ mod tests { let actual = SpotifyId::from_uri(c.uri).unwrap(); assert_eq!(actual.id, c.id); - assert_eq!(actual.audio_type, c.kind); + assert_eq!(actual.item_type, c.kind); } for c in &CONV_INVALID { - assert_eq!(SpotifyId::from_uri(c.uri), Err(SpotifyIdError)); + assert_eq!(SpotifyId::from_uri(c.uri), Err(c.uri_error.unwrap())); } } @@ -412,7 +695,7 @@ mod tests { for c in &CONV_VALID { let id = SpotifyId { id: c.id, - audio_type: c.kind, + item_type: c.kind, }; assert_eq!(id.to_uri(), c.uri); @@ -426,7 +709,7 @@ mod tests { } for c in &CONV_INVALID { - assert_eq!(SpotifyId::from_raw(c.raw), Err(SpotifyIdError)); + assert_eq!(SpotifyId::from_raw(c.raw), Err(SpotifyIdError::InvalidId)); } } } diff --git a/metadata/Cargo.toml b/metadata/Cargo.toml index 9409bae6..a12e12f8 100644 --- a/metadata/Cargo.toml +++ b/metadata/Cargo.toml @@ -11,9 +11,11 @@ edition = "2018" async-trait = "0.1" byteorder = "1.3" bytes = "1.0" +chrono = "0.4" log = "0.4" protobuf = "2.14.0" thiserror = "1" +uuid = { version = "0.8", default-features = false } [dependencies.librespot-core] path = "../core" diff --git a/metadata/src/album.rs b/metadata/src/album.rs new file mode 100644 index 00000000..fe01ee2b --- /dev/null +++ b/metadata/src/album.rs @@ -0,0 +1,151 @@ +use std::convert::{TryFrom, TryInto}; +use std::fmt::Debug; +use std::ops::Deref; + +use crate::{ + artist::Artists, + availability::Availabilities, + copyright::Copyrights, + date::Date, + error::{MetadataError, RequestError}, + external_id::ExternalIds, + image::Images, + request::RequestResult, + restriction::Restrictions, + sale_period::SalePeriods, + track::Tracks, + util::try_from_repeated_message, + Metadata, +}; + +use librespot_core::session::Session; +use librespot_core::spotify_id::SpotifyId; +use librespot_protocol as protocol; + +use protocol::metadata::Disc as DiscMessage; + +pub use protocol::metadata::Album_Type as AlbumType; + +#[derive(Debug, Clone)] +pub struct Album { + pub id: SpotifyId, + pub name: String, + pub artists: Artists, + pub album_type: AlbumType, + pub label: String, + pub date: Date, + pub popularity: i32, + pub genres: Vec, + pub covers: Images, + pub external_ids: ExternalIds, + pub discs: Discs, + pub reviews: Vec, + pub copyrights: Copyrights, + pub restrictions: Restrictions, + pub related: Albums, + pub sale_periods: SalePeriods, + pub cover_group: Images, + pub original_title: String, + pub version_title: String, + pub type_str: String, + pub availability: Availabilities, +} + +#[derive(Debug, Clone)] +pub struct Albums(pub Vec); + +impl Deref for Albums { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct Disc { + pub number: i32, + pub name: String, + pub tracks: Tracks, +} + +#[derive(Debug, Clone)] +pub struct Discs(pub Vec); + +impl Deref for Discs { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Album { + pub fn tracks(&self) -> Tracks { + let result = self + .discs + .iter() + .flat_map(|disc| disc.tracks.deref().clone()) + .collect(); + Tracks(result) + } +} + +#[async_trait] +impl Metadata for Album { + type Message = protocol::metadata::Album; + + async fn request(session: &Session, album_id: SpotifyId) -> RequestResult { + session + .spclient() + .get_album_metadata(album_id) + .await + .map_err(RequestError::Http) + } + + fn parse(msg: &Self::Message, _: SpotifyId) -> Result { + Self::try_from(msg) + } +} + +impl TryFrom<&::Message> for Album { + type Error = MetadataError; + fn try_from(album: &::Message) -> Result { + Ok(Self { + id: album.try_into()?, + name: album.get_name().to_owned(), + artists: album.get_artist().try_into()?, + album_type: album.get_field_type(), + label: album.get_label().to_owned(), + date: album.get_date().into(), + popularity: album.get_popularity(), + genres: album.get_genre().to_vec(), + covers: album.get_cover().into(), + external_ids: album.get_external_id().into(), + discs: album.get_disc().try_into()?, + reviews: album.get_review().to_vec(), + copyrights: album.get_copyright().into(), + restrictions: album.get_restriction().into(), + related: album.get_related().try_into()?, + sale_periods: album.get_sale_period().into(), + cover_group: album.get_cover_group().get_image().into(), + original_title: album.get_original_title().to_owned(), + version_title: album.get_version_title().to_owned(), + type_str: album.get_type_str().to_owned(), + availability: album.get_availability().into(), + }) + } +} + +try_from_repeated_message!(::Message, Albums); + +impl TryFrom<&DiscMessage> for Disc { + type Error = MetadataError; + fn try_from(disc: &DiscMessage) -> Result { + Ok(Self { + number: disc.get_number(), + name: disc.get_name().to_owned(), + tracks: disc.get_track().try_into()?, + }) + } +} + +try_from_repeated_message!(DiscMessage, Discs); diff --git a/metadata/src/artist.rs b/metadata/src/artist.rs new file mode 100644 index 00000000..517977bf --- /dev/null +++ b/metadata/src/artist.rs @@ -0,0 +1,139 @@ +use std::convert::{TryFrom, TryInto}; +use std::fmt::Debug; +use std::ops::Deref; + +use crate::{ + error::{MetadataError, RequestError}, + request::RequestResult, + track::Tracks, + util::try_from_repeated_message, + Metadata, +}; + +use librespot_core::session::Session; +use librespot_core::spotify_id::SpotifyId; +use librespot_protocol as protocol; + +use protocol::metadata::ArtistWithRole as ArtistWithRoleMessage; +use protocol::metadata::TopTracks as TopTracksMessage; + +pub use protocol::metadata::ArtistWithRole_ArtistRole as ArtistRole; + +#[derive(Debug, Clone)] +pub struct Artist { + pub id: SpotifyId, + pub name: String, + pub top_tracks: CountryTopTracks, +} + +#[derive(Debug, Clone)] +pub struct Artists(pub Vec); + +impl Deref for Artists { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct ArtistWithRole { + pub id: SpotifyId, + pub name: String, + pub role: ArtistRole, +} + +#[derive(Debug, Clone)] +pub struct ArtistsWithRole(pub Vec); + +impl Deref for ArtistsWithRole { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct TopTracks { + pub country: String, + pub tracks: Tracks, +} + +#[derive(Debug, Clone)] +pub struct CountryTopTracks(pub Vec); + +impl Deref for CountryTopTracks { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl CountryTopTracks { + pub fn for_country(&self, country: &str) -> Tracks { + if let Some(country) = self.0.iter().find(|top_track| top_track.country == country) { + return country.tracks.clone(); + } + + if let Some(global) = self.0.iter().find(|top_track| top_track.country.is_empty()) { + return global.tracks.clone(); + } + + Tracks(vec![]) // none found + } +} + +#[async_trait] +impl Metadata for Artist { + type Message = protocol::metadata::Artist; + + async fn request(session: &Session, artist_id: SpotifyId) -> RequestResult { + session + .spclient() + .get_artist_metadata(artist_id) + .await + .map_err(RequestError::Http) + } + + fn parse(msg: &Self::Message, _: SpotifyId) -> Result { + Self::try_from(msg) + } +} + +impl TryFrom<&::Message> for Artist { + type Error = MetadataError; + fn try_from(artist: &::Message) -> Result { + Ok(Self { + id: artist.try_into()?, + name: artist.get_name().to_owned(), + top_tracks: artist.get_top_track().try_into()?, + }) + } +} + +try_from_repeated_message!(::Message, Artists); + +impl TryFrom<&ArtistWithRoleMessage> for ArtistWithRole { + type Error = MetadataError; + fn try_from(artist_with_role: &ArtistWithRoleMessage) -> Result { + Ok(Self { + id: artist_with_role.try_into()?, + name: artist_with_role.get_artist_name().to_owned(), + role: artist_with_role.get_role(), + }) + } +} + +try_from_repeated_message!(ArtistWithRoleMessage, ArtistsWithRole); + +impl TryFrom<&TopTracksMessage> for TopTracks { + type Error = MetadataError; + fn try_from(top_tracks: &TopTracksMessage) -> Result { + Ok(Self { + country: top_tracks.get_country().to_owned(), + tracks: top_tracks.get_track().try_into()?, + }) + } +} + +try_from_repeated_message!(TopTracksMessage, CountryTopTracks); diff --git a/metadata/src/audio/file.rs b/metadata/src/audio/file.rs new file mode 100644 index 00000000..01ec984e --- /dev/null +++ b/metadata/src/audio/file.rs @@ -0,0 +1,31 @@ +use std::collections::HashMap; +use std::fmt::Debug; +use std::ops::Deref; + +use librespot_core::spotify_id::FileId; +use librespot_protocol as protocol; + +use protocol::metadata::AudioFile as AudioFileMessage; + +pub use protocol::metadata::AudioFile_Format as AudioFileFormat; + +#[derive(Debug, Clone)] +pub struct AudioFiles(pub HashMap); + +impl Deref for AudioFiles { + type Target = HashMap; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From<&[AudioFileMessage]> for AudioFiles { + fn from(files: &[AudioFileMessage]) -> Self { + let audio_files = files + .iter() + .map(|file| (file.get_format(), FileId::from(file.get_file_id()))) + .collect(); + + AudioFiles(audio_files) + } +} diff --git a/metadata/src/audio/item.rs b/metadata/src/audio/item.rs new file mode 100644 index 00000000..09b72ebc --- /dev/null +++ b/metadata/src/audio/item.rs @@ -0,0 +1,104 @@ +use std::fmt::Debug; + +use chrono::Local; + +use crate::{ + availability::{AudioItemAvailability, Availabilities, UnavailabilityReason}, + episode::Episode, + error::MetadataError, + restriction::Restrictions, + track::{Track, Tracks}, +}; + +use super::file::AudioFiles; + +use librespot_core::session::Session; +use librespot_core::spotify_id::{SpotifyId, SpotifyItemType}; + +pub type AudioItemResult = Result; + +// A wrapper with fields the player needs +#[derive(Debug, Clone)] +pub struct AudioItem { + pub id: SpotifyId, + pub spotify_uri: String, + pub files: AudioFiles, + pub name: String, + pub duration: i32, + pub availability: AudioItemAvailability, + pub alternatives: Option, +} + +impl AudioItem { + pub async fn get_file(session: &Session, id: SpotifyId) -> AudioItemResult { + match id.item_type { + SpotifyItemType::Track => Track::get_audio_item(session, id).await, + SpotifyItemType::Episode => Episode::get_audio_item(session, id).await, + _ => Err(MetadataError::NonPlayable), + } + } +} + +#[async_trait] +pub trait InnerAudioItem { + async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult; + + fn allowed_in_country(restrictions: &Restrictions, country: &str) -> AudioItemAvailability { + for premium_restriction in restrictions.iter().filter(|restriction| { + restriction + .catalogue_strs + .iter() + .any(|catalogue| *catalogue == "premium") + }) { + if let Some(allowed_countries) = &premium_restriction.countries_allowed { + // A restriction will specify either a whitelast *or* a blacklist, + // but not both. So restrict availability if there is a whitelist + // and the country isn't on it. + if allowed_countries.iter().any(|allowed| country == *allowed) { + return Ok(()); + } else { + return Err(UnavailabilityReason::NotWhitelisted); + } + } + + if let Some(forbidden_countries) = &premium_restriction.countries_forbidden { + if forbidden_countries + .iter() + .any(|forbidden| country == *forbidden) + { + return Err(UnavailabilityReason::Blacklisted); + } else { + return Ok(()); + } + } + } + + Ok(()) // no restrictions in place + } + + fn available(availability: &Availabilities) -> AudioItemAvailability { + if availability.is_empty() { + // not all items have availability specified + return Ok(()); + } + + if !(availability + .iter() + .any(|availability| Local::now() >= availability.start.as_utc())) + { + return Err(UnavailabilityReason::Embargo); + } + + Ok(()) + } + + fn available_in_country( + availability: &Availabilities, + restrictions: &Restrictions, + country: &str, + ) -> AudioItemAvailability { + Self::available(availability)?; + Self::allowed_in_country(restrictions, country)?; + Ok(()) + } +} diff --git a/metadata/src/audio/mod.rs b/metadata/src/audio/mod.rs new file mode 100644 index 00000000..cc4efef0 --- /dev/null +++ b/metadata/src/audio/mod.rs @@ -0,0 +1,5 @@ +pub mod file; +pub mod item; + +pub use file::AudioFileFormat; +pub use item::AudioItem; diff --git a/metadata/src/availability.rs b/metadata/src/availability.rs new file mode 100644 index 00000000..c40427cb --- /dev/null +++ b/metadata/src/availability.rs @@ -0,0 +1,49 @@ +use std::fmt::Debug; +use std::ops::Deref; + +use thiserror::Error; + +use crate::{date::Date, util::from_repeated_message}; + +use librespot_protocol as protocol; + +use protocol::metadata::Availability as AvailabilityMessage; + +pub type AudioItemAvailability = Result<(), UnavailabilityReason>; + +#[derive(Debug, Clone)] +pub struct Availability { + pub catalogue_strs: Vec, + pub start: Date, +} + +#[derive(Debug, Clone)] +pub struct Availabilities(pub Vec); + +impl Deref for Availabilities { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Copy, Clone, Error)] +pub enum UnavailabilityReason { + #[error("blacklist present and country on it")] + Blacklisted, + #[error("available date is in the future")] + Embargo, + #[error("whitelist present and country not on it")] + NotWhitelisted, +} + +impl From<&AvailabilityMessage> for Availability { + fn from(availability: &AvailabilityMessage) -> Self { + Self { + catalogue_strs: availability.get_catalogue_str().to_vec(), + start: availability.get_start().into(), + } + } +} + +from_repeated_message!(AvailabilityMessage, Availabilities); diff --git a/metadata/src/content_rating.rs b/metadata/src/content_rating.rs new file mode 100644 index 00000000..a6f061d0 --- /dev/null +++ b/metadata/src/content_rating.rs @@ -0,0 +1,35 @@ +use std::fmt::Debug; +use std::ops::Deref; + +use crate::util::from_repeated_message; + +use librespot_protocol as protocol; + +use protocol::metadata::ContentRating as ContentRatingMessage; + +#[derive(Debug, Clone)] +pub struct ContentRating { + pub country: String, + pub tags: Vec, +} + +#[derive(Debug, Clone)] +pub struct ContentRatings(pub Vec); + +impl Deref for ContentRatings { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From<&ContentRatingMessage> for ContentRating { + fn from(content_rating: &ContentRatingMessage) -> Self { + Self { + country: content_rating.get_country().to_owned(), + tags: content_rating.get_tag().to_vec(), + } + } +} + +from_repeated_message!(ContentRatingMessage, ContentRatings); diff --git a/metadata/src/copyright.rs b/metadata/src/copyright.rs new file mode 100644 index 00000000..7842b7dd --- /dev/null +++ b/metadata/src/copyright.rs @@ -0,0 +1,37 @@ +use std::fmt::Debug; +use std::ops::Deref; + +use librespot_protocol as protocol; + +use crate::util::from_repeated_message; + +use protocol::metadata::Copyright as CopyrightMessage; + +pub use protocol::metadata::Copyright_Type as CopyrightType; + +#[derive(Debug, Clone)] +pub struct Copyright { + pub copyright_type: CopyrightType, + pub text: String, +} + +#[derive(Debug, Clone)] +pub struct Copyrights(pub Vec); + +impl Deref for Copyrights { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From<&CopyrightMessage> for Copyright { + fn from(copyright: &CopyrightMessage) -> Self { + Self { + copyright_type: copyright.get_field_type(), + text: copyright.get_text().to_owned(), + } + } +} + +from_repeated_message!(CopyrightMessage, Copyrights); diff --git a/metadata/src/cover.rs b/metadata/src/cover.rs deleted file mode 100644 index b483f454..00000000 --- a/metadata/src/cover.rs +++ /dev/null @@ -1,20 +0,0 @@ -use byteorder::{BigEndian, WriteBytesExt}; -use std::io::Write; - -use librespot_core::channel::ChannelData; -use librespot_core::packet::PacketType; -use librespot_core::session::Session; -use librespot_core::spotify_id::FileId; - -pub fn get(session: &Session, file: FileId) -> ChannelData { - let (channel_id, channel) = session.channel().allocate(); - let (_headers, data) = channel.split(); - - let mut packet: Vec = Vec::new(); - packet.write_u16::(channel_id).unwrap(); - packet.write_u16::(0).unwrap(); - packet.write(&file.0).unwrap(); - session.send_packet(PacketType::Image, packet); - - data -} diff --git a/metadata/src/date.rs b/metadata/src/date.rs new file mode 100644 index 00000000..c402c05f --- /dev/null +++ b/metadata/src/date.rs @@ -0,0 +1,70 @@ +use std::convert::TryFrom; +use std::fmt::Debug; +use std::ops::Deref; + +use chrono::{DateTime, Utc}; +use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; + +use crate::error::MetadataError; + +use librespot_protocol as protocol; + +use protocol::metadata::Date as DateMessage; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Date(pub DateTime); + +impl Deref for Date { + type Target = DateTime; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Date { + pub fn as_timestamp(&self) -> i64 { + self.0.timestamp() + } + + pub fn from_timestamp(timestamp: i64) -> Result { + if let Some(date_time) = NaiveDateTime::from_timestamp_opt(timestamp, 0) { + Ok(Self::from_utc(date_time)) + } else { + Err(MetadataError::InvalidTimestamp) + } + } + + pub fn as_utc(&self) -> DateTime { + self.0 + } + + pub fn from_utc(date_time: NaiveDateTime) -> Self { + Self(DateTime::::from_utc(date_time, Utc)) + } +} + +impl From<&DateMessage> for Date { + fn from(date: &DateMessage) -> Self { + let naive_date = NaiveDate::from_ymd( + date.get_year() as i32, + date.get_month() as u32, + date.get_day() as u32, + ); + let naive_time = NaiveTime::from_hms(date.get_hour() as u32, date.get_minute() as u32, 0); + let naive_datetime = NaiveDateTime::new(naive_date, naive_time); + Self(DateTime::::from_utc(naive_datetime, Utc)) + } +} + +impl From> for Date { + fn from(date: DateTime) -> Self { + Self(date) + } +} + +impl TryFrom for Date { + type Error = MetadataError; + fn try_from(timestamp: i64) -> Result { + Self::from_timestamp(timestamp) + } +} diff --git a/metadata/src/episode.rs b/metadata/src/episode.rs new file mode 100644 index 00000000..35d6ed8f --- /dev/null +++ b/metadata/src/episode.rs @@ -0,0 +1,132 @@ +use std::convert::{TryFrom, TryInto}; +use std::fmt::Debug; +use std::ops::Deref; + +use crate::{ + audio::{ + file::AudioFiles, + item::{AudioItem, AudioItemResult, InnerAudioItem}, + }, + availability::Availabilities, + date::Date, + error::{MetadataError, RequestError}, + image::Images, + request::RequestResult, + restriction::Restrictions, + util::try_from_repeated_message, + video::VideoFiles, + Metadata, +}; + +use librespot_core::session::Session; +use librespot_core::spotify_id::SpotifyId; +use librespot_protocol as protocol; + +pub use protocol::metadata::Episode_EpisodeType as EpisodeType; + +#[derive(Debug, Clone)] +pub struct Episode { + pub id: SpotifyId, + pub name: String, + pub duration: i32, + pub audio: AudioFiles, + pub description: String, + pub number: i32, + pub publish_time: Date, + pub covers: Images, + pub language: String, + pub is_explicit: bool, + pub show: SpotifyId, + pub videos: VideoFiles, + pub video_previews: VideoFiles, + pub audio_previews: AudioFiles, + pub restrictions: Restrictions, + pub freeze_frames: Images, + pub keywords: Vec, + pub allow_background_playback: bool, + pub availability: Availabilities, + pub external_url: String, + pub episode_type: EpisodeType, + pub has_music_and_talk: bool, +} + +#[derive(Debug, Clone)] +pub struct Episodes(pub Vec); + +impl Deref for Episodes { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[async_trait] +impl InnerAudioItem for Episode { + async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult { + let episode = Self::get(session, id).await?; + let availability = Self::available_in_country( + &episode.availability, + &episode.restrictions, + &session.country(), + ); + + Ok(AudioItem { + id, + spotify_uri: id.to_uri(), + files: episode.audio, + name: episode.name, + duration: episode.duration, + availability, + alternatives: None, + }) + } +} + +#[async_trait] +impl Metadata for Episode { + type Message = protocol::metadata::Episode; + + async fn request(session: &Session, episode_id: SpotifyId) -> RequestResult { + session + .spclient() + .get_episode_metadata(episode_id) + .await + .map_err(RequestError::Http) + } + + fn parse(msg: &Self::Message, _: SpotifyId) -> Result { + Self::try_from(msg) + } +} + +impl TryFrom<&::Message> for Episode { + type Error = MetadataError; + fn try_from(episode: &::Message) -> Result { + Ok(Self { + id: episode.try_into()?, + name: episode.get_name().to_owned(), + duration: episode.get_duration().to_owned(), + audio: episode.get_audio().into(), + description: episode.get_description().to_owned(), + number: episode.get_number(), + publish_time: episode.get_publish_time().into(), + covers: episode.get_cover_image().get_image().into(), + language: episode.get_language().to_owned(), + is_explicit: episode.get_explicit().to_owned(), + show: episode.get_show().try_into()?, + videos: episode.get_video().into(), + video_previews: episode.get_video_preview().into(), + audio_previews: episode.get_audio_preview().into(), + restrictions: episode.get_restriction().into(), + freeze_frames: episode.get_freeze_frame().get_image().into(), + keywords: episode.get_keyword().to_vec(), + allow_background_playback: episode.get_allow_background_playback(), + availability: episode.get_availability().into(), + external_url: episode.get_external_url().to_owned(), + episode_type: episode.get_field_type(), + has_music_and_talk: episode.get_music_and_talk(), + }) + } +} + +try_from_repeated_message!(::Message, Episodes); diff --git a/metadata/src/error.rs b/metadata/src/error.rs new file mode 100644 index 00000000..2aeaef1e --- /dev/null +++ b/metadata/src/error.rs @@ -0,0 +1,34 @@ +use std::fmt::Debug; +use thiserror::Error; + +use protobuf::ProtobufError; + +use librespot_core::mercury::MercuryError; +use librespot_core::spclient::SpClientError; +use librespot_core::spotify_id::SpotifyIdError; + +#[derive(Debug, Error)] +pub enum RequestError { + #[error("could not get metadata over HTTP: {0}")] + Http(#[from] SpClientError), + #[error("could not get metadata over Mercury: {0}")] + Mercury(#[from] MercuryError), + #[error("response was empty")] + Empty, +} + +#[derive(Debug, Error)] +pub enum MetadataError { + #[error("{0}")] + InvalidSpotifyId(#[from] SpotifyIdError), + #[error("item has invalid date")] + InvalidTimestamp, + #[error("audio item is non-playable")] + NonPlayable, + #[error("could not parse protobuf: {0}")] + Protobuf(#[from] ProtobufError), + #[error("error executing request: {0}")] + Request(#[from] RequestError), + #[error("could not parse repeated fields")] + InvalidRepeated, +} diff --git a/metadata/src/external_id.rs b/metadata/src/external_id.rs new file mode 100644 index 00000000..31755e72 --- /dev/null +++ b/metadata/src/external_id.rs @@ -0,0 +1,35 @@ +use std::fmt::Debug; +use std::ops::Deref; + +use crate::util::from_repeated_message; + +use librespot_protocol as protocol; + +use protocol::metadata::ExternalId as ExternalIdMessage; + +#[derive(Debug, Clone)] +pub struct ExternalId { + pub external_type: String, + pub id: String, +} + +#[derive(Debug, Clone)] +pub struct ExternalIds(pub Vec); + +impl Deref for ExternalIds { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From<&ExternalIdMessage> for ExternalId { + fn from(external_id: &ExternalIdMessage) -> Self { + Self { + external_type: external_id.get_field_type().to_owned(), + id: external_id.get_id().to_owned(), + } + } +} + +from_repeated_message!(ExternalIdMessage, ExternalIds); diff --git a/metadata/src/image.rs b/metadata/src/image.rs new file mode 100644 index 00000000..b6653d09 --- /dev/null +++ b/metadata/src/image.rs @@ -0,0 +1,103 @@ +use std::convert::{TryFrom, TryInto}; +use std::fmt::Debug; +use std::ops::Deref; + +use crate::{ + error::MetadataError, + util::{from_repeated_message, try_from_repeated_message}, +}; + +use librespot_core::spotify_id::{FileId, SpotifyId}; +use librespot_protocol as protocol; + +use protocol::metadata::Image as ImageMessage; +use protocol::playlist4_external::PictureSize as PictureSizeMessage; +use protocol::playlist_annotate3::TranscodedPicture as TranscodedPictureMessage; + +pub use protocol::metadata::Image_Size as ImageSize; + +#[derive(Debug, Clone)] +pub struct Image { + pub id: FileId, + pub size: ImageSize, + pub width: i32, + pub height: i32, +} + +#[derive(Debug, Clone)] +pub struct Images(pub Vec); + +impl Deref for Images { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct PictureSize { + pub target_name: String, + pub url: String, +} + +#[derive(Debug, Clone)] +pub struct PictureSizes(pub Vec); + +impl Deref for PictureSizes { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct TranscodedPicture { + pub target_name: String, + pub uri: SpotifyId, +} + +#[derive(Debug, Clone)] +pub struct TranscodedPictures(pub Vec); + +impl Deref for TranscodedPictures { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From<&ImageMessage> for Image { + fn from(image: &ImageMessage) -> Self { + Self { + id: image.into(), + size: image.get_size(), + width: image.get_width(), + height: image.get_height(), + } + } +} + +from_repeated_message!(ImageMessage, Images); + +impl From<&PictureSizeMessage> for PictureSize { + fn from(size: &PictureSizeMessage) -> Self { + Self { + target_name: size.get_target_name().to_owned(), + url: size.get_url().to_owned(), + } + } +} + +from_repeated_message!(PictureSizeMessage, PictureSizes); + +impl TryFrom<&TranscodedPictureMessage> for TranscodedPicture { + type Error = MetadataError; + fn try_from(picture: &TranscodedPictureMessage) -> Result { + Ok(Self { + target_name: picture.get_target_name().to_owned(), + uri: picture.try_into()?, + }) + } +} + +try_from_repeated_message!(TranscodedPictureMessage, TranscodedPictures); diff --git a/metadata/src/lib.rs b/metadata/src/lib.rs index 05ab028d..f1090b0f 100644 --- a/metadata/src/lib.rs +++ b/metadata/src/lib.rs @@ -1,643 +1,51 @@ -#![allow(clippy::unused_io_amount)] - #[macro_use] extern crate log; #[macro_use] extern crate async_trait; -pub mod cover; +use protobuf::Message; -use std::collections::HashMap; - -use librespot_core::mercury::MercuryError; use librespot_core::session::Session; -use librespot_core::spclient::SpClientError; -use librespot_core::spotify_id::{FileId, SpotifyAudioType, SpotifyId}; -use librespot_protocol as protocol; -use protobuf::{Message, ProtobufError}; +use librespot_core::spotify_id::SpotifyId; -use thiserror::Error; +pub mod album; +pub mod artist; +pub mod audio; +pub mod availability; +pub mod content_rating; +pub mod copyright; +pub mod date; +pub mod episode; +pub mod error; +pub mod external_id; +pub mod image; +pub mod playlist; +mod request; +pub mod restriction; +pub mod sale_period; +pub mod show; +pub mod track; +mod util; +pub mod video; -pub use crate::protocol::metadata::AudioFile_Format as FileFormat; - -fn countrylist_contains(list: &str, country: &str) -> bool { - list.chunks(2).any(|cc| cc == country) -} - -fn parse_restrictions<'s, I>(restrictions: I, country: &str, catalogue: &str) -> bool -where - I: IntoIterator, -{ - let mut forbidden = "".to_string(); - let mut has_forbidden = false; - - let mut allowed = "".to_string(); - let mut has_allowed = false; - - let rs = restrictions - .into_iter() - .filter(|r| r.get_catalogue_str().contains(&catalogue.to_owned())); - - for r in rs { - if r.has_countries_forbidden() { - forbidden.push_str(r.get_countries_forbidden()); - has_forbidden = true; - } - - if r.has_countries_allowed() { - allowed.push_str(r.get_countries_allowed()); - has_allowed = true; - } - } - - !(has_forbidden && countrylist_contains(forbidden.as_str(), country) - || has_allowed && !countrylist_contains(allowed.as_str(), country)) -} - -// A wrapper with fields the player needs -#[derive(Debug, Clone)] -pub struct AudioItem { - pub id: SpotifyId, - pub uri: String, - pub files: HashMap, - pub name: String, - pub duration: i32, - pub available: bool, - pub alternatives: Option>, -} - -impl AudioItem { - pub async fn get_audio_item(session: &Session, id: SpotifyId) -> Result { - match id.audio_type { - SpotifyAudioType::Track => Track::get_audio_item(session, id).await, - SpotifyAudioType::Podcast => Episode::get_audio_item(session, id).await, - SpotifyAudioType::NonPlayable => Err(MetadataError::NonPlayable), - } - } -} - -pub type AudioItemResult = Result; - -#[async_trait] -trait AudioFiles { - async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult; -} - -#[async_trait] -impl AudioFiles for Track { - async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult { - let item = Self::get(session, id).await?; - let alternatives = { - if item.alternatives.is_empty() { - None - } else { - Some(item.alternatives) - } - }; - - Ok(AudioItem { - id, - uri: format!("spotify:track:{}", id.to_base62()), - files: item.files, - name: item.name, - duration: item.duration, - available: item.available, - alternatives, - }) - } -} - -#[async_trait] -impl AudioFiles for Episode { - async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult { - let item = Self::get(session, id).await?; - - Ok(AudioItem { - id, - uri: format!("spotify:episode:{}", id.to_base62()), - files: item.files, - name: item.name, - duration: item.duration, - available: item.available, - alternatives: None, - }) - } -} - -#[derive(Debug, Error)] -pub enum MetadataError { - #[error("could not get metadata over HTTP: {0}")] - Http(#[from] SpClientError), - #[error("could not get metadata over Mercury: {0}")] - Mercury(#[from] MercuryError), - #[error("could not parse metadata: {0}")] - Parsing(#[from] ProtobufError), - #[error("response was empty")] - Empty, - #[error("audio item is non-playable")] - NonPlayable, -} - -pub type MetadataResult = Result; +use error::MetadataError; +use request::RequestResult; #[async_trait] pub trait Metadata: Send + Sized + 'static { type Message: protobuf::Message; - async fn request(session: &Session, id: SpotifyId) -> MetadataResult; - fn parse(msg: &Self::Message, session: &Session) -> Self; + // Request a protobuf + async fn request(session: &Session, id: SpotifyId) -> RequestResult; + // Request a metadata struct async fn get(session: &Session, id: SpotifyId) -> Result { let response = Self::request(session, id).await?; let msg = Self::Message::parse_from_bytes(&response)?; - Ok(Self::parse(&msg, session)) - } -} - -// TODO: expose more fields available in the protobufs - -#[derive(Debug, Clone)] -pub struct Track { - pub id: SpotifyId, - pub name: String, - pub duration: i32, - pub album: SpotifyId, - pub artists: Vec, - pub files: HashMap, - pub alternatives: Vec, - pub available: bool, -} - -#[derive(Debug, Clone)] -pub struct Album { - pub id: SpotifyId, - pub name: String, - pub artists: Vec, - pub tracks: Vec, - pub covers: Vec, -} - -#[derive(Debug, Clone)] -pub struct Episode { - pub id: SpotifyId, - pub name: String, - pub external_url: String, - pub duration: i32, - pub language: String, - pub show: SpotifyId, - pub files: HashMap, - pub covers: Vec, - pub available: bool, - pub explicit: bool, -} - -#[derive(Debug, Clone)] -pub struct Show { - pub id: SpotifyId, - pub name: String, - pub publisher: String, - pub episodes: Vec, - pub covers: Vec, -} - -#[derive(Debug, Clone)] -pub struct TranscodedPicture { - pub target_name: String, - pub uri: String, -} - -#[derive(Debug, Clone)] -pub struct PlaylistAnnotation { - pub description: String, - pub picture: String, - pub transcoded_pictures: Vec, - pub abuse_reporting: bool, - pub taken_down: bool, -} - -#[derive(Debug, Clone)] -pub struct Playlist { - pub revision: Vec, - pub user: String, - pub name: String, - pub tracks: Vec, -} - -#[derive(Debug, Clone)] -pub struct Artist { - pub id: SpotifyId, - pub name: String, - pub top_tracks: Vec, -} - -#[async_trait] -impl Metadata for Track { - type Message = protocol::metadata::Track; - - async fn request(session: &Session, track_id: SpotifyId) -> MetadataResult { - session - .spclient() - .get_track_metadata(track_id) - .await - .map_err(MetadataError::Http) - } - - fn parse(msg: &Self::Message, session: &Session) -> Self { - debug!("MESSAGE: {:?}", msg); - let country = session.country(); - - let artists = msg - .get_artist() - .iter() - .filter(|artist| artist.has_gid()) - .map(|artist| SpotifyId::from_raw(artist.get_gid()).unwrap()) - .collect::>(); - - let files = msg - .get_file() - .iter() - .filter(|file| file.has_file_id()) - .map(|file| { - let mut dst = [0u8; 20]; - dst.clone_from_slice(file.get_file_id()); - (file.get_format(), FileId(dst)) - }) - .collect(); - - Self { - id: SpotifyId::from_raw(msg.get_gid()).unwrap(), - name: msg.get_name().to_owned(), - duration: msg.get_duration(), - album: SpotifyId::from_raw(msg.get_album().get_gid()).unwrap(), - artists, - files, - alternatives: msg - .get_alternative() - .iter() - .map(|alt| SpotifyId::from_raw(alt.get_gid()).unwrap()) - .collect(), - available: parse_restrictions(msg.get_restriction(), &country, "premium"), - } - } -} - -#[async_trait] -impl Metadata for Album { - type Message = protocol::metadata::Album; - - async fn request(session: &Session, album_id: SpotifyId) -> MetadataResult { - session - .spclient() - .get_album_metadata(album_id) - .await - .map_err(MetadataError::Http) - } - - fn parse(msg: &Self::Message, _: &Session) -> Self { - let artists = msg - .get_artist() - .iter() - .filter(|artist| artist.has_gid()) - .map(|artist| SpotifyId::from_raw(artist.get_gid()).unwrap()) - .collect::>(); - - let tracks = msg - .get_disc() - .iter() - .flat_map(|disc| disc.get_track()) - .filter(|track| track.has_gid()) - .map(|track| SpotifyId::from_raw(track.get_gid()).unwrap()) - .collect::>(); - - let covers = msg - .get_cover_group() - .get_image() - .iter() - .filter(|image| image.has_file_id()) - .map(|image| { - let mut dst = [0u8; 20]; - dst.clone_from_slice(image.get_file_id()); - FileId(dst) - }) - .collect::>(); - - Self { - id: SpotifyId::from_raw(msg.get_gid()).unwrap(), - name: msg.get_name().to_owned(), - artists, - tracks, - covers, - } - } -} - -#[async_trait] -impl Metadata for PlaylistAnnotation { - type Message = protocol::playlist_annotate3::PlaylistAnnotation; - - async fn request(session: &Session, playlist_id: SpotifyId) -> MetadataResult { - let current_user = session.username(); - Self::request_for_user(session, current_user, playlist_id).await - } - - fn parse(msg: &Self::Message, _: &Session) -> Self { - let transcoded_pictures = msg - .get_transcoded_picture() - .iter() - .map(|picture| TranscodedPicture { - target_name: picture.get_target_name().to_string(), - uri: picture.get_uri().to_string(), - }) - .collect::>(); - - let taken_down = !matches!( - msg.get_abuse_report_state(), - protocol::playlist_annotate3::AbuseReportState::OK - ); - - Self { - description: msg.get_description().to_string(), - picture: msg.get_picture().to_string(), - transcoded_pictures, - abuse_reporting: msg.get_is_abuse_reporting_enabled(), - taken_down, - } - } -} - -impl PlaylistAnnotation { - async fn request_for_user( - session: &Session, - username: String, - playlist_id: SpotifyId, - ) -> MetadataResult { - let uri = format!( - "hm://playlist-annotate/v1/annotation/user/{}/playlist/{}", - username, - playlist_id.to_base62() - ); - let response = session.mercury().get(uri).await?; - match response.payload.first() { - Some(data) => Ok(data.to_vec().into()), - None => Err(MetadataError::Empty), - } - } - - #[allow(dead_code)] - async fn get_for_user( - session: &Session, - username: String, - playlist_id: SpotifyId, - ) -> Result { - let response = Self::request_for_user(session, username, playlist_id).await?; - let msg = ::Message::parse_from_bytes(&response)?; - Ok(Self::parse(&msg, session)) - } -} - -#[async_trait] -impl Metadata for Playlist { - type Message = protocol::playlist4_external::SelectedListContent; - - async fn request(session: &Session, playlist_id: SpotifyId) -> MetadataResult { - let uri = format!("hm://playlist/v2/playlist/{}", playlist_id.to_base62()); - let response = session.mercury().get(uri).await?; - match response.payload.first() { - Some(data) => Ok(data.to_vec().into()), - None => Err(MetadataError::Empty), - } - } - - fn parse(msg: &Self::Message, _: &Session) -> Self { - let tracks = msg - .get_contents() - .get_items() - .iter() - .map(|item| { - let uri_split = item.get_uri().split(':'); - let uri_parts: Vec<&str> = uri_split.collect(); - SpotifyId::from_base62(uri_parts[2]).unwrap() - }) - .collect::>(); - - if tracks.len() != msg.get_length() as usize { - warn!( - "Got {} tracks, but the playlist should contain {} tracks.", - tracks.len(), - msg.get_length() - ); - } - - Self { - revision: msg.get_revision().to_vec(), - name: msg.get_attributes().get_name().to_owned(), - tracks, - user: msg.get_owner_username().to_string(), - } - } -} - -impl Playlist { - async fn request_for_user( - session: &Session, - username: String, - playlist_id: SpotifyId, - ) -> MetadataResult { - let uri = format!( - "hm://playlist/user/{}/playlist/{}", - username, - playlist_id.to_base62() - ); - let response = session.mercury().get(uri).await?; - match response.payload.first() { - Some(data) => Ok(data.to_vec().into()), - None => Err(MetadataError::Empty), - } - } - - async fn request_root_for_user(session: &Session, username: String) -> MetadataResult { - let uri = format!("hm://playlist/user/{}/rootlist", username); - let response = session.mercury().get(uri).await?; - match response.payload.first() { - Some(data) => Ok(data.to_vec().into()), - None => Err(MetadataError::Empty), - } - } - #[allow(dead_code)] - async fn get_for_user( - session: &Session, - username: String, - playlist_id: SpotifyId, - ) -> Result { - let response = Self::request_for_user(session, username, playlist_id).await?; - let msg = ::Message::parse_from_bytes(&response)?; - Ok(Self::parse(&msg, session)) - } - - #[allow(dead_code)] - async fn get_root_for_user(session: &Session, username: String) -> Result { - let response = Self::request_root_for_user(session, username).await?; - let msg = ::Message::parse_from_bytes(&response)?; - Ok(Self::parse(&msg, session)) - } -} - -#[async_trait] -impl Metadata for Artist { - type Message = protocol::metadata::Artist; - - async fn request(session: &Session, artist_id: SpotifyId) -> MetadataResult { - session - .spclient() - .get_artist_metadata(artist_id) - .await - .map_err(MetadataError::Http) - } - - fn parse(msg: &Self::Message, session: &Session) -> Self { - let country = session.country(); - - let top_tracks: Vec = match msg - .get_top_track() - .iter() - .find(|tt| !tt.has_country() || countrylist_contains(tt.get_country(), &country)) - { - Some(tracks) => tracks - .get_track() - .iter() - .filter(|track| track.has_gid()) - .map(|track| SpotifyId::from_raw(track.get_gid()).unwrap()) - .collect::>(), - None => Vec::new(), - }; - - Self { - id: SpotifyId::from_raw(msg.get_gid()).unwrap(), - name: msg.get_name().to_owned(), - top_tracks, - } - } -} - -// Podcast -#[async_trait] -impl Metadata for Episode { - type Message = protocol::metadata::Episode; - - async fn request(session: &Session, episode_id: SpotifyId) -> MetadataResult { - session - .spclient() - .get_album_metadata(episode_id) - .await - .map_err(MetadataError::Http) - } - - fn parse(msg: &Self::Message, session: &Session) -> Self { - let country = session.country(); - - let files = msg - .get_audio() - .iter() - .filter(|file| file.has_file_id()) - .map(|file| { - let mut dst = [0u8; 20]; - dst.clone_from_slice(file.get_file_id()); - (file.get_format(), FileId(dst)) - }) - .collect(); - - let covers = msg - .get_cover_image() - .get_image() - .iter() - .filter(|image| image.has_file_id()) - .map(|image| { - let mut dst = [0u8; 20]; - dst.clone_from_slice(image.get_file_id()); - FileId(dst) - }) - .collect::>(); - - Self { - id: SpotifyId::from_raw(msg.get_gid()).unwrap(), - name: msg.get_name().to_owned(), - external_url: msg.get_external_url().to_owned(), - duration: msg.get_duration().to_owned(), - language: msg.get_language().to_owned(), - show: SpotifyId::from_raw(msg.get_show().get_gid()).unwrap(), - covers, - files, - available: parse_restrictions(msg.get_restriction(), &country, "premium"), - explicit: msg.get_explicit().to_owned(), - } - } -} - -#[async_trait] -impl Metadata for Show { - type Message = protocol::metadata::Show; - - async fn request(session: &Session, show_id: SpotifyId) -> MetadataResult { - session - .spclient() - .get_show_metadata(show_id) - .await - .map_err(MetadataError::Http) - } - - fn parse(msg: &Self::Message, _: &Session) -> Self { - let episodes = msg - .get_episode() - .iter() - .filter(|episode| episode.has_gid()) - .map(|episode| SpotifyId::from_raw(episode.get_gid()).unwrap()) - .collect::>(); - - let covers = msg - .get_cover_image() - .get_image() - .iter() - .filter(|image| image.has_file_id()) - .map(|image| { - let mut dst = [0u8; 20]; - dst.clone_from_slice(image.get_file_id()); - FileId(dst) - }) - .collect::>(); - - Self { - id: SpotifyId::from_raw(msg.get_gid()).unwrap(), - name: msg.get_name().to_owned(), - publisher: msg.get_publisher().to_owned(), - episodes, - covers, - } - } -} - -struct StrChunks<'s>(&'s str, usize); - -trait StrChunksExt { - fn chunks(&self, size: usize) -> StrChunks; -} - -impl StrChunksExt for str { - fn chunks(&self, size: usize) -> StrChunks { - StrChunks(self, size) - } -} - -impl<'s> Iterator for StrChunks<'s> { - type Item = &'s str; - fn next(&mut self) -> Option<&'s str> { - let &mut StrChunks(data, size) = self; - if data.is_empty() { - None - } else { - let ret = Some(&data[..size]); - self.0 = &data[size..]; - ret - } + trace!("Received metadata: {:?}", msg); + Self::parse(&msg, id) } + + fn parse(msg: &Self::Message, _: SpotifyId) -> Result; } diff --git a/metadata/src/playlist/annotation.rs b/metadata/src/playlist/annotation.rs new file mode 100644 index 00000000..0116d997 --- /dev/null +++ b/metadata/src/playlist/annotation.rs @@ -0,0 +1,89 @@ +use std::convert::{TryFrom, TryInto}; +use std::fmt::Debug; + +use protobuf::Message; + +use crate::{ + error::MetadataError, + image::TranscodedPictures, + request::{MercuryRequest, RequestResult}, + Metadata, +}; + +use librespot_core::session::Session; +use librespot_core::spotify_id::SpotifyId; +use librespot_protocol as protocol; + +pub use protocol::playlist_annotate3::AbuseReportState; + +#[derive(Debug, Clone)] +pub struct PlaylistAnnotation { + pub description: String, + pub picture: String, + pub transcoded_pictures: TranscodedPictures, + pub has_abuse_reporting: bool, + pub abuse_report_state: AbuseReportState, +} + +#[async_trait] +impl Metadata for PlaylistAnnotation { + type Message = protocol::playlist_annotate3::PlaylistAnnotation; + + async fn request(session: &Session, playlist_id: SpotifyId) -> RequestResult { + let current_user = session.username(); + Self::request_for_user(session, ¤t_user, playlist_id).await + } + + fn parse(msg: &Self::Message, _: SpotifyId) -> Result { + Ok(Self { + description: msg.get_description().to_owned(), + picture: msg.get_picture().to_owned(), // TODO: is this a URL or Spotify URI? + transcoded_pictures: msg.get_transcoded_picture().try_into()?, + has_abuse_reporting: msg.get_is_abuse_reporting_enabled(), + abuse_report_state: msg.get_abuse_report_state(), + }) + } +} + +impl PlaylistAnnotation { + async fn request_for_user( + session: &Session, + username: &str, + playlist_id: SpotifyId, + ) -> RequestResult { + let uri = format!( + "hm://playlist-annotate/v1/annotation/user/{}/playlist/{}", + username, + playlist_id.to_base62() + ); + ::request(session, &uri).await + } + + #[allow(dead_code)] + async fn get_for_user( + session: &Session, + username: &str, + playlist_id: SpotifyId, + ) -> Result { + let response = Self::request_for_user(session, username, playlist_id).await?; + let msg = ::Message::parse_from_bytes(&response)?; + Self::parse(&msg, playlist_id) + } +} + +impl MercuryRequest for PlaylistAnnotation {} + +impl TryFrom<&::Message> for PlaylistAnnotation { + type Error = MetadataError; + fn try_from( + annotation: &::Message, + ) -> Result { + Ok(Self { + description: annotation.get_description().to_owned(), + picture: annotation.get_picture().to_owned(), + transcoded_pictures: annotation.get_transcoded_picture().try_into()?, + has_abuse_reporting: annotation.get_is_abuse_reporting_enabled(), + abuse_report_state: annotation.get_abuse_report_state(), + }) + } +} diff --git a/metadata/src/playlist/attribute.rs b/metadata/src/playlist/attribute.rs new file mode 100644 index 00000000..f00a2b13 --- /dev/null +++ b/metadata/src/playlist/attribute.rs @@ -0,0 +1,195 @@ +use std::collections::HashMap; +use std::convert::{TryFrom, TryInto}; +use std::fmt::Debug; +use std::ops::Deref; + +use crate::{date::Date, error::MetadataError, image::PictureSizes, util::from_repeated_enum}; + +use librespot_core::spotify_id::SpotifyId; +use librespot_protocol as protocol; + +use protocol::playlist4_external::FormatListAttribute as PlaylistFormatAttributeMessage; +use protocol::playlist4_external::ItemAttributes as PlaylistItemAttributesMessage; +use protocol::playlist4_external::ItemAttributesPartialState as PlaylistPartialItemAttributesMessage; +use protocol::playlist4_external::ListAttributes as PlaylistAttributesMessage; +use protocol::playlist4_external::ListAttributesPartialState as PlaylistPartialAttributesMessage; +use protocol::playlist4_external::UpdateItemAttributes as PlaylistUpdateItemAttributesMessage; +use protocol::playlist4_external::UpdateListAttributes as PlaylistUpdateAttributesMessage; + +pub use protocol::playlist4_external::ItemAttributeKind as PlaylistItemAttributeKind; +pub use protocol::playlist4_external::ListAttributeKind as PlaylistAttributeKind; + +#[derive(Debug, Clone)] +pub struct PlaylistAttributes { + pub name: String, + pub description: String, + pub picture: SpotifyId, + pub is_collaborative: bool, + pub pl3_version: String, + pub is_deleted_by_owner: bool, + pub client_id: String, + pub format: String, + pub format_attributes: PlaylistFormatAttribute, + pub picture_sizes: PictureSizes, +} + +#[derive(Debug, Clone)] +pub struct PlaylistAttributeKinds(pub Vec); + +impl Deref for PlaylistAttributeKinds { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +from_repeated_enum!(PlaylistAttributeKind, PlaylistAttributeKinds); + +#[derive(Debug, Clone)] +pub struct PlaylistFormatAttribute(pub HashMap); + +impl Deref for PlaylistFormatAttribute { + type Target = HashMap; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct PlaylistItemAttributes { + pub added_by: String, + pub timestamp: Date, + pub seen_at: Date, + pub is_public: bool, + pub format_attributes: PlaylistFormatAttribute, + pub item_id: SpotifyId, +} + +#[derive(Debug, Clone)] +pub struct PlaylistItemAttributeKinds(pub Vec); + +impl Deref for PlaylistItemAttributeKinds { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +from_repeated_enum!(PlaylistItemAttributeKind, PlaylistItemAttributeKinds); + +#[derive(Debug, Clone)] +pub struct PlaylistPartialAttributes { + #[allow(dead_code)] + values: PlaylistAttributes, + #[allow(dead_code)] + no_value: PlaylistAttributeKinds, +} + +#[derive(Debug, Clone)] +pub struct PlaylistPartialItemAttributes { + #[allow(dead_code)] + values: PlaylistItemAttributes, + #[allow(dead_code)] + no_value: PlaylistItemAttributeKinds, +} + +#[derive(Debug, Clone)] +pub struct PlaylistUpdateAttributes { + pub new_attributes: PlaylistPartialAttributes, + pub old_attributes: PlaylistPartialAttributes, +} + +#[derive(Debug, Clone)] +pub struct PlaylistUpdateItemAttributes { + pub index: i32, + pub new_attributes: PlaylistPartialItemAttributes, + pub old_attributes: PlaylistPartialItemAttributes, +} + +impl TryFrom<&PlaylistAttributesMessage> for PlaylistAttributes { + type Error = MetadataError; + fn try_from(attributes: &PlaylistAttributesMessage) -> Result { + Ok(Self { + name: attributes.get_name().to_owned(), + description: attributes.get_description().to_owned(), + picture: attributes.get_picture().try_into()?, + is_collaborative: attributes.get_collaborative(), + pl3_version: attributes.get_pl3_version().to_owned(), + is_deleted_by_owner: attributes.get_deleted_by_owner(), + client_id: attributes.get_client_id().to_owned(), + format: attributes.get_format().to_owned(), + format_attributes: attributes.get_format_attributes().into(), + picture_sizes: attributes.get_picture_size().into(), + }) + } +} + +impl From<&[PlaylistFormatAttributeMessage]> for PlaylistFormatAttribute { + fn from(attributes: &[PlaylistFormatAttributeMessage]) -> Self { + let format_attributes = attributes + .iter() + .map(|attribute| { + ( + attribute.get_key().to_owned(), + attribute.get_value().to_owned(), + ) + }) + .collect(); + + PlaylistFormatAttribute(format_attributes) + } +} + +impl TryFrom<&PlaylistItemAttributesMessage> for PlaylistItemAttributes { + type Error = MetadataError; + fn try_from(attributes: &PlaylistItemAttributesMessage) -> Result { + Ok(Self { + added_by: attributes.get_added_by().to_owned(), + timestamp: attributes.get_timestamp().try_into()?, + seen_at: attributes.get_seen_at().try_into()?, + is_public: attributes.get_public(), + format_attributes: attributes.get_format_attributes().into(), + item_id: attributes.get_item_id().try_into()?, + }) + } +} +impl TryFrom<&PlaylistPartialAttributesMessage> for PlaylistPartialAttributes { + type Error = MetadataError; + fn try_from(attributes: &PlaylistPartialAttributesMessage) -> Result { + Ok(Self { + values: attributes.get_values().try_into()?, + no_value: attributes.get_no_value().into(), + }) + } +} + +impl TryFrom<&PlaylistPartialItemAttributesMessage> for PlaylistPartialItemAttributes { + type Error = MetadataError; + fn try_from(attributes: &PlaylistPartialItemAttributesMessage) -> Result { + Ok(Self { + values: attributes.get_values().try_into()?, + no_value: attributes.get_no_value().into(), + }) + } +} + +impl TryFrom<&PlaylistUpdateAttributesMessage> for PlaylistUpdateAttributes { + type Error = MetadataError; + fn try_from(update: &PlaylistUpdateAttributesMessage) -> Result { + Ok(Self { + new_attributes: update.get_new_attributes().try_into()?, + old_attributes: update.get_old_attributes().try_into()?, + }) + } +} + +impl TryFrom<&PlaylistUpdateItemAttributesMessage> for PlaylistUpdateItemAttributes { + type Error = MetadataError; + fn try_from(update: &PlaylistUpdateItemAttributesMessage) -> Result { + Ok(Self { + index: update.get_index(), + new_attributes: update.get_new_attributes().try_into()?, + old_attributes: update.get_old_attributes().try_into()?, + }) + } +} diff --git a/metadata/src/playlist/diff.rs b/metadata/src/playlist/diff.rs new file mode 100644 index 00000000..080d72a1 --- /dev/null +++ b/metadata/src/playlist/diff.rs @@ -0,0 +1,29 @@ +use std::convert::{TryFrom, TryInto}; +use std::fmt::Debug; + +use crate::error::MetadataError; + +use super::operation::PlaylistOperations; + +use librespot_core::spotify_id::SpotifyId; +use librespot_protocol as protocol; + +use protocol::playlist4_external::Diff as DiffMessage; + +#[derive(Debug, Clone)] +pub struct PlaylistDiff { + pub from_revision: SpotifyId, + pub operations: PlaylistOperations, + pub to_revision: SpotifyId, +} + +impl TryFrom<&DiffMessage> for PlaylistDiff { + type Error = MetadataError; + fn try_from(diff: &DiffMessage) -> Result { + Ok(Self { + from_revision: diff.get_from_revision().try_into()?, + operations: diff.get_ops().try_into()?, + to_revision: diff.get_to_revision().try_into()?, + }) + } +} diff --git a/metadata/src/playlist/item.rs b/metadata/src/playlist/item.rs new file mode 100644 index 00000000..975a9840 --- /dev/null +++ b/metadata/src/playlist/item.rs @@ -0,0 +1,96 @@ +use std::convert::{TryFrom, TryInto}; +use std::fmt::Debug; +use std::ops::Deref; + +use crate::{date::Date, error::MetadataError, util::try_from_repeated_message}; + +use super::attribute::{PlaylistAttributes, PlaylistItemAttributes}; + +use librespot_core::spotify_id::SpotifyId; +use librespot_protocol as protocol; + +use protocol::playlist4_external::Item as PlaylistItemMessage; +use protocol::playlist4_external::ListItems as PlaylistItemsMessage; +use protocol::playlist4_external::MetaItem as PlaylistMetaItemMessage; + +#[derive(Debug, Clone)] +pub struct PlaylistItem { + pub id: SpotifyId, + pub attributes: PlaylistItemAttributes, +} + +#[derive(Debug, Clone)] +pub struct PlaylistItems(pub Vec); + +impl Deref for PlaylistItems { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct PlaylistItemList { + pub position: i32, + pub is_truncated: bool, + pub items: PlaylistItems, + pub meta_items: PlaylistMetaItems, +} + +#[derive(Debug, Clone)] +pub struct PlaylistMetaItem { + pub revision: SpotifyId, + pub attributes: PlaylistAttributes, + pub length: i32, + pub timestamp: Date, + pub owner_username: String, +} + +#[derive(Debug, Clone)] +pub struct PlaylistMetaItems(pub Vec); + +impl Deref for PlaylistMetaItems { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom<&PlaylistItemMessage> for PlaylistItem { + type Error = MetadataError; + fn try_from(item: &PlaylistItemMessage) -> Result { + Ok(Self { + id: item.try_into()?, + attributes: item.get_attributes().try_into()?, + }) + } +} + +try_from_repeated_message!(PlaylistItemMessage, PlaylistItems); + +impl TryFrom<&PlaylistItemsMessage> for PlaylistItemList { + type Error = MetadataError; + fn try_from(list_items: &PlaylistItemsMessage) -> Result { + Ok(Self { + position: list_items.get_pos(), + is_truncated: list_items.get_truncated(), + items: list_items.get_items().try_into()?, + meta_items: list_items.get_meta_items().try_into()?, + }) + } +} + +impl TryFrom<&PlaylistMetaItemMessage> for PlaylistMetaItem { + type Error = MetadataError; + fn try_from(item: &PlaylistMetaItemMessage) -> Result { + Ok(Self { + revision: item.try_into()?, + attributes: item.get_attributes().try_into()?, + length: item.get_length(), + timestamp: item.get_timestamp().try_into()?, + owner_username: item.get_owner_username().to_owned(), + }) + } +} + +try_from_repeated_message!(PlaylistMetaItemMessage, PlaylistMetaItems); diff --git a/metadata/src/playlist/list.rs b/metadata/src/playlist/list.rs new file mode 100644 index 00000000..7b5f0121 --- /dev/null +++ b/metadata/src/playlist/list.rs @@ -0,0 +1,201 @@ +use std::convert::{TryFrom, TryInto}; +use std::fmt::Debug; +use std::ops::Deref; + +use protobuf::Message; + +use crate::{ + date::Date, + error::MetadataError, + request::{MercuryRequest, RequestResult}, + util::try_from_repeated_message, + Metadata, +}; + +use super::{attribute::PlaylistAttributes, diff::PlaylistDiff, item::PlaylistItemList}; + +use librespot_core::session::Session; +use librespot_core::spotify_id::{NamedSpotifyId, SpotifyId}; +use librespot_protocol as protocol; + +#[derive(Debug, Clone)] +pub struct Playlist { + pub id: NamedSpotifyId, + pub revision: SpotifyId, + pub length: i32, + pub attributes: PlaylistAttributes, + pub contents: PlaylistItemList, + pub diff: PlaylistDiff, + pub sync_result: PlaylistDiff, + pub resulting_revisions: Playlists, + pub has_multiple_heads: bool, + pub is_up_to_date: bool, + pub nonces: Vec, + pub timestamp: Date, + pub has_abuse_reporting: bool, +} + +#[derive(Debug, Clone)] +pub struct Playlists(pub Vec); + +impl Deref for Playlists { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct RootPlaylist(pub SelectedListContent); + +impl Deref for RootPlaylist { + type Target = SelectedListContent; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct SelectedListContent { + pub revision: SpotifyId, + pub length: i32, + pub attributes: PlaylistAttributes, + pub contents: PlaylistItemList, + pub diff: PlaylistDiff, + pub sync_result: PlaylistDiff, + pub resulting_revisions: Playlists, + pub has_multiple_heads: bool, + pub is_up_to_date: bool, + pub nonces: Vec, + pub timestamp: Date, + pub owner_username: String, + pub has_abuse_reporting: bool, +} + +impl Playlist { + #[allow(dead_code)] + async fn request_for_user( + session: &Session, + username: &str, + playlist_id: SpotifyId, + ) -> RequestResult { + let uri = format!( + "hm://playlist/user/{}/playlist/{}", + username, + playlist_id.to_base62() + ); + ::request(session, &uri).await + } + + #[allow(dead_code)] + pub async fn get_for_user( + session: &Session, + username: &str, + playlist_id: SpotifyId, + ) -> Result { + let response = Self::request_for_user(session, username, playlist_id).await?; + let msg = ::Message::parse_from_bytes(&response)?; + Self::parse(&msg, playlist_id) + } + + pub fn tracks(&self) -> Vec { + let tracks = self + .contents + .items + .iter() + .map(|item| item.id) + .collect::>(); + + let length = tracks.len(); + let expected_length = self.length as usize; + if length != expected_length { + warn!( + "Got {} tracks, but the list should contain {} tracks.", + length, expected_length, + ); + } + + tracks + } + + pub fn name(&self) -> &str { + &self.attributes.name + } +} + +impl MercuryRequest for Playlist {} + +#[async_trait] +impl Metadata for Playlist { + type Message = protocol::playlist4_external::SelectedListContent; + + async fn request(session: &Session, playlist_id: SpotifyId) -> RequestResult { + let uri = format!("hm://playlist/v2/playlist/{}", playlist_id.to_base62()); + ::request(session, &uri).await + } + + fn parse(msg: &Self::Message, id: SpotifyId) -> Result { + // 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); + + Ok(Self { + id, + revision: playlist.revision, + length: playlist.length, + attributes: playlist.attributes, + contents: playlist.contents, + diff: playlist.diff, + sync_result: playlist.sync_result, + resulting_revisions: playlist.resulting_revisions, + has_multiple_heads: playlist.has_multiple_heads, + is_up_to_date: playlist.is_up_to_date, + nonces: playlist.nonces, + timestamp: playlist.timestamp, + has_abuse_reporting: playlist.has_abuse_reporting, + }) + } +} + +impl MercuryRequest for RootPlaylist {} + +impl RootPlaylist { + #[allow(dead_code)] + async fn request_for_user(session: &Session, username: &str) -> RequestResult { + let uri = format!("hm://playlist/user/{}/rootlist", username,); + ::request(session, &uri).await + } + + #[allow(dead_code)] + pub async fn get_root_for_user( + session: &Session, + username: &str, + ) -> Result { + let response = Self::request_for_user(session, username).await?; + let msg = protocol::playlist4_external::SelectedListContent::parse_from_bytes(&response)?; + Ok(Self(SelectedListContent::try_from(&msg)?)) + } +} + +impl TryFrom<&::Message> for SelectedListContent { + type Error = MetadataError; + fn try_from(playlist: &::Message) -> Result { + Ok(Self { + revision: playlist.get_revision().try_into()?, + length: playlist.get_length(), + attributes: playlist.get_attributes().try_into()?, + contents: playlist.get_contents().try_into()?, + diff: playlist.get_diff().try_into()?, + sync_result: playlist.get_sync_result().try_into()?, + resulting_revisions: playlist.get_resulting_revisions().try_into()?, + has_multiple_heads: playlist.get_multiple_heads(), + is_up_to_date: playlist.get_up_to_date(), + nonces: playlist.get_nonces().into(), + timestamp: playlist.get_timestamp().try_into()?, + owner_username: playlist.get_owner_username().to_owned(), + has_abuse_reporting: playlist.get_abuse_reporting_enabled(), + }) + } +} + +try_from_repeated_message!(Vec, Playlists); diff --git a/metadata/src/playlist/mod.rs b/metadata/src/playlist/mod.rs new file mode 100644 index 00000000..c52e637b --- /dev/null +++ b/metadata/src/playlist/mod.rs @@ -0,0 +1,9 @@ +pub mod annotation; +pub mod attribute; +pub mod diff; +pub mod item; +pub mod list; +pub mod operation; + +pub use annotation::PlaylistAnnotation; +pub use list::Playlist; diff --git a/metadata/src/playlist/operation.rs b/metadata/src/playlist/operation.rs new file mode 100644 index 00000000..c6ffa785 --- /dev/null +++ b/metadata/src/playlist/operation.rs @@ -0,0 +1,114 @@ +use std::convert::{TryFrom, TryInto}; +use std::fmt::Debug; +use std::ops::Deref; + +use crate::{ + error::MetadataError, + playlist::{ + attribute::{PlaylistUpdateAttributes, PlaylistUpdateItemAttributes}, + item::PlaylistItems, + }, + util::try_from_repeated_message, +}; + +use librespot_protocol as protocol; + +use protocol::playlist4_external::Add as PlaylistAddMessage; +use protocol::playlist4_external::Mov as PlaylistMoveMessage; +use protocol::playlist4_external::Op as PlaylistOperationMessage; +use protocol::playlist4_external::Rem as PlaylistRemoveMessage; + +pub use protocol::playlist4_external::Op_Kind as PlaylistOperationKind; + +#[derive(Debug, Clone)] +pub struct PlaylistOperation { + pub kind: PlaylistOperationKind, + pub add: PlaylistOperationAdd, + pub rem: PlaylistOperationRemove, + pub mov: PlaylistOperationMove, + pub update_item_attributes: PlaylistUpdateItemAttributes, + pub update_list_attributes: PlaylistUpdateAttributes, +} + +#[derive(Debug, Clone)] +pub struct PlaylistOperations(pub Vec); + +impl Deref for PlaylistOperations { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct PlaylistOperationAdd { + pub from_index: i32, + pub items: PlaylistItems, + pub add_last: bool, + pub add_first: bool, +} + +#[derive(Debug, Clone)] +pub struct PlaylistOperationMove { + pub from_index: i32, + pub length: i32, + pub to_index: i32, +} + +#[derive(Debug, Clone)] +pub struct PlaylistOperationRemove { + pub from_index: i32, + pub length: i32, + pub items: PlaylistItems, + pub has_items_as_key: bool, +} + +impl TryFrom<&PlaylistOperationMessage> for PlaylistOperation { + type Error = MetadataError; + fn try_from(operation: &PlaylistOperationMessage) -> Result { + Ok(Self { + kind: operation.get_kind(), + add: operation.get_add().try_into()?, + rem: operation.get_rem().try_into()?, + mov: operation.get_mov().into(), + update_item_attributes: operation.get_update_item_attributes().try_into()?, + update_list_attributes: operation.get_update_list_attributes().try_into()?, + }) + } +} + +try_from_repeated_message!(PlaylistOperationMessage, PlaylistOperations); + +impl TryFrom<&PlaylistAddMessage> for PlaylistOperationAdd { + type Error = MetadataError; + fn try_from(add: &PlaylistAddMessage) -> Result { + Ok(Self { + from_index: add.get_from_index(), + items: add.get_items().try_into()?, + add_last: add.get_add_last(), + add_first: add.get_add_first(), + }) + } +} + +impl From<&PlaylistMoveMessage> for PlaylistOperationMove { + fn from(mov: &PlaylistMoveMessage) -> Self { + Self { + from_index: mov.get_from_index(), + length: mov.get_length(), + to_index: mov.get_to_index(), + } + } +} + +impl TryFrom<&PlaylistRemoveMessage> for PlaylistOperationRemove { + type Error = MetadataError; + fn try_from(remove: &PlaylistRemoveMessage) -> Result { + Ok(Self { + from_index: remove.get_from_index(), + length: remove.get_length(), + items: remove.get_items().try_into()?, + has_items_as_key: remove.get_items_as_key(), + }) + } +} diff --git a/metadata/src/request.rs b/metadata/src/request.rs new file mode 100644 index 00000000..4e47fc38 --- /dev/null +++ b/metadata/src/request.rs @@ -0,0 +1,20 @@ +use crate::error::RequestError; + +use librespot_core::session::Session; + +pub type RequestResult = Result; + +#[async_trait] +pub trait MercuryRequest { + async fn request(session: &Session, uri: &str) -> RequestResult { + let response = session.mercury().get(uri).await?; + match response.payload.first() { + Some(data) => { + let data = data.to_vec().into(); + trace!("Received metadata: {:?}", data); + Ok(data) + } + None => Err(RequestError::Empty), + } + } +} diff --git a/metadata/src/restriction.rs b/metadata/src/restriction.rs new file mode 100644 index 00000000..588e45e2 --- /dev/null +++ b/metadata/src/restriction.rs @@ -0,0 +1,106 @@ +use std::fmt::Debug; +use std::ops::Deref; + +use crate::util::{from_repeated_enum, from_repeated_message}; + +use librespot_protocol as protocol; + +use protocol::metadata::Restriction as RestrictionMessage; + +pub use protocol::metadata::Restriction_Catalogue as RestrictionCatalogue; +pub use protocol::metadata::Restriction_Type as RestrictionType; + +#[derive(Debug, Clone)] +pub struct Restriction { + pub catalogues: RestrictionCatalogues, + pub restriction_type: RestrictionType, + pub catalogue_strs: Vec, + pub countries_allowed: Option>, + pub countries_forbidden: Option>, +} + +#[derive(Debug, Clone)] +pub struct Restrictions(pub Vec); + +impl Deref for Restrictions { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone)] +pub struct RestrictionCatalogues(pub Vec); + +impl Deref for RestrictionCatalogues { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Restriction { + fn parse_country_codes(country_codes: &str) -> Vec { + country_codes + .chunks(2) + .map(|country_code| country_code.to_owned()) + .collect() + } +} + +impl From<&RestrictionMessage> for Restriction { + fn from(restriction: &RestrictionMessage) -> Self { + let countries_allowed = if restriction.has_countries_allowed() { + Some(Self::parse_country_codes( + restriction.get_countries_allowed(), + )) + } else { + None + }; + + let countries_forbidden = if restriction.has_countries_forbidden() { + Some(Self::parse_country_codes( + restriction.get_countries_forbidden(), + )) + } else { + None + }; + + Self { + catalogues: restriction.get_catalogue().into(), + restriction_type: restriction.get_field_type(), + catalogue_strs: restriction.get_catalogue_str().to_vec(), + countries_allowed, + countries_forbidden, + } + } +} + +from_repeated_message!(RestrictionMessage, Restrictions); +from_repeated_enum!(RestrictionCatalogue, RestrictionCatalogues); + +struct StrChunks<'s>(&'s str, usize); + +trait StrChunksExt { + fn chunks(&self, size: usize) -> StrChunks; +} + +impl StrChunksExt for str { + fn chunks(&self, size: usize) -> StrChunks { + StrChunks(self, size) + } +} + +impl<'s> Iterator for StrChunks<'s> { + type Item = &'s str; + fn next(&mut self) -> Option<&'s str> { + let &mut StrChunks(data, size) = self; + if data.is_empty() { + None + } else { + let ret = Some(&data[..size]); + self.0 = &data[size..]; + ret + } + } +} diff --git a/metadata/src/sale_period.rs b/metadata/src/sale_period.rs new file mode 100644 index 00000000..6152b901 --- /dev/null +++ b/metadata/src/sale_period.rs @@ -0,0 +1,37 @@ +use std::fmt::Debug; +use std::ops::Deref; + +use crate::{date::Date, restriction::Restrictions, util::from_repeated_message}; + +use librespot_protocol as protocol; + +use protocol::metadata::SalePeriod as SalePeriodMessage; + +#[derive(Debug, Clone)] +pub struct SalePeriod { + pub restrictions: Restrictions, + pub start: Date, + pub end: Date, +} + +#[derive(Debug, Clone)] +pub struct SalePeriods(pub Vec); + +impl Deref for SalePeriods { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From<&SalePeriodMessage> for SalePeriod { + fn from(sale_period: &SalePeriodMessage) -> Self { + Self { + restrictions: sale_period.get_restriction().into(), + start: sale_period.get_start().into(), + end: sale_period.get_end().into(), + } + } +} + +from_repeated_message!(SalePeriodMessage, SalePeriods); diff --git a/metadata/src/show.rs b/metadata/src/show.rs new file mode 100644 index 00000000..4e75c598 --- /dev/null +++ b/metadata/src/show.rs @@ -0,0 +1,75 @@ +use std::convert::{TryFrom, TryInto}; +use std::fmt::Debug; + +use crate::{ + availability::Availabilities, copyright::Copyrights, episode::Episodes, error::RequestError, + image::Images, restriction::Restrictions, Metadata, MetadataError, RequestResult, +}; + +use librespot_core::session::Session; +use librespot_core::spotify_id::SpotifyId; +use librespot_protocol as protocol; + +pub use protocol::metadata::Show_ConsumptionOrder as ShowConsumptionOrder; +pub use protocol::metadata::Show_MediaType as ShowMediaType; + +#[derive(Debug, Clone)] +pub struct Show { + pub id: SpotifyId, + pub name: String, + pub description: String, + pub publisher: String, + pub language: String, + pub is_explicit: bool, + pub covers: Images, + pub episodes: Episodes, + pub copyrights: Copyrights, + pub restrictions: Restrictions, + pub keywords: Vec, + pub media_type: ShowMediaType, + pub consumption_order: ShowConsumptionOrder, + pub availability: Availabilities, + pub trailer_uri: SpotifyId, + pub has_music_and_talk: bool, +} + +#[async_trait] +impl Metadata for Show { + type Message = protocol::metadata::Show; + + async fn request(session: &Session, show_id: SpotifyId) -> RequestResult { + session + .spclient() + .get_show_metadata(show_id) + .await + .map_err(RequestError::Http) + } + + fn parse(msg: &Self::Message, _: SpotifyId) -> Result { + Self::try_from(msg) + } +} + +impl TryFrom<&::Message> for Show { + type Error = MetadataError; + fn try_from(show: &::Message) -> Result { + Ok(Self { + id: show.try_into()?, + name: show.get_name().to_owned(), + description: show.get_description().to_owned(), + publisher: show.get_publisher().to_owned(), + language: show.get_language().to_owned(), + is_explicit: show.get_explicit(), + covers: show.get_cover_image().get_image().into(), + episodes: show.get_episode().try_into()?, + copyrights: show.get_copyright().into(), + restrictions: show.get_restriction().into(), + keywords: show.get_keyword().to_vec(), + media_type: show.get_media_type(), + consumption_order: show.get_consumption_order(), + availability: show.get_availability().into(), + trailer_uri: SpotifyId::from_uri(show.get_trailer_uri())?, + has_music_and_talk: show.get_music_and_talk(), + }) + } +} diff --git a/metadata/src/track.rs b/metadata/src/track.rs new file mode 100644 index 00000000..8e7f6702 --- /dev/null +++ b/metadata/src/track.rs @@ -0,0 +1,150 @@ +use std::convert::{TryFrom, TryInto}; +use std::fmt::Debug; +use std::ops::Deref; + +use chrono::Local; +use uuid::Uuid; + +use crate::{ + artist::{Artists, ArtistsWithRole}, + audio::{ + file::AudioFiles, + item::{AudioItem, AudioItemResult, InnerAudioItem}, + }, + availability::{Availabilities, UnavailabilityReason}, + content_rating::ContentRatings, + date::Date, + error::RequestError, + external_id::ExternalIds, + restriction::Restrictions, + sale_period::SalePeriods, + util::try_from_repeated_message, + Metadata, MetadataError, RequestResult, +}; + +use librespot_core::session::Session; +use librespot_core::spotify_id::SpotifyId; +use librespot_protocol as protocol; + +#[derive(Debug, Clone)] +pub struct Track { + pub id: SpotifyId, + pub name: String, + pub album: SpotifyId, + pub artists: Artists, + pub number: i32, + pub disc_number: i32, + pub duration: i32, + pub popularity: i32, + pub is_explicit: bool, + pub external_ids: ExternalIds, + pub restrictions: Restrictions, + pub files: AudioFiles, + pub alternatives: Tracks, + pub sale_periods: SalePeriods, + pub previews: AudioFiles, + pub tags: Vec, + pub earliest_live_timestamp: Date, + pub has_lyrics: bool, + pub availability: Availabilities, + pub licensor: Uuid, + pub language_of_performance: Vec, + pub content_ratings: ContentRatings, + pub original_title: String, + pub version_title: String, + pub artists_with_role: ArtistsWithRole, +} + +#[derive(Debug, Clone)] +pub struct Tracks(pub Vec); + +impl Deref for Tracks { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[async_trait] +impl InnerAudioItem for Track { + async fn get_audio_item(session: &Session, id: SpotifyId) -> AudioItemResult { + let track = Self::get(session, id).await?; + let alternatives = { + if track.alternatives.is_empty() { + None + } else { + Some(track.alternatives.clone()) + } + }; + + // TODO: check meaning of earliest_live_timestamp in + let availability = if Local::now() < track.earliest_live_timestamp.as_utc() { + Err(UnavailabilityReason::Embargo) + } else { + Self::available_in_country(&track.availability, &track.restrictions, &session.country()) + }; + + Ok(AudioItem { + id, + spotify_uri: id.to_uri(), + files: track.files, + name: track.name, + duration: track.duration, + availability, + alternatives, + }) + } +} + +#[async_trait] +impl Metadata for Track { + type Message = protocol::metadata::Track; + + async fn request(session: &Session, track_id: SpotifyId) -> RequestResult { + session + .spclient() + .get_track_metadata(track_id) + .await + .map_err(RequestError::Http) + } + + fn parse(msg: &Self::Message, _: SpotifyId) -> Result { + Self::try_from(msg) + } +} + +impl TryFrom<&::Message> for Track { + type Error = MetadataError; + fn try_from(track: &::Message) -> Result { + Ok(Self { + id: track.try_into()?, + name: track.get_name().to_owned(), + album: track.get_album().try_into()?, + artists: track.get_artist().try_into()?, + number: track.get_number(), + disc_number: track.get_disc_number(), + duration: track.get_duration(), + popularity: track.get_popularity(), + is_explicit: track.get_explicit(), + external_ids: track.get_external_id().into(), + restrictions: track.get_restriction().into(), + files: track.get_file().into(), + alternatives: track.get_alternative().try_into()?, + sale_periods: track.get_sale_period().into(), + previews: track.get_preview().into(), + tags: track.get_tags().to_vec(), + earliest_live_timestamp: track.get_earliest_live_timestamp().try_into()?, + has_lyrics: track.get_has_lyrics(), + availability: track.get_availability().into(), + licensor: Uuid::from_slice(track.get_licensor().get_uuid()) + .unwrap_or_else(|_| Uuid::nil()), + language_of_performance: track.get_language_of_performance().to_vec(), + content_ratings: track.get_content_rating().into(), + original_title: track.get_original_title().to_owned(), + version_title: track.get_version_title().to_owned(), + artists_with_role: track.get_artist_with_role().try_into()?, + }) + } +} + +try_from_repeated_message!(::Message, Tracks); diff --git a/metadata/src/util.rs b/metadata/src/util.rs new file mode 100644 index 00000000..d0065221 --- /dev/null +++ b/metadata/src/util.rs @@ -0,0 +1,39 @@ +macro_rules! from_repeated_message { + ($src:ty, $dst:ty) => { + impl From<&[$src]> for $dst { + fn from(src: &[$src]) -> Self { + let result = src.iter().map(From::from).collect(); + Self(result) + } + } + }; +} + +pub(crate) use from_repeated_message; + +macro_rules! from_repeated_enum { + ($src:ty, $dst:ty) => { + impl From<&[$src]> for $dst { + fn from(src: &[$src]) -> Self { + let result = src.iter().map(|x| <$src>::from(*x)).collect(); + Self(result) + } + } + }; +} + +pub(crate) use from_repeated_enum; + +macro_rules! try_from_repeated_message { + ($src:ty, $dst:ty) => { + impl TryFrom<&[$src]> for $dst { + type Error = MetadataError; + fn try_from(src: &[$src]) -> Result { + let result: Result, _> = src.iter().map(TryFrom::try_from).collect(); + Ok(Self(result?)) + } + } + }; +} + +pub(crate) use try_from_repeated_message; diff --git a/metadata/src/video.rs b/metadata/src/video.rs new file mode 100644 index 00000000..926727a5 --- /dev/null +++ b/metadata/src/video.rs @@ -0,0 +1,21 @@ +use std::fmt::Debug; +use std::ops::Deref; + +use crate::util::from_repeated_message; + +use librespot_core::spotify_id::FileId; +use librespot_protocol as protocol; + +use protocol::metadata::VideoFile as VideoFileMessage; + +#[derive(Debug, Clone)] +pub struct VideoFiles(pub Vec); + +impl Deref for VideoFiles { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +from_repeated_message!(VideoFileMessage, VideoFiles); diff --git a/playback/src/player.rs b/playback/src/player.rs index 1395b99a..61c7105a 100644 --- a/playback/src/player.rs +++ b/playback/src/player.rs @@ -24,7 +24,7 @@ use crate::core::session::Session; use crate::core::spotify_id::SpotifyId; use crate::core::util::SeqGenerator; use crate::decoder::{AudioDecoder, AudioError, AudioPacket, PassthroughDecoder, VorbisDecoder}; -use crate::metadata::{AudioItem, FileFormat}; +use crate::metadata::audio::{AudioFileFormat, AudioItem}; use crate::mixer::AudioFilter; use crate::{NUM_CHANNELS, SAMPLES_PER_SECOND}; @@ -639,17 +639,17 @@ struct PlayerTrackLoader { impl PlayerTrackLoader { async fn find_available_alternative(&self, audio: AudioItem) -> Option { - if audio.available { + if audio.availability.is_ok() { Some(audio) } else if let Some(alternatives) = &audio.alternatives { let alternatives: FuturesUnordered<_> = alternatives .iter() - .map(|alt_id| AudioItem::get_audio_item(&self.session, *alt_id)) + .map(|alt_id| AudioItem::get_file(&self.session, *alt_id)) .collect(); alternatives .filter_map(|x| future::ready(x.ok())) - .filter(|x| future::ready(x.available)) + .filter(|x| future::ready(x.availability.is_ok())) .next() .await } else { @@ -657,19 +657,19 @@ impl PlayerTrackLoader { } } - fn stream_data_rate(&self, format: FileFormat) -> usize { + fn stream_data_rate(&self, format: AudioFileFormat) -> usize { match format { - FileFormat::OGG_VORBIS_96 => 12 * 1024, - FileFormat::OGG_VORBIS_160 => 20 * 1024, - FileFormat::OGG_VORBIS_320 => 40 * 1024, - FileFormat::MP3_256 => 32 * 1024, - FileFormat::MP3_320 => 40 * 1024, - FileFormat::MP3_160 => 20 * 1024, - FileFormat::MP3_96 => 12 * 1024, - FileFormat::MP3_160_ENC => 20 * 1024, - FileFormat::AAC_24 => 3 * 1024, - FileFormat::AAC_48 => 6 * 1024, - FileFormat::FLAC_FLAC => 112 * 1024, // assume 900 kbps on average + AudioFileFormat::OGG_VORBIS_96 => 12 * 1024, + AudioFileFormat::OGG_VORBIS_160 => 20 * 1024, + AudioFileFormat::OGG_VORBIS_320 => 40 * 1024, + AudioFileFormat::MP3_256 => 32 * 1024, + AudioFileFormat::MP3_320 => 40 * 1024, + AudioFileFormat::MP3_160 => 20 * 1024, + AudioFileFormat::MP3_96 => 12 * 1024, + AudioFileFormat::MP3_160_ENC => 20 * 1024, + AudioFileFormat::AAC_24 => 3 * 1024, + AudioFileFormat::AAC_48 => 6 * 1024, + AudioFileFormat::FLAC_FLAC => 112 * 1024, // assume 900 kbps on average } } @@ -678,7 +678,7 @@ impl PlayerTrackLoader { spotify_id: SpotifyId, position_ms: u32, ) -> Option { - let audio = match AudioItem::get_audio_item(&self.session, spotify_id).await { + let audio = match AudioItem::get_file(&self.session, spotify_id).await { Ok(audio) => audio, Err(_) => { error!("Unable to load audio item."); @@ -686,7 +686,10 @@ impl PlayerTrackLoader { } }; - info!("Loading <{}> with Spotify URI <{}>", audio.name, audio.uri); + info!( + "Loading <{}> with Spotify URI <{}>", + audio.name, audio.spotify_uri + ); let audio = match self.find_available_alternative(audio).await { Some(audio) => audio, @@ -699,22 +702,23 @@ impl PlayerTrackLoader { assert!(audio.duration >= 0); let duration_ms = audio.duration as u32; - // (Most) podcasts seem to support only 96 bit Vorbis, so fall back to it + // (Most) podcasts seem to support only 96 kbps Vorbis, so fall back to it + // TODO: update this logic once we also support MP3 and/or FLAC let formats = match self.config.bitrate { Bitrate::Bitrate96 => [ - FileFormat::OGG_VORBIS_96, - FileFormat::OGG_VORBIS_160, - FileFormat::OGG_VORBIS_320, + AudioFileFormat::OGG_VORBIS_96, + AudioFileFormat::OGG_VORBIS_160, + AudioFileFormat::OGG_VORBIS_320, ], Bitrate::Bitrate160 => [ - FileFormat::OGG_VORBIS_160, - FileFormat::OGG_VORBIS_96, - FileFormat::OGG_VORBIS_320, + AudioFileFormat::OGG_VORBIS_160, + AudioFileFormat::OGG_VORBIS_96, + AudioFileFormat::OGG_VORBIS_320, ], Bitrate::Bitrate320 => [ - FileFormat::OGG_VORBIS_320, - FileFormat::OGG_VORBIS_160, - FileFormat::OGG_VORBIS_96, + AudioFileFormat::OGG_VORBIS_320, + AudioFileFormat::OGG_VORBIS_160, + AudioFileFormat::OGG_VORBIS_96, ], };