diff --git a/CHANGELOG.md b/CHANGELOG.md index 436025c..c4c0273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,13 @@ and [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). - [remote] Add audio format and bitrate to `track_changed` event - [signal] New module for unified signal handling across platforms - [tests] Add anonymized API response examples +- [track] Support for podcast episodes with external streaming ### Changed - [docs] Enhanced documentation for signal handling and lifecycle management - [main] Improved signal handling and graceful shutdown +- [remote] Renamed `ALBUM_COVER` to `COVER_ID` in the `track_changed` event +- [track] Renamed `album_cover` to `cover_id` for consistency ## [0.7.0] - 2024-12-28 diff --git a/Cargo.lock b/Cargo.lock index 47e98ff..a94df2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2559,6 +2559,7 @@ dependencies = [ "lazy_static", "symphonia-bundle-flac", "symphonia-bundle-mp3", + "symphonia-codec-aac", "symphonia-core", "symphonia-metadata", ] @@ -2587,6 +2588,17 @@ dependencies = [ "symphonia-metadata", ] +[[package]] +name = "symphonia-codec-aac" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdbf25b545ad0d3ee3e891ea643ad115aff4ca92f6aec472086b957a58522f70" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", +] + [[package]] name = "symphonia-core" version = "0.5.4" diff --git a/Cargo.toml b/Cargo.toml index 7b7be70..fc1e7f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ reqwest = { version = "0.12", default-features = false, features = [ "stream", ] } rodio = { version = "0.20", default-features = false, features = [ + "symphonia-aac", "symphonia-flac", "symphonia-mp3", ] } diff --git a/README.md b/README.md index e1a9c96..44e991d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ - Volume normalization to maintain consistent levels across tracks - Configurable initial volume level with automatic fallback to client control - **Content Support**: + - **Songs**: Stream regular music tracks and your uploaded MP3s + - **Podcasts**: Listen to your favorite shows with direct streaming - **Flow and Mixes**: Access personalized playlists and [mixes](https://www.deezer.com/explore/mixes) tailored to your preferences - **User MP3s**: Play your [uploaded MP3 files](https://support.deezer.com/hc/en-gb/articles/115004221605-Upload-MP3s) alongside streamed content - **Playback Reporting**: Contribute to accurate artist monetization metrics @@ -55,8 +57,9 @@ ### Planned Features -- [Live radio](https://www.deezer.com/explore/radio) and [podcast](https://www.deezer.com/explore/podcasts) integration +- [Live radio](https://www.deezer.com/explore/radio) integration - Device registration +- RAM-backed storage ## Installation @@ -310,22 +313,30 @@ Emitted when playback is paused No additional variables #### `track_changed` -Emitted when the track changes - -Variables: -- `TRACK_ID`: The ID of the track -- `TITLE`: The track title -- `ARTIST`: The main artist name -- `ALBUM_TITLE`: The album title -- `ALBUM_COVER`: The album cover ID, which can be used to construct image URLs: - ``` - https://e-cdns-images.dzcdn.net/images/cover/{album_cover}/{resolution}x{resolution}.{format} - ``` - where `{resolution}` is the desired resolution in pixels (up to 1920) and - `{format}` is either `jpg` (smaller file size) or `png` (higher quality). - Deezer's default is 500x500.jpg -- `DURATION`: Track duration in seconds -- `FORMAT`: The audio format and bitrate (e.g., "MP3 320K" showing constant bitrate, or "FLAC 1.234M" showing variable bitrate) +Emitted when the track changes. The variables differ based on content type: + +| Variable | Music | Podcast | Radio | +|---------------|--------------------------|----------------------------|--------------------------| +| `TRACK_TYPE` | `song` | `episode` | `livestream` | +| `TRACK_ID` | Song ID | Episode ID | Livestream ID | +| `TITLE` | Song title | Episode title | _(not set)_ | +| `ARTIST` | Artist name | Podcast title | Station name | +| `ALBUM_TITLE` | Album title | _(not set)_ | _(not set)_ | +| `COVER_ID` | Album art | Podcast art | Station logo | +| `DURATION` | Song duration (seconds) | Episode duration (seconds) | _(not set)_ | +| `FORMAT` | Audio format and bitrate | Audio format and bitrate | Audio format and bitrate | + +The `FORMAT` value shows the audio configuration (e.g., "MP3 320K" for constant bitrate, +or "FLAC 1.234M" for variable bitrate). + +The `COVER_ID` can be used to construct image URLs: +``` +https://e-cdns-images.dzcdn.net/images/cover/{cover_id}/{resolution}x{resolution}.{format} +``` +where `{resolution}` is the desired resolution in pixels (up to 1920) and +`{format}` is either `jpg` (smaller file size) or `png` (higher quality). +Deezer's default is `500x500.jpg`. +``` #### `connected` Emitted when a Deezer client connects to control playback diff --git a/src/gateway.rs b/src/gateway.rs index d407833..f72785b 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -29,6 +29,7 @@ use std::time::SystemTime; +use futures_util::TryFutureExt; use md5::{Digest, Md5}; use reqwest::{ self, @@ -45,10 +46,21 @@ use crate::{ protocol::{ self, auth, connect::{ - queue::{self, TrackType}, + queue::{self}, AudioQuality, UserId, }, - gateway::{self, MediaUrl, Queue, UserData}, + gateway::{ + self, + list_data::{ + episodes::{self, EpisodeData}, + livestream::{self, LivestreamData}, + songs::{self, SongData}, + ListData, + }, + user_radio::{self, UserRadio}, + MediaUrl, Queue, Response, UserData, + }, + Codec, }, tokens::UserToken, }; @@ -239,7 +251,7 @@ impl Gateway { pub async fn refresh(&mut self) -> Result<()> { // Send an empty JSON map match self - .request::(Self::EMPTY_JSON_OBJECT, None) + .request::(Self::EMPTY_JSON_OBJECT, None) .await { Ok(response) => { @@ -309,7 +321,7 @@ impl Gateway { &mut self, body: impl Into, headers: Option, - ) -> Result> + ) -> Result> where T: std::fmt::Debug + gateway::Method + for<'de> Deserialize<'de>, { @@ -385,7 +397,7 @@ impl Gateway { /// Returns a reference to the current user data if available. #[must_use] - pub fn user_data(&self) -> Option<&gateway::UserData> { + pub fn user_data(&self) -> Option<&UserData> { self.user_data.as_ref() } @@ -449,27 +461,48 @@ impl Gateway { /// * Network request fails /// * Response parsing fails pub async fn list_to_queue(&mut self, list: &queue::List) -> Result { - let track_list = gateway::list_data::Request { - track_ids: list - .tracks - .iter() - .map(|track| { - let track_type = track.typ.enum_value_or_default(); - if track_type == TrackType::TRACK_TYPE_SONG { - track.id.parse().map_err(Into::into) - } else { - Err(Error::unimplemented(format!( - "{track_type:?} not yet implemented" - ))) - } - }) - .collect::, _>>()?, - }; + let ids = list + .tracks + .iter() + .map(|track| track.id.parse().map_err(Error::from)) + .collect::, _>>()?; + + if let Some(first) = list.tracks.first() { + let response: Response = match first.typ.enum_value_or_default() { + queue::TrackType::TRACK_TYPE_SONG => { + let songs = songs::Request { song_ids: ids }; + let request = serde_json::to_string(&songs)?; + self.request::(request, None) + .map_ok(Into::into) + .await? + } + queue::TrackType::TRACK_TYPE_EPISODE => { + let episodes = episodes::Request { episode_ids: ids }; + let request = serde_json::to_string(&episodes)?; + self.request::(request, None) + .map_ok(Into::into) + .await? + } + queue::TrackType::TRACK_TYPE_LIVE => { + let radio = livestream::Request { + livestream_id: first.id.parse()?, + supported_codecs: vec![Codec::AAC, Codec::MP3], + }; + let request = serde_json::to_string(&radio)?; + self.request::(request, None) + .map_ok(Into::into) + .await? + } + queue::TrackType::TRACK_TYPE_CHAPTER => { + return Err(Error::unimplemented( + "audio books not implemented - report what you were trying to play to the developers", + )); + } + }; - let body = serde_json::to_string(&track_list)?; - match self.request::(body, None).await { - Ok(response) => Ok(response.all().clone()), - Err(e) => Err(e), + Ok(response.all().clone()) + } else { + Ok(Queue::default()) } } @@ -487,9 +520,9 @@ impl Gateway { /// * Network request fails /// * Response parsing fails pub async fn user_radio(&mut self, user_id: UserId) -> Result { - let request = gateway::user_radio::Request { user_id }; + let request = user_radio::Request { user_id }; let body = serde_json::to_string(&request)?; - match self.request::(body, None).await { + match self.request::(body, None).await { Ok(response) => { // Transform the `UserRadio` response into a `Queue`. This is done to have // `UserRadio` re-use the `ListData` struct (for which `Queue` is an alias). diff --git a/src/main.rs b/src/main.rs index 6167a60..7fb9ca0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,11 @@ //! * Application lifecycle //! * Connection retry logic with exponential backoff //! +//! * Audio content: +//! - Songs +//! - Podcast episodes +//! - Live radio (future) +//! //! # Runtime Behavior //! //! The application: @@ -221,6 +226,7 @@ fn init_logger(config: &Args) { } // Filter log messages of external crates. + logger.filter_module("symphonia_codec_aac", LevelFilter::Warn); logger.filter_module("symphonia_bundle_flac", LevelFilter::Warn); logger.filter_module("symphonia_bundle_mp3", LevelFilter::Warn); logger.filter_module("symphonia_core", LevelFilter::Warn); diff --git a/src/player.rs b/src/player.rs index e5cc2e9..3d1bea2 100644 --- a/src/player.rs +++ b/src/player.rs @@ -707,21 +707,26 @@ impl Player { ratio = f32::powf(10.0, difference / 20.0); } None => { - warn!("track {track} has no gain information, skipping normalization"); + warn!( + "{} {track} has no gain information, skipping normalization", + track.typ() + ); } } } let rx = if ratio < 1.0 { debug!( - "attenuating track {track} by {difference:.1} dB ({})", + "attenuating {} {track} by {difference:.1} dB ({})", + track.typ(), Percentage::from_ratio_f32(ratio) ); let attenuated = decoder.amplify(ratio); sources.append_with_signal(attenuated) } else if ratio > 1.0 { debug!( - "amplifying track {track} by {difference:.1} dB ({}) (with limiter)", + "amplifying {} {track} by {difference:.1} dB ({}) (with limiter)", + track.typ(), Percentage::from_ratio_f32(ratio) ); let amplified = decoder.automatic_gain_control( @@ -792,13 +797,14 @@ impl Player { let next_position = self.position.saturating_add(1); if let Some(next_track) = self.queue.get(next_position) { let next_track_id = next_track.id(); + let next_track_typ = next_track.typ(); if !self.skip_tracks.contains(&next_track_id) { match self.load_track(next_position).await { Ok(rx) => { self.preload_rx = rx; } Err(e) => { - error!("failed to preload next track: {e}"); + error!("failed to preload next {next_track_typ}: {e}"); self.mark_unavailable(next_track_id); } } @@ -810,6 +816,7 @@ impl Player { None => { if let Some(track) = self.track() { let track_id = track.id(); + let track_typ = track.typ(); if self.skip_tracks.contains(&track_id) { self.go_next(); } else { @@ -824,7 +831,7 @@ impl Player { } } Err(e) => { - error!("failed to load track: {e}"); + error!("failed to load {track_typ}: {e}"); self.mark_unavailable(track_id); } } @@ -1270,7 +1277,7 @@ impl Player { #[must_use] pub fn progress(&self) -> Option { if let Some(track) = self.track() { - let duration = track.duration(); + let duration = track.duration()?; if duration.is_zero() { return None; } @@ -1307,10 +1314,15 @@ impl Player { /// * Seek operation fails pub fn set_progress(&mut self, progress: Percentage) -> Result<()> { if let Some(track) = self.track() { - info!("setting track progress to {progress}"); + info!("setting {} progress to {progress}", track.typ()); let progress = progress.as_ratio_f32(); if progress < 1.0 { - let progress = track.duration().mul_f32(progress); + let progress = track + .duration() + .ok_or_else(|| { + Error::unavailable(format!("duration unknown for {} {track}", track.typ())) + })? + .mul_f32(progress); // Try to seek only if the track has started downloading, otherwise defer the seek. // This prevents stalling the player when seeking in a track that has not started. diff --git a/src/protocol/codec.rs b/src/protocol/codec.rs new file mode 100644 index 0000000..0f167a9 --- /dev/null +++ b/src/protocol/codec.rs @@ -0,0 +1,39 @@ +//! Audio codec support for Deezer content. +//! +//! Defines supported audio formats: +//! * AAC - Advanced Audio Coding (streams) +//! * FLAC - Free Lossless Audio Codec (downloads) +//! * MP3 - MPEG Layer-3 (both) +//! +//! Different content types support different codecs: +//! * Songs - MP3 and FLAC +//! * Episodes - MP3 only +//! * Livestreams - AAC and MP3 + +use serde::Serialize; +use std::fmt; + +/// Supported audio codecs for live streams. +/// Note: Deezer does not use FLAC for live streams. +#[derive(Clone, Eq, PartialEq, Serialize, Debug, Hash)] +#[serde(rename_all = "lowercase")] +pub enum Codec { + /// Advanced Audio Coding + AAC, + /// Free Lossless Audio Codec + FLAC, + /// MPEG Layer-3 + MP3, +} + +/// Formats codec type for display. +/// +/// Used for serialization and logging. Shows codec name in uppercase: +/// * "AAC" +/// * "FLAC" +/// * "MP3" +impl fmt::Display for Codec { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{self:?}") + } +} diff --git a/src/protocol/gateway/list_data.rs b/src/protocol/gateway/list_data.rs deleted file mode 100644 index f74e027..0000000 --- a/src/protocol/gateway/list_data.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! Track list data retrieval from Deezer's gateway API. -//! -//! This module handles fetching detailed track information, including: -//! * Track metadata (title, artist, album) -//! * Playback information (duration, gain) -//! * Authentication (track tokens) -//! * Media assets (album covers) -//! -//! # Wire Format -//! -//! Response format: -//! ```json -//! { -//! "SNG_ID": "123456", -//! "ART_NAME": "Artist Name", -//! "ALB_TITLE": "Album Title", -//! "ALB_PICTURE": "album_cover_id", -//! "DURATION": "180", -//! "SNG_TITLE": "Track Title", -//! "GAIN": "-1.3", -//! "TRACK_TOKEN": "secret_token", -//! "TRACK_TOKEN_EXPIRE": "1234567890" -//! } -//! ``` -//! -//! Request format: -//! ```json -//! { -//! "sng_ids": ["123456", "789012"] -//! } -//! ``` - -use std::time::{Duration, SystemTime}; - -use serde::{Deserialize, Serialize}; -use serde_with::{ - formats::Flexible, serde_as, DisplayFromStr, DurationSeconds, PickFirst, TimestampSeconds, -}; -use veil::Redact; - -use crate::track::TrackId; - -use super::{Method, StringOrUnknown}; - -/// Gateway method name for retrieving track information. -/// -/// This endpoint returns detailed track data including: -/// * Metadata (titles, artists, albums) -/// * Playback information (duration, gain) -/// * Authentication tokens -/// * Media asset identifiers -impl Method for ListData { - const METHOD: &'static str = "song.getListData"; -} - -/// Collection of track list data responses. -pub type Queue = Vec; - -/// Detailed track information from Deezer's gateway. -/// -/// Contains all the metadata and authentication information needed -/// to play a track, including titles, tokens, and media assets. -/// Some fields like duration may be missing or invalid and will use defaults. -/// -/// # Fields -/// -/// * `track_id` - Unique track identifier -/// * `artist` - Artist name (defaults to "UNKNOWN") -/// * `album_title` - Album name (defaults to "UNKNOWN") -/// * `album_cover` - Album artwork identifier -/// * `duration` - Track length -/// * `title` - Track name (defaults to "UNKNOWN") -/// * `gain` - Volume normalization value -/// * `track_token` - Authentication token for playback -/// * `expiry` - Token expiration timestamp -/// -/// # Example -/// -/// ```rust -/// use deezer::gateway::{ListData, Response}; -/// -/// let response: Response = /* gateway response */; -/// if let Some(track) = response.first() { -/// println!("{} by {}", track.title, track.artist); -/// println!("Token expires: {:?}", track.expiry); -/// } -/// ``` -#[serde_as] -#[derive(Clone, PartialEq, PartialOrd, Deserialize, Redact)] -#[serde(rename_all = "UPPERCASE")] -pub struct ListData { - /// Unique track identifier. - /// - /// This ID is consistent across all Deezer services and can be: - /// * Positive - Regular Deezer tracks - /// * Negative - User-uploaded tracks - #[serde(rename = "SNG_ID")] - #[serde_as(as = "PickFirst<(_, serde_with::DisplayFromStr)>")] - pub track_id: TrackId, - - /// Artist name. - /// - /// Defaults to "UNKNOWN" if not provided or invalid. - /// For tracks with multiple artists, this contains only the main artist. - #[serde(default)] - #[serde(rename = "ART_NAME")] - pub artist: StringOrUnknown, - - /// Album title. - /// - /// Defaults to "UNKNOWN" if not provided or invalid. - /// For singles or EPs, this might be the same as the track title. - #[serde(default)] - #[serde(rename = "ALB_TITLE")] - pub album_title: StringOrUnknown, - - /// Album cover identifier. - /// - /// When available, this ID can be used to construct image URLs: - /// ```text - /// https://e-cdns-images.dzcdn.net/images/cover/{album_cover}/{resolution}x{resolution}.{format} - /// ``` - /// Where: - /// * `resolution` is the desired size in pixels (up to 1920) - /// * `format` is either: - /// - `jpg` for smaller file size - /// - `png` for higher quality - /// - /// Deezer's default format is 500x500.jpg - /// - /// Defaults to an empty string when no cover is available. - #[serde(default)] - #[serde(rename = "ALB_PICTURE")] - pub album_cover: String, - - /// Track duration. - /// - /// The actual playback length of the track, parsed from seconds. - /// Used for progress calculation and UI display. - /// Defaults to zero duration if not provided or invalid. - #[serde(default)] - #[serde_as(as = "DurationSeconds")] - pub duration: Duration, - - /// Track title. - /// - /// Defaults to "UNKNOWN" if not provided or invalid. - /// This is the main display title of the track. - #[serde(default)] - #[serde(rename = "SNG_TITLE")] - pub title: StringOrUnknown, - - /// Track's average loudness in decibels (dB). - /// - /// Used to calculate volume normalization. May be absent if - /// loudness data isn't available. - /// - /// Negative values indicate quieter tracks (typical range: -20 to 0 dB). - #[serde_as(as = "Option")] - pub gain: Option, - - /// Authentication token for track playback. - /// - /// This token is required to access the track's media content and: - /// * Is unique per track - /// * Has a limited validity period - /// * Should be kept secure - #[redact] - pub track_token: String, - - /// Token expiration timestamp. - /// - /// The time at which the `track_token` becomes invalid. - /// New tokens should be requested after expiration. - #[serde(rename = "TRACK_TOKEN_EXPIRE")] - #[serde_as(as = "TimestampSeconds")] - pub expiry: SystemTime, -} - -/// Request parameters for track list data. -/// -/// Used to request information for multiple tracks in a single query. -/// -/// # Example -/// -/// ```rust -/// use deezer::gateway::{Request, TrackId}; -/// -/// let request = Request { -/// track_ids: vec![123456.into(), 789012.into()], -/// }; -/// ``` -#[serde_as] -#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Debug, Hash)] -pub struct Request { - /// List of track IDs to fetch information for. - /// - /// Each ID must be: - /// * Non-zero - /// * Either positive (Deezer tracks) or negative (user uploads) - /// * Valid within Deezer's catalog - #[serde(rename = "sng_ids")] - #[serde_as(as = "Vec")] - pub track_ids: Vec, -} diff --git a/src/protocol/gateway/list_data/episodes.rs b/src/protocol/gateway/list_data/episodes.rs new file mode 100644 index 0000000..2ab6a22 --- /dev/null +++ b/src/protocol/gateway/list_data/episodes.rs @@ -0,0 +1,98 @@ +//! Podcast episode handling for Deezer's gateway API. +//! +//! Provides episode-specific wrappers and types, including: +//! * Episode metadata (title, show, duration) +//! * External streaming URLs +//! * Availability status +//! * Show artwork +//! +//! Episodes differ from songs in several ways: +//! * Use direct streaming rather than encrypted downloads +//! * Include show/podcast metadata instead of artist/album +//! * Have region availability restrictions +//! * May be hosted outside Deezer's CDN +//! +//! # Wire Format +//! +//! Response format: +//! ```json +//! { +//! "EPISODE_ID": "123456", +//! "AVAILABLE": true, +//! "DURATION": "1800", +//! "EPISODE_TITLE": "Episode Title", +//! "SHOW_NAME": "Podcast Name", +//! "SHOW_ART_MD5": "cover_id", +//! "TRACK_TOKEN": "secret_token", +//! "TRACK_TOKEN_EXPIRE": "1234567890", +//! "EPISODE_DIRECT_STREAM_URL": "https://..." +//! } +//! ``` + +use std::ops::Deref; + +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, DisplayFromStr}; + +use super::{ListData, Method}; +use crate::track::TrackId; + +/// Gateway method name for retrieving episodes. +/// +/// This endpoint returns detailed episode data including: +/// * Episode metadata +/// * Show information +/// * Authentication tokens (if hosted on Deezer CDN) +/// * Streaming URLs +/// * Regional availability +impl Method for EpisodeData { + const METHOD: &'static str = "episode.getListData"; +} + +/// Wrapper for episode data. +/// +/// Contains the same track information as [`ListData`] but specifically +/// for podcast episodes. The wrapper allows specialized handling while +/// reusing the underlying data structure. +#[derive(Clone, PartialEq, Deserialize, Debug)] +#[serde(transparent)] +pub struct EpisodeData(pub ListData); + +/// Provides access to the underlying episode data. +/// +/// Allows transparent access to the episode fields while maintaining +/// type safety for episode-specific operations. +impl Deref for EpisodeData { + type Target = ListData; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// Request parameters for episode list data. +/// +/// Used to request information for multiple episodes in a single query. +/// Episodes must be available in the user's region to be retrieved. +/// +/// # Example +/// +/// ```rust +/// use deezer::gateway::{Request, TrackId}; +/// +/// let request = Request { +/// episode_ids: vec![123456.into(), 789012.into()], +/// }; +/// ``` +#[serde_as] +#[derive(Clone, Eq, PartialEq, Serialize, Debug, Hash)] +pub struct Request { + /// List of episode IDs to fetch information for. + /// + /// Each ID must be: + /// * Non-zero + /// * Valid within Deezer's catalog + /// * Available in user's region + #[serde_as(as = "Vec")] + pub episode_ids: Vec, +} diff --git a/src/protocol/gateway/list_data/livestream.rs b/src/protocol/gateway/list_data/livestream.rs new file mode 100644 index 0000000..3523811 --- /dev/null +++ b/src/protocol/gateway/list_data/livestream.rs @@ -0,0 +1,96 @@ +//! Live radio stream handling for Deezer's gateway API. +//! +//! Provides livestream-specific wrappers and types for: +//! * Stream URLs in multiple formats (AAC/MP3) +//! * Multiple bitrate options +//! * Station metadata +//! * Availability status +//! +//! Livestreams have unique characteristics: +//! * No track duration/progress +//! * Multiple parallel streams +//! * Codec selection +//! * Always external URLs +//! +//! # Wire Format +//! +//! Response format: +//! ```json +//! { +//! "EPISODE_ID": "123456", +//! "AVAILABLE": true, +//! "DURATION": "1800", +//! "EPISODE_TITLE": "Episode Title", +//! "SHOW_NAME": "Podcast Name", +//! "SHOW_ART_MD5": "cover_id", +//! "TRACK_TOKEN": "secret_token", +//! "TRACK_TOKEN_EXPIRE": "1234567890", +//! "EPISODE_DIRECT_STREAM_URL": "https://..." +//! } +//! ``` + +use std::ops::Deref; + +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, DisplayFromStr}; + +use super::{ListData, Method}; +use crate::{protocol::Codec, track::TrackId}; + +/// Gateway method name for retrieving radio streams. +/// +/// Returns stream information including: +/// * Station metadata +/// * Multiple quality streams +/// * Codec options +/// * Availability status +impl Method for LivestreamData { + const METHOD: &'static str = "livestream.getData"; +} + +/// Wrapper for livestream data. +/// +/// Contains the same track information as [`ListData`] but specifically +/// for podcast episodes. The wrapper allows specialized handling while +/// reusing the underlying data structure. +#[derive(Clone, PartialEq, Deserialize, Debug)] +#[serde(transparent)] +#[expect(clippy::module_name_repetitions)] +pub struct LivestreamData(pub ListData); + +/// Provides access to the underlying livestream data. +/// +/// Allows transparent access to the livestream fields while maintaining +/// type safety for livestream-specific operations. +impl Deref for LivestreamData { + type Target = ListData; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// Request parameters for track list data. +/// +/// Used to request information for multiple tracks in a single query. +/// Supports different content types through enum variants. +/// +/// # Example +/// +/// ```rust +/// use deezer::gateway::{Request, TrackId}; +/// +/// let request = Request::Songs { +/// song_ids: vec![123456.into(), 789012.into()], +/// }; +/// ``` +#[serde_as] +#[derive(Clone, Eq, PartialEq, Serialize, Debug, Hash)] +pub struct Request { + /// Live stream ID to fetch information for. + pub livestream_id: TrackId, + + /// List of audio codecs supported by the client + #[serde_as(as = "Vec")] + pub supported_codecs: Vec, +} diff --git a/src/protocol/gateway/list_data/mod.rs b/src/protocol/gateway/list_data/mod.rs new file mode 100644 index 0000000..d2d05d7 --- /dev/null +++ b/src/protocol/gateway/list_data/mod.rs @@ -0,0 +1,556 @@ +//! Core types and functionality for Deezer content listings. +//! +//! This module provides shared data structures and traits for handling: +//! * Songs - Regular music tracks +//! * Episodes - Podcast episodes +//! * Livestreams - Radio stations +//! +//! Content types share common traits but have specialized handling through +//! type-specific wrappers in submodules: +//! * [`songs`] - Music track handling +//! * [`episodes`] - Podcast episode handling +//! * [`livestream`] - Radio stream handling +//! +//! # Content Types +//! +//! While all content types share basic metadata like IDs and titles, +//! they have unique characteristics: +//! +//! * Songs +//! - Artist/album metadata +//! - Volume normalization +//! - Encrypted content +//! +//! * Episodes +//! - Show/podcast metadata +//! - External streaming URLs +//! - Availability flags +//! +//! * Livestreams +//! - Multiple quality streams +//! - Codec selection +//! - No duration/progress +//! +//! # Wire Format +//! +//! Each content type has its own response format: +//! +//! ## Songs +//! ```json +//! { +//! "SNG_ID": "123456", +//! "ART_NAME": "Artist Name", +//! "ALB_TITLE": "Album Title", +//! ... +//! } +//! ``` +//! +//! ## Episodes +//! ```json +//! { +//! "EPISODE_ID": "123456", +//! "EPISODE_TITLE": "Episode Title", +//! "SHOW_NAME": "Show Name", +//! ... +//! } +//! ``` +//! +//! ## Livestreams +//! ```json +//! { +//! "LIVESTREAM_ID": "123456", +//! "LIVESTREAM_TITLE": "Station Name", +//! "LIVESTREAM_URLS": { ... }, +//! ... +//! } +//! ``` + +pub mod episodes; +pub mod livestream; +pub mod songs; + +pub use episodes::EpisodeData; +pub use livestream::LivestreamData; +pub use songs::SongData; + +use std::{ + collections::HashMap, + ops::Deref, + time::{Duration, SystemTime}, +}; + +use serde::Deserialize; +use serde_with::{ + formats::Flexible, serde_as, DisplayFromStr, DurationSeconds, PickFirst, TimestampSeconds, +}; +use url::Url; +use veil::Redact; + +use crate::track::TrackId; + +use super::Method; + +/// Collection of track list data responses. +/// +/// Contains a list of tracks that can be: +/// * Songs from the Deezer catalog +/// * User-uploaded songs +/// * Podcast episodes +/// * Live radio streams +pub type Queue = Vec; + +/// Detailed track information from Deezer's gateway. +/// +/// Contains metadata and authentication information needed to play content: +/// * Unique identifiers +/// * Titles and artist/show information +/// * Media assets (covers) +/// * Playback details (duration, gain) +/// * Authentication tokens +/// * Availability information +/// +/// Supports multiple content types through enum variants: +/// * Songs - Regular music tracks +/// * Episodes - Podcast episodes +/// * Livestreams - Radio stations +/// +/// # Fields +/// +/// * `track_id` - Unique track identifier +/// * `artist` - Artist name +/// * `album_title` - Album name +/// * `album_cover` - Album artwork identifier +/// * `duration` - Track length +/// * `title` - Track name +/// * `gain` - Volume normalization value +/// * `track_token` - Authentication token for playback +/// * `expiry` - Token expiration timestamp +/// +/// # Example +/// +/// ```rust +/// use deezer::gateway::{ListData, Response}; +/// +/// let response: Response = /* gateway response */; +/// if let Some(track) = response.first() { +/// println!("{} by {}", track.title, track.artist); +/// println!("Token expires: {:?}", track.expiry); +/// } +/// ``` +#[serde_as] +#[derive(Clone, PartialEq, Deserialize, Redact)] +#[serde(tag = "__TYPE__")] +pub enum ListData { + /// Regular music track + #[serde(rename = "song")] + Song { + /// Unique song identifier. + /// + /// This ID can be: + /// * Positive - Regular Deezer songs + /// * Negative - User-uploaded songs + #[serde(rename = "SNG_ID")] + #[serde_as(as = "PickFirst<(DisplayFromStr, _)>")] + id: TrackId, + + /// Artist name. + /// + /// For songs with multiple artists, this contains only the main artist. + #[serde(default)] + #[serde(rename = "ART_NAME")] + artist: String, + + /// Album title. + /// + /// For singles or EPs, this might be the same as the song title. + #[serde(default)] + #[serde(rename = "ALB_TITLE")] + album_title: String, + + /// Album cover identifier. + /// + /// When available, this ID can be used to construct image URLs: + /// ```text + /// https://e-cdns-images.dzcdn.net/images/cover/{album_cover}/{resolution}x{resolution}.{format} + /// ``` + /// Where: + /// * `resolution` is the desired size in pixels (up to 1920) + /// * `format` is either: + /// - `jpg` for smaller file size + /// - `png` for higher quality + /// + /// Deezer's default format is 500x500.jpg + /// + /// Defaults to an empty string when no cover is available. + #[serde(default)] + #[serde(rename = "ALB_PICTURE")] + album_cover: String, + + /// Song duration. + /// + /// The actual playback length of the song, parsed from seconds. + /// Used for progress calculation and UI display. + /// Defaults to zero duration if not provided or invalid. + #[serde(default)] + #[serde(rename = "DURATION")] + #[serde_as(as = "DurationSeconds")] + duration: Duration, + + /// Song title. + /// + /// This is the main display title of the song. + #[serde(default)] + #[serde(rename = "SNG_TITLE")] + title: String, + + /// Song's average loudness in decibels (dB). + /// + /// Used to calculate volume normalization. May be absent if + /// loudness data isn't available. + /// + /// Negative values indicate quieter songs (typical range: -20 to 0 dB). + #[serde(rename = "GAIN")] + #[serde_as(as = "Option")] + gain: Option, + + /// Authentication token for song playback. + /// + /// This token is required to access the song's media content and: + /// * Is unique per track + /// * Has a limited validity period + /// * Should be kept secure + #[serde(rename = "TRACK_TOKEN")] + #[redact] + track_token: String, + + /// Token expiration timestamp. + /// + /// The time at which the `track_token` becomes invalid. + /// New tokens should be requested after expiration. + #[serde(rename = "TRACK_TOKEN_EXPIRE")] + #[serde_as(as = "TimestampSeconds")] + expiry: SystemTime, + }, + + /// Podcast episode + #[serde(rename = "episode")] + Episode { + /// Unique episode identifier. + #[serde(rename = "EPISODE_ID")] + #[serde_as(as = "PickFirst<(DisplayFromStr, _)>")] + id: TrackId, + + /// Whether the episode is available in the user's region + #[serde(rename = "AVAILABLE")] + #[serde(default)] + available: bool, + + /// Episode duration. + /// + /// The actual playback length of the episode, parsed from seconds. + /// Used for progress calculation and UI display. + /// Defaults to zero duration if not provided or invalid. #[serde(default)] + #[serde(rename = "DURATION")] + #[serde_as(as = "DurationSeconds")] + duration: Duration, + + /// Direct streaming URL for the episode. + /// + /// Unlike songs which require token-based downloads, + /// episodes are streamed directly from this URL. + #[serde(rename = "EPISODE_DIRECT_STREAM_URL")] + external_url: Option, + + /// Episode title. + /// + /// This is the main display title of the episode. + #[serde(default)] + #[serde(rename = "EPISODE_TITLE")] + title: String, + + /// Whether this is an external stream. + /// + /// True for episodes hosted outside Deezer's CDN. + #[serde(default)] + #[serde(rename = "SHOW_IS_DIRECT_STREAM")] + #[serde(deserialize_with = "bool_from_string")] + external: bool, + + /// Show name. + /// + /// The name of the podcast this episode belongs to. + /// For shows with multiple hosts, this contains only the main host. + #[serde(default)] + #[serde(rename = "SHOW_NAME")] + podcast_title: String, + + /// Podcast cover identifier. + /// + /// When available, this ID can be used to construct image URLs: + /// ```text + /// https://e-cdns-images.dzcdn.net/images/cover/{podcast_art}/{resolution}x{resolution}.{format} + /// ``` + /// Where: + /// * `resolution` is the desired size in pixels (up to 1920) + /// * `format` is either: + /// - `jpg` for smaller file size + /// - `png` for higher quality + /// + /// Deezer's default format is 500x500.jpg + /// + /// Defaults to an empty string when no cover is available. + #[serde(default)] + #[serde(rename = "SHOW_ART_MD5")] + podcast_art: String, + + /// Authentication token for podcast playback from Deezer's CDN. + /// + /// This token is required to access the podcast's media content and: + /// * Is unique per episode + /// * Has a limited validity period + /// * Should be kept secure + #[serde(rename = "TRACK_TOKEN")] + #[redact] + track_token: String, + + /// Token expiration timestamp. + /// + /// The time at which the `track_token` becomes invalid. + /// New tokens should be requested after expiration. + #[serde(rename = "TRACK_TOKEN_EXPIRE")] + #[serde_as(as = "TimestampSeconds")] + expiry: SystemTime, + }, + + /// Live radio stream + #[serde(rename = "livestream")] + Livestream { + /// Unique live stream identifier. + #[serde(rename = "LIVESTREAM_ID")] + #[serde_as(as = "PickFirst<(_, DisplayFromStr)>")] + id: TrackId, + + /// Live stream title. + /// + /// The name of the radio station. + #[serde(default)] + #[serde(rename = "LIVESTREAM_TITLE")] + title: String, + + /// Live stream art identifier. + /// + /// When available, this ID can be used to construct image URLs: + /// ```text + /// https://e-cdns-images.dzcdn.net/images/cover/{cover_id}/{resolution}x{resolution}.{format} + /// ``` + /// Where: + /// * `resolution` is the desired size in pixels (up to 1920) + /// * `format` is either: + /// - `jpg` for smaller file size + /// - `png` for higher quality + /// + /// Deezer's default format is 500x500.jpg + /// + /// Defaults to an empty string when no cover is available. + #[serde(default)] + #[serde(rename = "LIVESTREAM_IMAGE_MD5")] + live_stream_art: String, + + /// Live stream URLs. + /// + /// Contains a list of available stream URLs for different bitrates and codecs. + #[serde(rename = "LIVESTREAM_URLS")] + external_urls: LivestreamUrls, + + /// Live stream availability status. + /// + /// Indicates whether the live stream is currently available for playback. + #[serde(rename = "AVAILABLE")] + #[serde(default)] + #[serde(deserialize_with = "bool_from_string")] + available: bool, + }, +} + +/// Converts string "1"/"0" to boolean values. +/// +/// Used for fields that are boolean in logic but transmitted as strings: +/// * "1" -> true +/// * "0" -> false +/// * anything else -> error +/// +/// Used primarily for availability and stream type flags. +fn bool_from_string<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + match s.as_str() { + "1" => Ok(true), + "0" => Ok(false), + _ => Err(serde::de::Error::custom("invalid boolean string")), + } +} + +impl ListData { + /// Returns the type of this track. + /// + /// Returns a string identifier for the content type: + /// * "song" - Regular music track + /// * "episode" - Podcast episode + /// * "livestream" - Radio station + #[must_use] + pub const fn typ(&self) -> &'static str { + match self { + ListData::Song { .. } => "song", + ListData::Episode { .. } => "episode", + ListData::Livestream { .. } => "livestream", + } + } + + /// Returns the unique identifier for this content. + /// + /// IDs can be: + /// * Positive - Regular Deezer content + /// * Negative - User uploaded songs + #[must_use] + pub fn id(&self) -> TrackId { + match self { + ListData::Song { id, .. } + | ListData::Episode { id, .. } + | ListData::Livestream { id, .. } => *id, + } + } + + /// Returns the title of this track. + /// + /// Returns None for livestreams which only have a station name. + #[must_use] + pub fn title(&self) -> Option<&str> { + match self { + ListData::Song { title, .. } | ListData::Episode { title, .. } => Some(title.as_str()), + ListData::Livestream { .. } => None, + } + } + + /// Returns the artist of this track. + /// + /// Returns: + /// * Song artist for songs + /// * Podcast name for episodes + /// * Station name for livestreams + #[must_use] + pub fn artist(&self) -> &str { + match self { + ListData::Song { artist, .. } => artist.as_str(), + ListData::Episode { podcast_title, .. } => podcast_title.as_str(), + ListData::Livestream { title, .. } => title.as_str(), + } + } + + /// Returns the cover art identifier for this track. + /// + /// Returns: + /// * Album cover ID for songs + /// * Podcast artwork ID for episodes + /// * Station logo ID for livestreams + #[must_use] + pub fn cover_id(&self) -> &str { + match self { + ListData::Song { album_cover, .. } => album_cover, + ListData::Episode { podcast_art, .. } => podcast_art, + ListData::Livestream { + live_stream_art, .. + } => live_stream_art, + } + } + + /// Returns the duration of this track. + /// + /// Returns: + /// * Track duration for songs + /// * Episode duration for podcasts + /// * None for livestreams + #[must_use] + pub fn duration(&self) -> Option { + match self { + ListData::Song { duration, .. } | ListData::Episode { duration, .. } => Some(*duration), + ListData::Livestream { .. } => None, + } + } + + /// Returns the authentication token if required. + /// + /// Returns: + /// * Songs - Track token for encrypted content + /// * Episodes - Track token for Deezer CDN + /// * Livestreams - None (uses direct URLs) + #[must_use] + pub fn track_token(&self) -> Option<&str> { + match self { + ListData::Song { track_token, .. } | ListData::Episode { track_token, .. } => { + Some(track_token) + } + ListData::Livestream { .. } => None, + } + } + + /// Returns the expiration time for access token. + /// + /// Returns: + /// * Songs - Track token expiry + /// * Episodes - Track token expiry + /// * Livestreams - None (no token needed) + #[must_use] + pub fn expiry(&self) -> Option { + match self { + ListData::Song { expiry, .. } | ListData::Episode { expiry, .. } => Some(*expiry), + ListData::Livestream { .. } => None, + } + } +} + +/// Key-value mapping of bitrates to codec URLs. +/// +/// Keys are bitrate strings (e.g., "64", "128") +/// Values are codec-specific URLs for that bitrate +pub type LivestreamUrl = HashMap; + +/// Quality-based stream URL mapping. +/// +/// Maps bitrate strings to codec URLs: +/// ```json +/// { +/// "64": { "aac": "...", "mp3": "..." }, +/// "128": { "aac": "...", "mp3": "..." } +/// } +/// ``` +#[derive(Clone, PartialEq, Eq, Debug, Deserialize)] +#[serde(transparent)] +pub struct LivestreamUrls(#[serde(rename = "data")] pub LivestreamUrl); + +/// Provides access to the underlying URL mapping. +/// +/// Allows direct access to quality->codec->URL mappings while +/// maintaining type safety for livestream operations. +impl Deref for LivestreamUrls { + type Target = LivestreamUrl; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// URLs for different audio codecs of a livestream. +/// +/// Provides access to stream URLs for different audio formats: +/// * AAC - Advanced Audio Coding +/// * MP3 - MPEG Layer-3 +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Hash, Redact)] +#[redact(all)] +pub struct CodecUrl { + /// URL for AAC stream if available + pub aac: Option, + /// URL for MP3 stream if available + pub mp3: Option, +} diff --git a/src/protocol/gateway/list_data/songs.rs b/src/protocol/gateway/list_data/songs.rs new file mode 100644 index 0000000..ee4b692 --- /dev/null +++ b/src/protocol/gateway/list_data/songs.rs @@ -0,0 +1,133 @@ +//! Music track handling for Deezer's gateway API. +//! +//! Provides song-specific wrappers and types for: +//! * Track metadata (artist, album, title) +//! * Audio quality and encryption +//! * Volume normalization +//! * Content delivery +//! +//! Songs have specific features: +//! * Artist/album organization +//! * Volume normalization data +//! * Encrypted content delivery +//! * Quality selection +//! +//! # Wire Format +//! +//! Song response format: +//! ```json +//! { +//! "SNG_ID": "123456", +//! "ART_NAME": "Artist Name", +//! "ALB_TITLE": "Album Title", +//! "ALB_PICTURE": "album_cover_id", +//! "DURATION": "180", +//! "SNG_TITLE": "Track Title", +//! "GAIN": "-1.3", +//! "TRACK_TOKEN": "secret_token", +//! "TRACK_TOKEN_EXPIRE": "1234567890" +//! } +//! ``` +//! +//! Episode response format: +//! ```json +//! { +//! "EPISODE_ID": "123456", +//! "AVAILABLE": true, +//! "DURATION": "1800", +//! "EPISODE_TITLE": "Episode Title", +//! "SHOW_NAME": "Podcast Name", +//! "SHOW_ART_MD5": "cover_id", +//! "TRACK_TOKEN": "secret_token", +//! "TRACK_TOKEN_EXPIRE": "1234567890", +//! "EPISODE_DIRECT_STREAM_URL": "https://..." +//! } +//! ``` +//! +//! Livestream response format: +//! ```json +//! { +//! "LIVESTREAM_ID": "123456", +//! "LIVESTREAM_TITLE": "Station Name", +//! "LIVESTREAM_IMAGE_MD5": "cover_id", +//! "LIVESTREAM_URLS": { +//! "data": { +//! "64": { +//! "mp3": "https://...", +//! "aac": "https://..." +//! } +//! } +//! }, +//! "AVAILABLE": true +//! } +//! ``` + +use std::ops::Deref; + +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, DisplayFromStr}; + +use crate::track::TrackId; + +use super::{ListData, Method}; + +/// Gateway method name for retrieving songs. +/// +/// Returns detailed track data including: +/// * Song metadata +/// * Album information +/// * Authentication tokens +/// * Quality options +/// * Volume normalization +impl Method for SongData { + const METHOD: &'static str = "song.getListData"; +} + +/// Wrapper for song data. +/// +/// Contains the same track information as [`ListData`] but specifically +/// for music songs. The wrapper allows specialized handling while +/// reusing the underlying data structure. +#[derive(Clone, PartialEq, Deserialize, Debug)] +#[serde(transparent)] +pub struct SongData(pub ListData); + +/// Provides access to the underlying song data. +/// +/// Allows transparent access to the song fields while maintaining +/// type safety for song-specific operations. +impl Deref for SongData { + type Target = ListData; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// Request parameters for track list data. +/// +/// Used to request information for multiple tracks in a single query. +/// Supports different content types through enum variants. +/// +/// # Example +/// +/// ```rust +/// use deezer::gateway::{Request, TrackId}; +/// +/// let request = Request::Songs { +/// song_ids: vec![123456.into(), 789012.into()], +/// }; +/// ``` +#[serde_as] +#[derive(Clone, Eq, PartialEq, Serialize, Debug, Hash)] +pub struct Request { + /// List of track IDs to fetch information for. + /// + /// Each ID must be: + /// * Non-zero + /// * Either positive (Deezer tracks) or negative (user uploads) + /// * Valid within Deezer's catalog + #[serde(rename = "sng_ids")] + #[serde_as(as = "Vec")] + pub song_ids: Vec, +} diff --git a/src/protocol/gateway/mod.rs b/src/protocol/gateway/mod.rs index 10e7fd2..32ee117 100644 --- a/src/protocol/gateway/mod.rs +++ b/src/protocol/gateway/mod.rs @@ -7,6 +7,11 @@ //! * Content listings ([`list_data`]) //! * Radio stations ([`user_radio`]) //! +//! Supports multiple content types: +//! * Songs - Regular music tracks +//! * Episodes - Podcast episodes +//! * Livestreams - Radio stations (future) +//! //! # Number Handling //! //! All numeric values are stored as 64-bit integers because the JSON protocol @@ -39,11 +44,14 @@ pub mod user_data; pub mod user_radio; pub use arl::Arl; -pub use list_data::{ListData, Queue}; +pub use list_data::{ + episodes, livestream, songs, EpisodeData, ListData, LivestreamData, LivestreamUrl, + LivestreamUrls, Queue, SongData, +}; pub use user_data::{MediaUrl, UserData}; pub use user_radio::UserRadio; -use std::{collections::HashMap, convert::Infallible, ops::Deref, str::FromStr}; +use std::collections::HashMap; use serde::Deserialize; use serde_with::serde_as; @@ -76,9 +84,14 @@ pub trait Method { /// Response from a Deezer gateway API endpoint. /// -/// Responses can be either paginated (with total counts and filtered results) -/// or unpaginated (direct result lists). Both formats include an error map -/// for API status information. +/// Can contain either: +/// * Regular content (songs, user uploads) +/// * Episodes (podcasts) +/// * Livestreams (radio) +/// +/// The response format varies by content type but always includes: +/// * Error information +/// * Results array or pagination /// /// # Response Formats /// @@ -180,6 +193,81 @@ impl Response { } } +/// Converts episode responses into list data responses. +/// +/// This allows episode data to be handled using the same infrastructure +/// as other content types while maintaining type safety for episode-specific +/// operations. +impl From> for Response { + fn from(response: Response) -> Self { + match response { + Response::Paginated { error, results } => { + let results = Paginated { + data: results.data.into_iter().map(|data| data.0).collect(), + count: results.count, + total: results.total, + filtered_count: results.filtered_count, + }; + Response::Paginated { error, results } + } + Response::Unpaginated { error, results } => Response::Unpaginated { + error, + results: results.into_iter().map(|data| data.0).collect(), + }, + } + } +} + +/// Converts episode responses into list data responses. +/// +/// This allows episode data to be handled using the same infrastructure +/// as other content types while maintaining type safety for episode-specific +/// operations. +impl From> for Response { + fn from(response: Response) -> Self { + match response { + Response::Paginated { error, results } => { + let results = Paginated { + data: results.data.into_iter().map(|data| data.0).collect(), + count: results.count, + total: results.total, + filtered_count: results.filtered_count, + }; + Response::Paginated { error, results } + } + Response::Unpaginated { error, results } => Response::Unpaginated { + error, + results: results.into_iter().map(|data| data.0).collect(), + }, + } + } +} + +/// Converts episode responses into list data responses. +/// +/// This allows episode data to be handled using the same infrastructure +/// as other content types while maintaining type safety for episode-specific +/// operations. +impl From> for Response { + fn from(response: Response) -> Self { + match response { + Response::Paginated { error, results } => { + let results = Paginated { + data: results.data.into_iter().map(|data| data.0).collect(), + count: results.count, + total: results.total, + filtered_count: results.filtered_count, + }; + Response::Paginated { error, results } + } + Response::Unpaginated { error, results } => Response::Unpaginated { + error, + results: results.into_iter().map(|data| data.0).collect(), + }, + } + } +} + /// Paginated result set from the Deezer gateway API. /// /// Contains both the actual data items and metadata about the total @@ -207,100 +295,3 @@ pub struct Paginated { /// Number of items matching applied filters pub filtered_count: u64, } - -/// String value that defaults to "UNKNOWN" when parsing fails. -/// -/// Used for API fields that might return unexpected or invalid values, -/// ensuring robust handling of responses while maintaining type safety. -/// -/// # Examples -/// -/// ```rust -/// use deezer::gateway::StringOrUnknown; -/// -/// // Normal string -/// let value: StringOrUnknown = "value".parse()?; -/// assert_eq!(&*value, "value"); -/// -/// // Default value -/// let unknown = StringOrUnknown::default(); -/// assert_eq!(&*unknown, "UNKNOWN"); -/// ``` -/// -/// # Deref Behavior -/// -/// Derefs to `String` for convenient access to string methods: -/// ```rust -/// use deezer::gateway::StringOrUnknown; -/// -/// let value = StringOrUnknown::default(); -/// assert_eq!(value.to_uppercase(), "UNKNOWN"); -/// ``` -#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Deserialize, Debug, Hash)] -pub struct StringOrUnknown(pub String); - -/// Provides read-only access to the underlying string. -/// -/// # Examples -/// -/// ```rust -/// use deezer::gateway::StringOrUnknown; -/// -/// let value = StringOrUnknown("test".to_string()); -/// assert_eq!(value.len(), 4); // Uses String's len() method -/// assert_eq!(&*value, "test"); // Direct access to string content -/// ``` -impl Deref for StringOrUnknown { - /// Target type for deref coercion. - /// - /// Allows `StringOrUnknown` to be used anywhere a `String` reference is expected. - type Target = String; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -/// Creates a `StringOrUnknown` from a string slice. -/// -/// Simply wraps the input in a new `String`. Cannot fail. -/// -/// # Examples -/// -/// ```rust -/// use std::str::FromStr; -/// use deezer::gateway::StringOrUnknown; -/// -/// let value = StringOrUnknown::from_str("test")?; -/// assert_eq!(&*value, "test"); -/// -/// // Also works with string literals -/// let value: StringOrUnknown = "test".parse()?; -/// assert_eq!(&*value, "test"); -/// ``` -impl FromStr for StringOrUnknown { - /// This implementation never fails, ensuring robust parsing. - type Err = Infallible; - - fn from_str(s: &str) -> Result { - Ok(Self(s.to_string())) - } -} - -/// Creates a new `StringOrUnknown` with the value "UNKNOWN". -/// -/// Used when a string value cannot be properly parsed or is missing. -/// -/// # Examples -/// -/// ```rust -/// use deezer::gateway::StringOrUnknown; -/// -/// let value = StringOrUnknown::default(); -/// assert_eq!(&*value, "UNKNOWN"); -/// ``` -impl Default for StringOrUnknown { - fn default() -> Self { - Self(String::from("UNKNOWN")) - } -} diff --git a/src/protocol/gateway/user_data.rs b/src/protocol/gateway/user_data.rs index 7b551b5..f2bb3dd 100644 --- a/src/protocol/gateway/user_data.rs +++ b/src/protocol/gateway/user_data.rs @@ -45,7 +45,7 @@ use veil::Redact; use crate::protocol::{self, connect::UserId}; -use super::{Method, StringOrUnknown}; +use super::Method; /// Gateway method name for retrieving user data. /// @@ -180,7 +180,7 @@ pub struct User { /// Display name (defaults to "UNKNOWN") #[serde(default)] #[serde(rename = "BLOG_NAME")] - pub name: StringOrUnknown, + pub name: String, /// License and device management #[serde(rename = "OPTIONS")] diff --git a/src/protocol/gateway/user_radio.rs b/src/protocol/gateway/user_radio.rs index f3eefeb..1a1d8d1 100644 --- a/src/protocol/gateway/user_radio.rs +++ b/src/protocol/gateway/user_radio.rs @@ -53,6 +53,7 @@ impl Method for UserRadio { /// for tracks provided by Deezer Flow. Each response contains multiple /// recommended tracks. #[derive(Clone, PartialEq, Deserialize, Debug)] +#[serde(transparent)] pub struct UserRadio(pub ListData); /// Provides access to the underlying track data. diff --git a/src/protocol/media.rs b/src/protocol/media.rs index 74872f6..a726f65 100644 --- a/src/protocol/media.rs +++ b/src/protocol/media.rs @@ -1,10 +1,41 @@ //! Media streaming types and formats for Deezer. //! //! This module handles media access requests and responses, including: -//! * Track streaming URLs +//! * Track/episode download URLs //! * Audio formats and quality levels //! * Content encryption //! * Access tokens and expiry +//! * External streaming URLs (podcasts) +//! +//! # Authentication +//! +//! Media access requires two types of tokens: +//! * License token - For general media access rights +//! * Track tokens - For specific content access +//! +//! Both tokens have expiration times and must be refreshed periodically. +//! +//! # Content Types +//! +//! Three main content categories: +//! * Regular tracks - Encrypted, quality selection, token auth +//! * External content - No encryption, direct URLs +//! * Previews - Short samples, usually no encryption +//! +//! Each type may have different authentication and delivery requirements. +//! +//! # Error Handling +//! +//! Media access can fail in several ways: +//! * Authentication errors (invalid/expired tokens) +//! * Availability errors (geo-restrictions, takedowns) +//! * Technical errors (network issues, invalid formats) +//! +//! Errors include both a code and human-readable message. +//! Common error codes: +//! * 404 - Content not found +//! * 403 - Access denied +//! * 429 - Too many requests //! //! # Wire Format //! @@ -56,29 +87,33 @@ use super::connect::AudioQuality; /// Used to request streaming URLs for tracks with specific /// format and encryption requirements. #[serde_as] -#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Serialize, Debug, Hash)] +#[derive(Clone, Eq, PartialEq, Serialize, Debug, Hash)] pub struct Request { - /// License authentication token + /// Authentication token for accessing licensed content pub license_token: String, - /// Requested media formats + /// List of requested media formats and types pub media: Vec, - /// Track-specific access tokens + /// Authentication tokens for specific tracks + /// One token per requested track pub track_tokens: Vec, } /// Media format request. /// -/// Specifies the type of media (full/preview) and desired -/// format/encryption combinations. +/// Specifies the desired media type (full/preview) and formats +/// with their encryption methods. Multiple format/cipher combinations +/// can be requested to handle fallback scenarios. #[serde_as] -#[derive(Clone, Default, Eq, PartialEq, Ord, PartialOrd, Serialize, Debug, Hash)] +#[derive(Clone, Default, Eq, PartialEq, Serialize, Debug, Hash)] pub struct Media { - /// Full track or preview clip + /// Content type requested (full track or preview) + /// Defaults to full track #[serde(default)] #[serde(rename = "type")] pub typ: Type, - /// Requested format and encryption combinations + /// List of format and encryption combinations to try + /// Ordered by preference (first is most preferred) #[serde(rename = "formats")] pub cipher_formats: Vec, } @@ -87,9 +122,7 @@ pub struct Media { /// /// Determines whether to return the full track or just /// a preview clip. -#[derive( - Copy, Clone, Default, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize, Debug, Hash, -)] +#[derive(Copy, Clone, Default, Eq, PartialEq, Deserialize, Serialize, Debug, Hash)] pub enum Type { /// Full-length track #[default] @@ -120,14 +153,26 @@ impl fmt::Display for Type { /// Format and encryption combination. /// /// Specifies both the audio format (quality level) and -/// encryption method for the content. +/// encryption method for the content. Used to request +/// specific quality/security combinations. +/// +/// # Examples +/// +/// ```rust +/// use deezer::protocol::media::{CipherFormat, Cipher, Format}; +/// +/// let format = CipherFormat { +/// cipher: Cipher::BF_CBC_STRIPE, +/// format: Format::MP3_320, +/// }; +/// ``` #[derive( Copy, Clone, Default, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize, Debug, Hash, )] pub struct CipherFormat { - /// Encryption method + /// Encryption method to use for content protection pub cipher: Cipher, - /// Audio format + /// Audio format and quality level requested pub format: Format, } @@ -137,10 +182,13 @@ pub struct CipherFormat { )] #[expect(non_camel_case_types)] pub enum Cipher { - /// Blowfish CBC with striping + /// Blowfish CBC encryption with data striping + /// Used for most protected content #[default] BF_CBC_STRIPE, + /// No encryption + /// Used for external content and previews NONE, } @@ -173,18 +221,33 @@ impl fmt::Display for Cipher { #[expect(non_camel_case_types)] #[repr(i64)] pub enum Format { - /// External source (-1) + /// External source hosted outside Deezer's CDN + /// Protocol ID: -1 EXTERNAL = -1, - /// FLAC lossless (9) + + /// Free Lossless Audio Codec + /// Highest quality, largest file size + /// Protocol ID: 9 FLAC = 9, - /// 64 kbps MP3 (10) + + /// MP3 at 64 kbps + /// Basic quality, smallest file size + /// Protocol ID: 10 MP3_64 = 10, - /// 128 kbps MP3 (1, default) + + /// MP3 at 128 kbps + /// Standard quality, balanced size + /// Protocol ID: 1 #[default] MP3_128 = 1, - /// 320 kbps MP3 (3) + + /// MP3 at 320 kbps + /// High quality, larger file size + /// Protocol ID: 3 MP3_320 = 3, - /// Other or unknown MP3 bitrate (0) + + /// MP3 with unknown or variable bitrate + /// Protocol ID: 0 MP3_MISC = 0, } @@ -239,23 +302,35 @@ impl From for AudioQuality { /// Media access response. /// -/// Contains either media URLs or error information. -#[derive(Clone, Default, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize, Debug, Hash)] +/// Contains either: +/// * Successful media access information with URLs and formats +/// * Error details when access fails +/// +/// Multiple media entries may be returned when requesting +/// multiple tracks or formats. +#[derive(Clone, Default, Eq, PartialEq, Deserialize, Serialize, Debug, Hash)] pub struct Response { - /// Collection of media data or errors from the server + /// List of media access results or errors + /// One entry per requested track pub data: Vec, } /// Response data variant. /// /// Can contain either media information or error details. -#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize, Debug, Hash)] +#[derive(Clone, Eq, PartialEq, Deserialize, Serialize, Debug, Hash)] #[serde(untagged)] pub enum Data { - /// Contains media information including URLs and format details - Media { media: Vec }, - /// Contains error information when media access fails - Errors { errors: Vec }, + /// Media information, including URLs, formats and validity periods + Media { + /// List of available media formats and sources + media: Vec, + }, + /// Error information when media access fails + Errors { + /// List of error details and codes + errors: Vec, + }, } /// Media access error. @@ -322,32 +397,37 @@ impl fmt::Display for Error { /// Contains all information needed to access a media file, /// including URLs, format, and validity period. #[serde_as] -#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize, Debug, Hash)] +#[derive(Clone, Eq, PartialEq, Deserialize, Serialize, Debug, Hash)] pub struct Medium { - /// Full track or preview + /// Type of media content (full track or preview) #[serde(default)] pub media_type: Type, - /// Encryption method + /// Content encryption configuration + /// Specifies the cipher type used to protect the content #[serde(default)] pub cipher: CipherType, - /// Audio format + /// Audio format and quality level + /// Indicates bitrate and codec for the content #[serde(default)] pub format: Format, - /// Available download sources + /// List of available download sources + /// Multiple sources may be provided for redundancy pub sources: Vec, - /// Start of validity period + /// Time before which content is not accessible + /// Used for release date restrictions #[serde(rename = "nbf")] - #[serde_as(as = "TimestampSeconds")] - pub not_before: SystemTime, + #[serde_as(as = "Option>")] + pub not_before: Option, - /// End of validity period + /// Time after which content becomes inaccessible + /// Used for token expiration and temporary access #[serde(rename = "exp")] - #[serde_as(as = "TimestampSeconds")] - pub expiry: SystemTime, + #[serde_as(as = "Option>")] + pub expiry: Option, } /// Encryption method wrapper for media content. @@ -386,14 +466,20 @@ pub struct CipherType { /// Media source information. /// -/// Contains the URL and provider for downloading media content. +/// Contains URL and provider information for content delivery: +/// * URLs are redacted in debug output for security +/// * Provider indicates delivery network (e.g., "cdn") +/// +/// Multiple sources may be available for redundancy. #[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize, Redact, Hash)] pub struct Source { - /// Download URL (redacted in debug output) + /// Download URL for the media content + /// Redacted in debug output for security #[redact] pub url: Url, - /// Content provider name (e.g., "cdn") + /// Content delivery provider identifier + /// Usually "cdn" for Deezer's content delivery network #[serde(default)] pub provider: String, } diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index b6189cc..385989f 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -44,10 +44,13 @@ //! of concerns between different protocol aspects. pub mod auth; +pub mod codec; pub mod connect; pub mod gateway; pub mod media; +pub use codec::Codec; + use crate::error::Result; use serde::Deserialize; use std::fmt::Debug; diff --git a/src/remote.rs b/src/remote.rs index 05e2b4f..d988c97 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -665,6 +665,7 @@ impl Client { /// # Arguments /// /// * `event` - Event to process + #[allow(clippy::too_many_lines)] async fn handle_event(&mut self, event: Event) { let mut command = self.hook.as_ref().map(Command::new); let track_id = self.player.track().map(Track::id); @@ -721,7 +722,7 @@ impl Client { AudioQuality::Lossless | AudioQuality::Unknown => track .file_size() .unwrap_or_default() - .checked_div(track.duration().as_secs()) + .checked_div(track.duration().unwrap_or_default().as_secs()) .map(|bytes| bytes * 8 / 1024), _ => quality.bitrate().map(|kbps| kbps as u64), }; @@ -740,16 +741,21 @@ impl Client { command .env("EVENT", "track_changed") + .env("TRACK_TYPE", shell_escape(&track.typ().to_string())) .env("TRACK_ID", shell_escape(&track.id().to_string())) - .env("TITLE", shell_escape(track.title())) .env("ARTIST", shell_escape(track.artist())) - .env("ALBUM_TITLE", shell_escape(track.album_title())) - .env("ALBUM_COVER", shell_escape(track.album_cover())) - .env( - "DURATION", - shell_escape(&track.duration().as_secs().to_string()), - ) + .env("COVER", shell_escape(track.cover_id())) .env("FORMAT", shell_escape(&format!("{codec} {bitrate}"))); + + if let Some(title) = track.title() { + command.env("TITLE", shell_escape(title)); + } + if let Some(album_title) = track.album_title() { + command.env("ALBUM_TITLE", shell_escape(album_title)); + } + if let Some(duration) = track.duration() { + command.env("DURATION", shell_escape(&duration.as_secs().to_string())); + } } } } @@ -1311,10 +1317,6 @@ impl Client { error!("live radio is not supported yet"); Vec::new() } - ContainerType::CONTAINER_TYPE_PODCAST => { - error!("podcasts are not supported yet"); - Vec::new() - } _ => { tokio::time::timeout(Self::NETWORK_TIMEOUT, self.gateway.list_to_queue(&list)) .await?? @@ -1873,7 +1875,7 @@ impl Client { message_id: crate::Uuid::fast_v4().to_string(), track: item, quality: track.quality(), - duration: track.duration(), + duration: track.duration().unwrap_or_default(), buffered: track.buffered(), progress: self.player.progress(), volume: self.player.volume(), diff --git a/src/track.rs b/src/track.rs index 91b7a4c..b474538 100644 --- a/src/track.rs +++ b/src/track.rs @@ -58,6 +58,7 @@ use std::{ fmt, num::NonZeroI64, + str::FromStr, sync::{Arc, Mutex, PoisonError}, time::{Duration, SystemTime}, }; @@ -68,6 +69,7 @@ use stream_download::{ }; use time::OffsetDateTime; use url::Url; +use veil::Redact; use crate::{ error::{Error, Result}, @@ -88,6 +90,53 @@ use crate::{ #[expect(clippy::module_name_repetitions)] pub type TrackId = NonZeroI64; +/// Type of track content. +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)] +#[expect(clippy::module_name_repetitions)] +pub enum TrackType { + /// Regular music track from Deezer catalog or user upload + #[default] + Song, + /// Podcast episode with external streaming + Episode, + /// Live radio station with multiple streams + Livestream, +} + +/// External streaming URL configuration. +#[derive(Clone, Redact, Eq, PartialEq)] +#[redact(all, variant)] +pub enum ExternalUrl { + /// Direct streaming URL (for episodes) + Direct(Url), + /// Multiple quality streams (for livestreams) + WithQuality(gateway::LivestreamUrls), +} + +/// Display implementation for track type. +impl fmt::Display for TrackType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Song => write!(f, "song"), + Self::Episode => write!(f, "episode"), + Self::Livestream => write!(f, "livestream"), + } + } +} + +impl FromStr for TrackType { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "song" => Ok(Self::Song), + "episode" => Ok(Self::Episode), + "livestream" => Ok(Self::Livestream), + _ => Err(Error::invalid_argument(format!("unknown track type: {s}"))), + } + } +} + /// Represents a Deezer track with metadata and download state. /// /// Combines track metadata (title, artist, etc) with download management @@ -105,41 +154,59 @@ pub type TrackId = NonZeroI64; /// ``` #[derive(Debug)] pub struct Track { - /// Unique identifier for the track. - /// Negative values indicate user-uploaded content. + /// Type of content (song, episode, or livestream) + typ: TrackType, + + /// Unique identifier for the track id: TrackId, - /// Authentication token specific to this track. - /// Required for media access requests. - track_token: String, + /// Authentication token for media access. + /// None for livestreams or when using external URLs. + track_token: Option, + + /// Whether content is served from external source + external: bool, + + /// External URL for direct streaming. + /// Used by episodes and livestreams. + external_url: Option, - /// Title of the track. - title: String, + /// Title of the content. + /// None for livestreams which only have station name. + title: Option, - /// Main artist name. + /// Content creator: + /// * Artist name for songs + /// * Show name for episodes + /// * Station name for livestreams artist: String, - /// Title of the album containing this track. - album_title: String, + /// Album title. Only available for songs. + album_title: Option, - /// Identifier for the album's cover artwork. - /// Used to construct cover image URLs. - album_cover: String, + /// Identifier for cover artwork: + /// * Album art for songs + /// * Show art for episodes + /// * Station logo for livestreams + cover_id: String, /// Replay gain value in decibels. /// Used for volume normalization if available. + /// Only available for songs, but not all songs have this value. gain: Option, /// When this track's access token expires. /// After this time, new tokens must be requested. - expiry: SystemTime, + /// Not available for livestreams. + expiry: Option, /// Current audio quality setting. - /// May be lower than requested if higher quality unavailable. + /// May be lower than requested if any higher quality was unavailable. quality: AudioQuality, /// Total duration of the track. - duration: Duration, + /// Not available for livestreams. + duration: Option, /// Amount of audio data downloaded and available for playback. /// Protected by mutex for concurrent access from download task. @@ -147,6 +214,7 @@ pub struct Track { /// Total size of the audio file in bytes. /// Available only after download begins. + /// Not available for livestreams. file_size: Option, /// Encryption cipher used for this track. @@ -156,6 +224,12 @@ pub struct Track { /// Handle to active download if any. /// None if download hasn't started or was reset. handle: Option, + + /// Whether the track is available for download. + /// Only available for podcasts and episodes. + /// Songs have this always set to `true`. + /// Note that the expiry time should be checked separately. + available: bool, } impl Track { @@ -180,11 +254,27 @@ impl Track { /// Returns the track duration. /// /// The duration represents the total playback time of the track. + /// No duration is available for livestreams. #[must_use] - pub fn duration(&self) -> Duration { + pub fn duration(&self) -> Option { self.duration } + /// Returns whether this content is accessible. + /// + /// Always true for songs. Episodes and livestreams may be + /// region-restricted or temporarily unavailable. + #[must_use] + pub fn available(&self) -> bool { + self.available + } + + /// Returns the track type. + #[must_use] + pub fn typ(&self) -> TrackType { + self.typ + } + /// Returns the track's replay gain value if available. /// /// Replay gain is used for volume normalization: @@ -198,8 +288,8 @@ impl Track { /// Returns the track title. #[must_use] - pub fn title(&self) -> &str { - &self.title + pub fn title(&self) -> Option<&str> { + self.title.as_deref() } /// Returns the track artist name. @@ -210,21 +300,21 @@ impl Track { /// Returns the album title for this track. #[must_use] - pub fn album_title(&self) -> &str { - &self.album_title + pub fn album_title(&self) -> Option<&str> { + self.album_title.as_deref() } - /// The ID of the album cover image. + /// The ID of the cover art. /// - /// This ID can be used to construct a URL for retrieving the album cover image. - /// Album covers are always square and available in various resolutions up to 1920x1920. + /// This ID can be used to construct a URL for retrieving the cover art. + /// Covers are always square and available in various resolutions up to 1920x1920. /// /// # URL Format /// ```text - /// https://e-cdns-images.dzcdn.net/images/cover/{album_cover}/{resolution}x{resolution}.{format} + /// https://e-cdns-images.dzcdn.net/images/cover/{cover_id}/{resolution}x{resolution}.{format} /// ``` /// where: - /// - `{album_cover}` is the ID returned by this method + /// - `{cover_id}` is the ID returned by this method /// - `{resolution}` is the desired resolution in pixels (e.g., 500) /// - `{format}` is either `jpg` or `png` /// @@ -238,8 +328,8 @@ impl Track { /// https://e-cdns-images.dzcdn.net/images/cover/f286f9e7dc818e181c37b944e2461101/500x500.jpg /// ``` #[must_use] - pub fn album_cover(&self) -> &str { - &self.album_cover + pub fn cover_id(&self) -> &str { + &self.cover_id } /// Returns the track's expiration time. @@ -247,7 +337,7 @@ impl Track { /// After this time, the track becomes unavailable for download /// and may need token refresh. #[must_use] - pub fn expiry(&self) -> SystemTime { + pub fn expiry(&self) -> Option { self.expiry } @@ -288,9 +378,10 @@ impl Track { self.cipher != Cipher::NONE } - /// Returns whether this track uses lossless audio encoding. + /// Returns whether the track is lossless audio. /// - /// True only for FLAC encoded tracks. + /// True only for FLAC encoded songs. Episodes and livestreams + /// are never lossless. #[must_use] pub fn is_lossless(&self) -> bool { self.quality == AudioQuality::Lossless @@ -390,26 +481,68 @@ impl Track { quality: AudioQuality, license_token: impl Into, ) -> Result { - if self.expiry <= SystemTime::now() { + if !self.available() { return Err(Error::unavailable(format!( - "track {self} no longer available since {}", - OffsetDateTime::from(self.expiry) + "{} {self} is not available for download", + self.typ ))); } + if let Some(expiry) = self.expiry { + if expiry <= SystemTime::now() { + return Err(Error::unavailable(format!( + "{} {self} has expired since {}", + self.typ, + OffsetDateTime::from(expiry) + ))); + } + } + + if self.external { + let external_url = self.external_url.as_ref().ok_or_else(|| { + Error::unavailable(format!("external {} {self} has no urls", self.typ)) + })?; + + let url = match external_url { + ExternalUrl::Direct(url) => url.clone(), + ExternalUrl::WithQuality(_) => todo!(), + }; + + let source = media::Source { + url, + provider: String::default(), + }; + + return Ok(Medium { + format: Format::EXTERNAL, + cipher: media::CipherType { typ: Cipher::NONE }, + sources: vec![source], + not_before: None, + expiry: None, + media_type: media::Type::FULL, + }); + } + + let track_token = self.track_token.as_ref().ok_or_else(|| { + Error::permission_denied(format!("{} {self} does not have a track token", self.typ)) + })?; + let cipher_formats = match quality { AudioQuality::Basic => Self::CIPHER_FORMATS_MP3_64.to_vec(), AudioQuality::Standard => Self::CIPHER_FORMATS_MP3_128.to_vec(), AudioQuality::High => Self::CIPHER_FORMATS_MP3_320.to_vec(), AudioQuality::Lossless => Self::CIPHER_FORMATS_FLAC.to_vec(), AudioQuality::Unknown => { - return Err(Error::unknown("unknown audio quality for track {self}")); + return Err(Error::unknown(format!( + "unknown audio quality for {} {self}", + self.typ + ))); } }; let request = media::Request { license_token: license_token.into(), - track_tokens: vec![self.track_token.clone()], + track_tokens: vec![track_token.into()], media: vec![media::Media { typ: media::Type::FULL, cipher_formats, @@ -430,16 +563,21 @@ impl Track { let result = match result.data.first() { Some(data) => match data { Data::Media { media } => media.first().cloned().ok_or(Error::not_found( - format!("empty media data for track {self}"), + format!("empty media data for {} {self}", self.typ), ))?, Data::Errors { errors } => { return Err(Error::unavailable(errors.first().map_or_else( - || format!("unknown error getting media for track {self}"), + || format!("unknown error getting media for {} {self}", self.typ), ToString::to_string, ))); } }, - None => return Err(Error::not_found(format!("no media data for track {self}"))), + None => { + return Err(Error::not_found(format!( + "no media data for {} {self}", + self.typ + ))) + } }; let available_quality = AudioQuality::from(result.format); @@ -448,8 +586,8 @@ impl Track { // based on the bitrate, but the official client does not do this either. if !self.is_user_uploaded() && quality != available_quality { warn!( - "requested track {self} in {}, but got {}", - quality, available_quality + "requested {} {self} in {}, but got {}", + self.typ, quality, available_quality ); } @@ -458,28 +596,30 @@ impl Track { /// Returns whether this is a user-uploaded track. /// - /// User-uploaded tracks are identified by negative IDs and may - /// have different availability and quality characteristics. + /// User uploads are identified by negative IDs and only + /// available for songs. #[must_use] pub fn is_user_uploaded(&self) -> bool { self.id.is_negative() } - /// Opens a stream for downloading the track content. + /// Opens a stream for downloading or streaming content. /// - /// Attempts to open the first available source URL, falling back - /// to alternatives if needed. + /// Behavior varies by content type: + /// * Songs - Downloads encrypted content + /// * Episodes - Opens direct stream + /// * Livestreams - Opens selected quality stream /// /// # Arguments /// - /// * `client` - HTTP client for making requests + /// * `client` - HTTP client for requests /// * `medium` - Media source information /// /// # Errors /// /// Returns error if: /// * No valid sources available - /// * Track expired or not yet available + /// * Content unavailable in region /// * Network error occurs async fn open_stream( &self, @@ -487,7 +627,8 @@ impl Track { medium: &Medium, ) -> Result> { let mut result = Err(Error::unavailable(format!( - "no valid sources found for track {self}" + "no valid sources found for {} {self}", + self.typ ))); let now = SystemTime::now(); @@ -498,7 +639,7 @@ impl Track { for source in &medium.sources { // URLs can theoretically be non-HTTP, and we only support HTTP(S) URLs. let Some(host_str) = source.url.host_str() else { - warn!("skipping source with invalid host for track {self}"); + warn!("skipping source with invalid host for {} {self}", self.typ); continue; }; @@ -506,30 +647,39 @@ impl Track { // If not, it can be that the download link expired and needs to be // refreshed, that the track is not available yet, or that the track is // no longer available. - if medium.not_before > now { - warn!( - "track {self} is not available for download until {} from {host_str}", - OffsetDateTime::from(medium.not_before) - ); - continue; + if let Some(not_before) = medium.not_before { + if not_before > now { + warn!( + "{} {self} is not available for download until {} from {host_str}", + self.typ, + OffsetDateTime::from(not_before) + ); + continue; + } } - if medium.expiry <= now { - warn!( - "track {self} is no longer available for download since {} from {host_str}", - OffsetDateTime::from(medium.expiry) - ); - continue; + if let Some(expiry) = medium.expiry { + if expiry <= now { + warn!( + "{} {self} is no longer available for download since {} from {host_str}", + self.typ, + OffsetDateTime::from(expiry) + ); + continue; + } } // Perform the request and stream the response. match HttpStream::new(client.unlimited.clone(), source.url.clone()).await { Ok(http_stream) => { - debug!("starting download of track {self} from {host_str}"); + debug!("starting download of {} {self} from {host_str}", self.typ); result = Ok(http_stream); break; } Err(err) => { - warn!("failed to start download of track {self} from {host_str}: {err}",); + warn!( + "failed to start download of {} {self} from {host_str}: {err}", + self.typ + ); continue; } }; @@ -584,47 +734,51 @@ impl Track { // Calculate the prefetch size based on the audio quality. This assumes // that the track is encoded with a constant bitrate, which is not // necessarily true. However, it is a good approximation. - let mut prefetch_size = None; + let mut prefetch_size = Self::PREFETCH_DEFAULT as u64; if let Some(file_size) = stream.content_length() { - info!("downloading {file_size} bytes for track {self}"); + info!("downloading {file_size} bytes for {} {self}", self.typ); self.file_size = Some(file_size); - if !self.duration.is_zero() { - let size = Self::PREFETCH_LENGTH.as_secs() - * file_size.saturating_div(self.duration.as_secs()); - trace!("prefetch size for track {self}: {size} bytes"); - prefetch_size = Some(size); + if let Some(duration) = self.duration { + if !duration.is_zero() { + let size = Self::PREFETCH_LENGTH.as_secs() + * file_size.saturating_div(duration.as_secs()); + trace!("prefetch size for {} {self}: {size} bytes", self.typ); + prefetch_size = size; + } } } else { - info!("downloading track {self} with unknown file size"); + info!("downloading {} {self} with unknown file size", self.typ); }; - let prefetch_size = prefetch_size.unwrap_or(Self::PREFETCH_DEFAULT as u64); // A progress callback that logs the download progress. let track_str = self.to_string(); + let track_typ = self.typ.to_string(); let duration = self.duration; let buffered = Arc::clone(&self.buffered); let callback = move |stream: &HttpStream<_>, stream_state: StreamState, _: &tokio_util::sync::CancellationToken| { - if stream_state.phase == StreamPhase::Complete { - info!("completed download of track {track_str}"); - - // Prevent rounding errors and set the buffered duration - // equal to the total duration. It's OK to unwrap here: if - // the mutex is poisoned, then the main thread panicked and - // we should propagate the error. - *buffered.lock().unwrap() = duration; - } else if let Some(file_size) = stream.content_length() { - if file_size > 0 { - // `f64` not for precision, but to be able to fit - // as big as possible file sizes. - // TODO : use `Percentage` type - #[expect(clippy::cast_precision_loss)] - let progress = stream_state.current_position as f64 / file_size as f64; - - // OK to unwrap: see rationale above. - *buffered.lock().unwrap() = duration.mul_f64(progress); + if let Some(duration) = duration { + if stream_state.phase == StreamPhase::Complete { + info!("completed download of {track_typ} {track_str}"); + + // Prevent rounding errors and set the buffered duration + // equal to the total duration. It's OK to unwrap here: if + // the mutex is poisoned, then the main thread panicked and + // we should propagate the error. + *buffered.lock().unwrap() = duration; + } else if let Some(file_size) = stream.content_length() { + if file_size > 0 { + // `f64` not for precision, but to be able to fit + // as big as possible file sizes. + // TODO : use `Percentage` type + #[expect(clippy::cast_precision_loss)] + let progress = stream_state.current_position as f64 / file_size as f64; + + // OK to unwrap: see rationale above. + *buffered.lock().unwrap() = duration.mul_f64(progress); + } } } }; @@ -645,9 +799,11 @@ impl Track { Ok(download) } - /// Returns a handle to the track's download if active. + /// Returns the current download handle if active. /// - /// Returns None if download hasn't started. + /// Returns None if: + /// * Download hasn't started + /// * Download was reset #[must_use] pub fn handle(&self) -> Option { self.handle.clone() @@ -657,9 +813,12 @@ impl Track { /// /// A track is complete when the buffered duration equals /// the total track duration. + /// + /// Livestreams are never complete. #[must_use] pub fn is_complete(&self) -> bool { - self.buffered().as_secs() == self.duration.as_secs() + self.duration + .is_some_and(|duration| self.buffered() == duration) } /// Resets the track's download state. @@ -693,34 +852,77 @@ impl Track { /// Creates a Track from gateway list data. /// /// Initializes track with: -/// * Basic metadata (ID, title, artist, etc) +/// * Content type-specific fields /// * Default quality (Standard) /// * Default cipher (`BF_CBC_STRIPE`) /// * Empty download state +/// +/// Content types are handled differently: +/// * Songs - Uses artist/album metadata +/// * Episodes - Uses show/podcast metadata and external URLs +/// * Livestreams - Uses station metadata and quality streams impl From for Track { fn from(item: gateway::ListData) -> Self { + let (gain, album_title) = if let gateway::ListData::Song { + gain, album_title, .. + } = &item + { + (gain.as_ref(), Some(album_title)) + } else { + (None, None) + }; + + let (available, external, external_url) = match &item { + gateway::ListData::Song { .. } => (true, false, None), + gateway::ListData::Episode { + available, + external, + external_url, + .. + } => ( + *available, + *external, + external_url.clone().map(ExternalUrl::Direct), + ), + gateway::ListData::Livestream { + available, + external_urls, + .. + } => ( + *available, + true, + Some(ExternalUrl::WithQuality(external_urls.clone())), + ), + }; + Self { - id: item.track_id, - track_token: item.track_token, - title: item.title.to_string(), - artist: item.artist.to_string(), - album_title: item.album_title.to_string(), - album_cover: item.album_cover, - duration: item.duration, - gain: item.gain.map(ToF32::to_f32_lossy), - expiry: item.expiry, + typ: item.typ().parse().unwrap_or_default(), + id: item.id(), + track_token: item.track_token().map(ToOwned::to_owned), + title: item.title().map(ToOwned::to_owned), + artist: item.artist().to_owned(), + album_title: album_title.map(ToString::to_string), + cover_id: item.cover_id().to_owned(), + duration: item.duration(), + gain: gain.map(|gain| gain.to_f32_lossy()), + expiry: item.expiry(), quality: AudioQuality::Standard, buffered: Arc::new(Mutex::new(Duration::ZERO)), file_size: None, cipher: Cipher::BF_CBC_STRIPE, handle: None, + available, + external, + external_url, } } } -/// Formats track for display, showing ID, artist and title. +/// Formats track for display, showing ID, artist and title if available. /// -/// Format: "{id}: "{artist} - {title}"" +/// Format varies by content type: +/// * Songs/Episodes: "{id}: "{artist} - {title}"" +/// * Livestreams: "{id}: "{station}"" /// /// # Example /// @@ -729,6 +931,11 @@ impl From for Track { /// ``` impl fmt::Display for Track { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}: \"{} - {}\"", self.id, self.artist, self.title) + let artist = self.artist(); + if let Some(title) = &self.title() { + write!(f, "{}: \"{} - {}\"", self.id, artist, title) + } else { + write!(f, "{}: \"{}\"", self.id, artist) + } } }