//! Module d'accès au catalogue Qobuz (albums, tracks, artistes, playlists) use super::QobuzApi; use crate::error::{QobuzError, Result}; use crate::models::*; use serde::Deserialize; use tracing::debug; /// Réponse paginée de l'API #[derive(Debug, Deserialize)] struct PaginatedResponse { items: Vec, #[serde(default)] total: Option, #[serde(default)] limit: Option, #[serde(default)] offset: Option, } /// Réponse de l'endpoint /album/get #[derive(Debug, Deserialize)] pub(crate) struct AlbumResponse { #[serde(deserialize_with = "crate::models::deserialize_id")] id: String, title: String, artist: ArtistResponse, #[serde(default)] tracks_count: Option, #[serde(default)] duration: Option, #[serde(default)] release_date_original: Option, #[serde(default)] image: Option, #[serde(default = "default_streamable")] streamable: bool, #[serde(default)] description: Option, #[serde(default)] maximum_sampling_rate: Option, #[serde(default)] maximum_bit_depth: Option, #[serde(default)] genre: Option, #[serde(default)] label: Option, #[serde(default)] tracks: Option>, } /// Réponse de l'endpoint /track/get #[derive(Debug, Deserialize)] pub(crate) struct TrackResponse { #[serde(deserialize_with = "crate::models::deserialize_id")] id: String, title: String, #[serde(default)] performer: Option, #[serde(default)] artist: Option, #[serde(default)] album: Option, duration: u32, track_number: u32, media_number: u32, #[serde(default = "default_streamable")] streamable: bool, } /// Réponse artiste #[derive(Debug, Deserialize)] pub(crate) struct ArtistResponse { #[serde(deserialize_with = "crate::models::deserialize_id")] id: String, name: String, #[serde(default)] image: Option, #[serde(default)] albums: Option>, } /// Réponse image #[derive(Debug, Deserialize)] struct ImageResponse { #[serde(default)] large: Option, } /// Réponse genre #[derive(Debug, Deserialize)] struct GenreResponse { #[serde(default)] id: Option, name: String, } /// Réponse label #[derive(Debug, Deserialize)] struct LabelResponse { name: String, } /// Réponse playlist #[derive(Debug, Deserialize)] pub(crate) struct PlaylistResponse { #[serde(deserialize_with = "crate::models::deserialize_id")] id: String, name: String, #[serde(default)] description: Option, #[serde(default)] tracks_count: Option, #[serde(default)] duration: Option, #[serde(default)] images300: Option>, #[serde(default)] is_public: bool, #[serde(default)] owner: Option, #[serde(default)] tracks: Option>, } /// Réponse propriétaire #[derive(Debug, Deserialize)] struct OwnerResponse { #[serde(deserialize_with = "crate::models::deserialize_id")] id: String, name: String, } /// Réponse genres list #[derive(Debug, Deserialize)] struct GenresResponse { genres: PaginatedResponse, } /// Réponse albums featured #[derive(Debug, Deserialize)] struct FeaturedAlbumsResponse { albums: PaginatedResponse, } /// Réponse playlists featured #[derive(Debug, Deserialize)] struct FeaturedPlaylistsResponse { playlists: PaginatedResponse, } /// Réponse search #[derive(Debug, Deserialize)] struct SearchResponse { #[serde(default)] albums: Option>, #[serde(default)] artists: Option>, #[serde(default)] tracks: Option>, #[serde(default)] playlists: Option>, } /// Réponse track file URL #[derive(Debug, Deserialize)] struct FileUrlResponse { url: String, mime_type: String, sampling_rate: f64, bit_depth: u32, format_id: u8, } fn default_streamable() -> bool { true } impl QobuzApi { /// Récupère les détails d'un album pub async fn get_album(&self, album_id: &str) -> Result { debug!("Fetching album {}", album_id); let params = [("album_id", album_id)]; let response: AlbumResponse = self.get("/album/get", ¶ms).await?; Ok(Self::parse_album(response)) } /// Récupère les tracks d'un album pub async fn get_album_tracks(&self, album_id: &str) -> Result> { debug!("Fetching tracks for album {}", album_id); let params = [("album_id", album_id)]; let mut response: AlbumResponse = self.get("/album/get", ¶ms).await?; if let Some(tracks) = response.tracks.take() { let album = Self::parse_album(response); Ok(tracks .items .into_iter() .map(|t| Self::parse_track(t, Some(album.clone()))) .collect()) } else { Ok(Vec::new()) } } /// Récupère les détails d'une track pub async fn get_track(&self, track_id: &str) -> Result { debug!("Fetching track {}", track_id); let params = [("track_id", track_id)]; let response: TrackResponse = self.get("/track/get", ¶ms).await?; Ok(Self::parse_track(response, None)) } /// Récupère l'URL de streaming d'une track /// /// Cette méthode nécessite un secret s4 pour signer la requête. /// Si aucun secret n'est configuré, retourne une erreur. /// /// # Errors /// /// Retourne `QobuzError::Configuration` si le secret n'est pas configuré. pub async fn get_file_url(&self, track_id: &str) -> Result { use super::signing; debug!("Fetching file URL for track {}", track_id); // Vérifier que le secret est disponible let secret = self.secret().ok_or_else(|| { QobuzError::Configuration( "Secret not configured. Cannot sign track/getFileUrl request.".to_string(), ) })?; let format_id = self.format_id.id().to_string(); let intent = "stream"; let timestamp = signing::get_timestamp(); // Vérifier que le token d'authentification est disponible self.auth_token() .ok_or_else(|| QobuzError::Unauthorized("Missing auth token".to_string()))?; // Signer la requête (comme Python: track_getFileUrl) let signature = signing::sign_track_get_file_url(&format_id, intent, track_id, ×tamp, &secret); debug!( "Signing track/getFileUrl: track_id={}, format_id={}, ts={}", track_id, format_id, timestamp ); // Construire les paramètres signés // Note: app_id et user_auth_token sont envoyés automatiquement comme headers // par la méthode request() (X-App-Id et X-User-Auth-Token) // IMPORTANT: L'ordre doit correspondre EXACTEMENT à Python (raw.py) let params = [ ("format_id", format_id.as_str()), ("intent", intent), ("request_ts", timestamp.as_str()), ("request_sig", signature.as_str()), ("track_id", track_id), ]; // Utiliser GET (comme qobuz-player-client qui fonctionne) let response: FileUrlResponse = self.get("/track/getFileUrl", ¶ms).await?; Ok(StreamInfo { url: response.url, mime_type: response.mime_type, sampling_rate: response.sampling_rate, bit_depth: response.bit_depth, format_id: response.format_id, expires_at: chrono::Utc::now() + chrono::Duration::minutes(5), }) } /// Récupère les albums d'un artiste pub async fn get_artist_albums(&self, artist_id: &str) -> Result> { debug!("Fetching albums for artist {}", artist_id); let params = [("artist_id", artist_id), ("extra", "albums")]; let response: ArtistResponse = self.get("/artist/get", ¶ms).await?; if let Some(albums) = response.albums { Ok(albums .items .into_iter() .map(Self::parse_album) .filter(|a| a.streamable) .collect()) } else { Ok(Vec::new()) } } /// Récupère les artistes similaires pub async fn get_similar_artists(&self, artist_id: &str) -> Result> { debug!("Fetching similar artists for {}", artist_id); let params = [("artist_id", artist_id)]; #[derive(Debug, Deserialize)] struct SimilarArtistsResponse { artists: PaginatedResponse, } let response: SimilarArtistsResponse = self.get("/artist/getSimilarArtists", ¶ms).await?; Ok(response .artists .items .into_iter() .map(Self::parse_artist) .collect()) } /// Récupère les détails d'une playlist pub async fn get_playlist(&self, playlist_id: &str) -> Result { debug!("Fetching playlist {}", playlist_id); let params = [("playlist_id", playlist_id)]; let response: PlaylistResponse = self.get("/playlist/get", ¶ms).await?; Ok(Self::parse_playlist(response)) } /// Récupère les tracks d'une playlist pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result> { debug!("Fetching tracks for playlist {}", playlist_id); let params = [("playlist_id", playlist_id), ("extra", "tracks")]; let response: PlaylistResponse = self.get("/playlist/get", ¶ms).await?; if let Some(tracks) = response.tracks { Ok(tracks .items .into_iter() .map(|t| Self::parse_track(t, None)) .collect()) } else { Ok(Vec::new()) } } /// Récupère la liste des genres pub async fn get_genres(&self) -> Result> { debug!("Fetching genres"); let response: GenresResponse = self.get("/genre/list", &[]).await?; Ok(response .genres .items .into_iter() .map(Self::parse_genre) .collect()) } /// Récupère les albums featured (nouveautés, éditeur, etc.) pub async fn get_featured_albums( &self, genre_id: Option<&str>, type_: &str, ) -> Result> { debug!("Fetching featured albums (type: {})", type_); let mut params = vec![("type", type_), ("limit", "100")]; if let Some(gid) = genre_id { params.push(("genre_ids", gid)); } let response: FeaturedAlbumsResponse = self.get("/album/getFeatured", ¶ms).await?; Ok(response .albums .items .into_iter() .map(Self::parse_album) .filter(|a| a.streamable) .collect()) } /// Récupère les playlists featured pub async fn get_featured_playlists( &self, genre_id: Option<&str>, tags: Option<&str>, ) -> Result> { debug!("Fetching featured playlists"); let mut params = vec![("type", "editor-picks"), ("limit", "100")]; if let Some(gid) = genre_id { params.push(("genre_ids", gid)); } if let Some(t) = tags { params.push(("tags", t)); } let response: FeaturedPlaylistsResponse = self.get("/playlist/getFeatured", ¶ms).await?; Ok(response .playlists .items .into_iter() .map(Self::parse_playlist) .collect()) } /// Recherche dans le catalogue pub async fn search(&self, query: &str, type_: Option<&str>) -> Result { debug!("Searching for '{}' (type: {:?})", query, type_); let mut params = vec![("query", query), ("limit", "200")]; if let Some(t) = type_ { params.push(("type", t)); } let response: SearchResponse = self.get("/catalog/search", ¶ms).await?; Ok(SearchResult { albums: response .albums .map(|a| { a.items .into_iter() .map(Self::parse_album) .filter(|album| album.streamable) .collect() }) .unwrap_or_default(), artists: response .artists .map(|a| a.items.into_iter().map(Self::parse_artist).collect()) .unwrap_or_default(), tracks: response .tracks .map(|t| { t.items .into_iter() .map(|track| Self::parse_track(track, None)) .filter(|track| track.streamable) .collect() }) .unwrap_or_default(), playlists: response .playlists .map(|p| p.items.into_iter().map(Self::parse_playlist).collect()) .unwrap_or_default(), }) } // Fonctions de parsing publiques (utilisées aussi par le module user) pub(crate) fn parse_album(response: AlbumResponse) -> Album { Album { id: response.id, title: response.title, artist: Self::parse_artist(response.artist), tracks_count: response.tracks_count, duration: response.duration, release_date: response.release_date_original, image: response.image.and_then(|i| i.large), image_cached: None, streamable: response.streamable, description: response.description, maximum_sampling_rate: response.maximum_sampling_rate, maximum_bit_depth: response.maximum_bit_depth, genres: response.genre.map(|g| vec![g.name]).unwrap_or_default(), label: response.label.map(|l| l.name), } } pub(crate) fn parse_track(response: TrackResponse, album: Option) -> Track { let performer = response .performer .or(response.artist) .map(Self::parse_artist); let album = album.or_else(|| response.album.map(Self::parse_album)); Track { id: response.id, title: response.title, performer, album, duration: response.duration, track_number: response.track_number, media_number: response.media_number, streamable: response.streamable, mime_type: None, sample_rate: None, bit_depth: None, channels: None, } } pub(crate) fn parse_artist(response: ArtistResponse) -> Artist { Artist { id: response.id, name: response.name, image: response.image.and_then(|i| i.large), image_cached: None, } } pub(crate) fn parse_playlist(response: PlaylistResponse) -> Playlist { Playlist { id: response.id, name: response.name, description: response.description, tracks_count: response.tracks_count, duration: response.duration, image: response.images300.and_then(|imgs| imgs.first().cloned()), image_cached: None, is_public: response.is_public, owner: response.owner.map(|o| PlaylistOwner { id: o.id.parse().unwrap_or(0), name: o.name, }), } } pub(crate) fn parse_genre(response: GenreResponse) -> Genre { Genre { id: response.id, name: response.name, children: Vec::new(), } } }