//! Music source implementation for Qobuz //! //! This module implements the [`pmosource::MusicSource`] trait for Qobuz, //! providing a complete music catalog browsing and searching experience. use crate::client::QobuzClient; use crate::didl::{format_duration, ToDIDL}; use crate::lazy_provider::QobuzLazyProvider; use crate::models::Track; use pmoaudiocache::{AudioMetadata, Cache as AudioCache}; use pmocovers::Cache as CoverCache; use pmodidl::{Container, Item}; use pmosource::SourceCacheManager; use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result}; use serde_json::json; use std::sync::Arc; use std::time::SystemTime; /// TTL pour les playlists d'albums (7 jours) /// Trait pour les types dont on peut cacher la cover image. trait CoverCacheable { fn image_url(&self) -> Option<&str>; fn set_image_cached(&mut self, url: String); } impl CoverCacheable for crate::models::Album { fn image_url(&self) -> Option<&str> { self.image.as_deref() } fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); } } impl CoverCacheable for crate::models::Playlist { fn image_url(&self) -> Option<&str> { self.image.as_deref() } fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); } } impl CoverCacheable for crate::models::Artist { fn image_url(&self) -> Option<&str> { self.image.as_deref() } fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); } } /// Pour Track, la cover est celle de l'album. impl CoverCacheable for crate::models::Track { fn image_url(&self) -> Option<&str> { self.album.as_ref()?.image.as_deref() } fn set_image_cached(&mut self, url: String) { if let Some(ref mut album) = self.album { album.image_cached = Some(url); } } } /// Default image for Qobuz (300x300 WebP, embedded in binary) const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); /// Qobuz music source with full MusicSource trait implementation /// /// This struct combines a [`QobuzClient`] for API access with browsing and /// navigation capabilities, implementing the complete [`MusicSource`] trait. /// /// # Features /// /// - **Catalog Navigation**: Browse albums, artists, playlists, favorites /// - **Search**: Full-text search across the Qobuz catalog /// - **URI Resolution**: Resolves track streaming URIs with authentication /// - **DIDL-Lite Export**: Converts albums, tracks, and playlists to UPnP formats /// - **Caching**: Integrated with QobuzClient's cache for performance /// /// # Architecture /// /// Unlike streaming sources like Radio Paradise, Qobuz is a catalog-based source: /// - Root container has multiple sub-containers (Albums, Artists, Favorites, etc.) /// - No FIFO support (it's a static catalog, not a dynamic stream) /// - Hierarchical browsing: Root → Category → Albums → Tracks /// /// # Examples /// /// ```no_run /// use std::sync::Arc; /// use pmoaudiocache::cache as audio_cache; /// use pmocovers::cache as cover_cache; /// use pmoqobuz::{QobuzSource, QobuzClient}; /// use pmosource::MusicSource; /// /// #[tokio::main] /// async fn main() -> Result<(), Box> { /// let client = QobuzClient::from_config().await?; /// let cover_cache = Arc::new(cover_cache::new_cache("/tmp/qobuz_covers", 256)?); /// let audio_cache = Arc::new(audio_cache::new_cache("/tmp/qobuz_audio", 64)?); /// let source = QobuzSource::new(client, cover_cache, audio_cache, "http://localhost:8080"); /// /// println!("Source: {}", source.name()); /// println!("Supports FIFO: {}", source.supports_fifo()); /// /// // Browse root container /// let root = source.root_container().await?; /// println!("Root: {} with {} children", root.title, root.child_count.unwrap_or_default()); /// /// Ok(()) /// } /// ``` #[derive(Clone)] pub struct QobuzSource { inner: Arc, } struct QobuzSourceInner { /// Qobuz API client client: Arc, /// Cache manager (centralisé) cache_manager: SourceCacheManager, /// Base URL for streaming server (e.g., "http://192.168.0.138:8080") base_url: String, /// Update tracking update_counter: tokio::sync::RwLock, last_change: tokio::sync::RwLock, } impl std::fmt::Debug for QobuzSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("QobuzSource").finish() } } impl QobuzSource { /// Create a new Qobuz source from the cache registry /// /// This is the recommended way to create a source when using the UPnP server. /// The caches are automatically retrieved from the global registry. /// /// # Arguments /// /// * `client` - Authenticated Qobuz API client /// * `base_url` - Base URL for streaming server (e.g., "http://192.168.0.138:8080") /// /// # Errors /// /// Returns an error if the caches are not initialized in the registry #[cfg(feature = "server")] pub fn from_registry(client: QobuzClient, base_url: impl Into) -> Result { let cache_manager = SourceCacheManager::from_registry("qobuz".to_string())?; let client = Arc::new(client); cache_manager.register_lazy_provider(Arc::new(QobuzLazyProvider::new(client.clone()))); Ok(Self { inner: Arc::new(QobuzSourceInner { client, cache_manager, base_url: base_url.into(), update_counter: tokio::sync::RwLock::new(0), last_change: tokio::sync::RwLock::new(SystemTime::now()), }), }) } /// Create a new Qobuz source with explicit caches (for tests) /// /// # Arguments /// /// * `client` - Authenticated Qobuz API client /// * `cover_cache` - Cover image cache (required) /// * `audio_cache` - Audio cache (required) /// * `base_url` - Base URL for streaming server (e.g., "http://192.168.0.138:8080") pub fn new( client: QobuzClient, cover_cache: Arc, audio_cache: Arc, base_url: impl Into, ) -> Self { let cache_manager = SourceCacheManager::new("qobuz".to_string(), cover_cache, audio_cache); let client = Arc::new(client); cache_manager.register_lazy_provider(Arc::new(QobuzLazyProvider::new(client.clone()))); Self { inner: Arc::new(QobuzSourceInner { client, cache_manager, base_url: base_url.into(), update_counter: tokio::sync::RwLock::new(0), last_change: tokio::sync::RwLock::new(SystemTime::now()), }), } } /// Get the Qobuz client pub fn client(&self) -> &QobuzClient { &self.inner.client } /// Add a track from Qobuz with caching /// /// This method downloads and caches both cover art and audio data. pub async fn add_track(&self, track: &Track) -> Result { let track_id = format!("qobuz://track/{}", track.id); // Get streaming URL let stream_url = self .inner .client .get_stream_url(&track.id) .await .map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))?; // 1. Cache cover via manager let cached_cover_pk = if let Some(ref album) = track.album { if let Some(ref image_url) = album.image { self.inner.cache_manager.cache_cover(image_url).await.ok() } else { None } } else { None }; // 2. Prepare rich metadata from Qobuz track let metadata = AudioMetadata { title: Some(track.title.clone()), artist: track.performer.as_ref().map(|p| p.name.clone()), album: track.album.as_ref().map(|a| a.title.clone()), duration_secs: Some(track.duration as u64), year: track.album.as_ref().and_then(|a| { a.release_date .as_ref() .and_then(|d| d.split('-').next()?.parse().ok()) }), track_number: Some(track.track_number), track_total: track.album.as_ref().and_then(|a| a.tracks_count), disc_number: Some(track.media_number), disc_total: None, genre: track.album.as_ref().and_then(|a| { if !a.genres.is_empty() { Some(a.genres.join(", ")) } else { None } }), sample_rate: track.sample_rate, channels: track.channels, bitrate: None, conversion: None, }; // 3. Cache audio via manager let cached_audio_pk = self .inner .cache_manager .cache_audio(&stream_url, Some(metadata)) .await .ok(); if let (Some(audio_pk), Some(cover_pk)) = (&cached_audio_pk, &cached_cover_pk) { let _ = self.inner .cache_manager .set_audio_metadata(audio_pk, "cover_pk", json!(cover_pk)); } // 4. Store metadata self.inner .cache_manager .update_metadata( track_id.clone(), pmosource::TrackMetadata { original_uri: stream_url, cached_audio_pk, cached_cover_pk, }, ) .await; Ok(track_id) } /// Add track with lazy audio caching (cover eager, audio lazy) /// /// This method caches cover art immediately (small, needed for UI) but /// defers audio download until the track is actually played. /// /// # Arguments /// /// * `track` - The Qobuz track to add /// /// # Returns /// /// `(track_id, lazy_pk)` where `track_id` is the logical Qobuz URI and /// `lazy_pk` the cache identifier stored in pmocache. pub async fn add_track_lazy(&self, track: &Track) -> Result<(String, String)> { let track_id = format!("qobuz://track/{}", track.id); let lazy_pk = format!("QOBUZ:{}", track.id); // Get streaming URL (for metadata fallback) let stream_url = self .inner .client .get_stream_url(&track.id) .await .map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))?; // 1. Cache cover EAGERLY (small, UI needs it) let cached_cover_pk = if let Some(ref album) = track.album { if let Some(ref image_url) = album.image { self.inner.cache_manager.cache_cover(image_url).await.ok() } else { None } } else { None }; // 2. Prepare rich metadata from Qobuz track let metadata = AudioMetadata { title: Some(track.title.clone()), artist: track.performer.as_ref().map(|p| p.name.clone()), album: track.album.as_ref().map(|a| a.title.clone()), duration_secs: Some(track.duration as u64), year: track.album.as_ref().and_then(|a| { a.release_date .as_ref() .and_then(|d| d.split('-').next()?.parse().ok()) }), track_number: Some(track.track_number), track_total: track.album.as_ref().and_then(|a| a.tracks_count), disc_number: Some(track.media_number), disc_total: None, genre: track.album.as_ref().and_then(|a| { if !a.genres.is_empty() { Some(a.genres.join(", ")) } else { None } }), sample_rate: track.sample_rate, channels: track.channels, bitrate: None, conversion: None, }; // 3. Cache audio LAZILY avec un provider let cached_audio_pk = self .inner .cache_manager .cache_audio_lazy_with_provider( &lazy_pk, Some(metadata.clone()), cached_cover_pk.clone(), ) .await .map_err(|e| { MusicSourceError::CacheError(format!( "Failed to register lazy track {}: {}", track.title, e )) })?; if let Some(ref cover_pk) = cached_cover_pk { let _ = self.inner.cache_manager.set_audio_metadata( &cached_audio_pk, "cover_pk", json!(cover_pk), ); } // Stocker le track_id Qobuz pour reconstruction DIDL ultérieure let _ = self.inner.cache_manager.set_audio_metadata( &cached_audio_pk, "qobuz_track_id", json!(track.id), ); // 4. Store metadata self.inner .cache_manager .update_metadata( track_id.clone(), pmosource::TrackMetadata { original_uri: stream_url, cached_audio_pk: Some(cached_audio_pk.clone()), cached_cover_pk, }, ) .await; Ok((track_id, cached_audio_pk)) } /// Load full album into pmoplaylist with lazy audio /// /// This method fetches all tracks from a Qobuz album and adds them to a playlist /// with lazy audio loading. Covers are downloaded eagerly, audio lazily. /// /// # Arguments /// /// * `playlist_id` - ID of the target playlist /// * `album_id` - Qobuz album ID /// /// # Returns /// /// Number of tracks successfully added pub async fn add_album_to_playlist(&self, playlist_id: &str, album_id: &str) -> Result { use tracing::{debug, info, warn}; // 1. Get tracks from Qobuz (goes through rate limiter) let tracks = self .inner .client .get_album_tracks(album_id) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; if tracks.is_empty() { return Ok(0); } info!( "Adding album {} ({} tracks) to playlist {} with lazy audio", album_id, tracks.len(), playlist_id ); // 2. Add each track lazily + collect lazy PKs let mut lazy_pks = Vec::with_capacity(tracks.len()); for (i, track) in tracks.iter().enumerate() { match self.add_track_lazy(track).await { Ok((_track_id, lazy_pk)) => { debug!( "Track {}/{}: {} (lazy pk {})", i + 1, tracks.len(), track.title, &lazy_pk ); lazy_pks.push(lazy_pk); } Err(e) => { warn!("Failed to add track {} ({}): {}", i + 1, track.title, e); // Continue with other tracks } } } // 3. Batch insert into playlist (single DB transaction) let playlist_manager = pmoplaylist::PlaylistManager(); let writer = playlist_manager .get_persistent_write_handle(playlist_id.to_string()) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; writer .set_role(pmoplaylist::PlaylistRole::Album) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; writer .push_lazy_batch(lazy_pks.clone()) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; // 4. Enable lazy mode with lookahead of 2 tracks playlist_manager.enable_lazy_mode(playlist_id, 2); info!( "Album {} added: {}/{} tracks", album_id, lazy_pks.len(), tracks.len() ); Ok(lazy_pks.len()) } /// Load Qobuz playlist into pmoplaylist with lazy audio /// /// This method fetches all tracks from a Qobuz playlist and adds them to a pmoplaylist /// with lazy audio loading. Covers are downloaded eagerly, audio lazily. /// /// # Arguments /// /// * `playlist_id` - ID of the target pmoplaylist /// * `qobuz_playlist_id` - Qobuz playlist ID /// /// # Returns /// /// Number of tracks successfully added pub async fn add_qobuz_playlist_to_playlist( &self, playlist_id: &str, qobuz_playlist_id: &str, ) -> Result { use tracing::{debug, info, warn}; // 1. Get tracks from Qobuz playlist (goes through rate limiter) let tracks = self .inner .client .get_playlist_tracks(qobuz_playlist_id) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; if tracks.is_empty() { return Ok(0); } info!( "Adding Qobuz playlist {} ({} tracks) to pmoplaylist {} with lazy audio", qobuz_playlist_id, tracks.len(), playlist_id ); // 2. Add each track lazily + collect lazy PKs let mut lazy_pks = Vec::with_capacity(tracks.len()); for (i, track) in tracks.iter().enumerate() { match self.add_track_lazy(track).await { Ok((_track_id, lazy_pk)) => { debug!( "Track {}/{}: {} (lazy pk {})", i + 1, tracks.len(), track.title, &lazy_pk ); lazy_pks.push(lazy_pk); } Err(e) => { warn!("Failed to add track {} ({}): {}", i + 1, track.title, e); // Continue with other tracks } } } // 3. Batch insert into playlist (single DB transaction) let playlist_manager = pmoplaylist::PlaylistManager(); let writer = playlist_manager .get_persistent_write_handle(playlist_id.to_string()) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; writer .push_lazy_batch(lazy_pks.clone()) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; // 4. Enable lazy mode with lookahead of 2 tracks playlist_manager.enable_lazy_mode(playlist_id, 2); info!( "Qobuz playlist {} added: {}/{} tracks", qobuz_playlist_id, lazy_pks.len(), tracks.len() ); Ok(lazy_pks.len()) } /// Vérifie si une playlist d'album existe et est valide (non expirée ET non vide) /// Retourne la source_version d'une pmoplaylist si elle existe en mémoire ou en DB. async fn cached_source_version(&self, playlist_id: &str) -> Option { let playlist_manager = pmoplaylist::PlaylistManager(); if !playlist_manager.exists(playlist_id).await { return None; } playlist_manager .get_read_handle(playlist_id) .await .ok()? .source_version() .await } /// Adapte des Items DIDL issus d'une pmoplaylist pour le schéma UPnP Qobuz. /// /// - Résout l'ID de la track (`qobuz:track:`) depuis les métadonnées cache /// - Rend absolues les URLs relatives (resource + cover) /// - Affecte le `parent_id` fourni async fn adapt_items_to_qobuz( &self, items: Vec, parent_id: &str, ) -> Result> { use tracing::warn; let mut adapted = Vec::with_capacity(items.len()); for mut item in items { let cache_pk = item .resources .first() .and_then(|r| r.url.strip_prefix("/audio/flac/").map(|s| s.to_string())); if let Some(pk) = cache_pk { if let Ok(Some(track_id_value)) = self .inner .cache_manager .get_audio_metadata(&pk, "qobuz_track_id") { if let Some(track_id) = track_id_value.as_str() { item.id = format!("qobuz:track:{}", track_id); } else { warn!("qobuz_track_id not a string for {}", pk); } } else { warn!("No qobuz_track_id metadata for {}", pk); } if let Some(resource) = item.resources.first_mut() { if resource.url.starts_with('/') { resource.url = format!("{}{}", self.inner.base_url, resource.url); } } } if let Some(art) = item.album_art.as_mut() { if art.starts_with('/') { *art = format!("{}{}", self.inner.base_url, art); } } item.parent_id = parent_id.to_string(); adapted.push(item); } Ok(adapted) } /// Ouvre un WriteHandle sur une pmoplaylist persistante, en la créant si absente /// ou en la vidant (flush) si elle existe déjà. async fn get_or_flush_playlist_writer( &self, playlist_id: &str, role: pmoplaylist::PlaylistRole, ) -> Result { let playlist_manager = pmoplaylist::PlaylistManager(); if playlist_manager.exists(playlist_id).await { let writer = playlist_manager .get_persistent_write_handle(playlist_id.to_string()) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; writer .flush() .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; Ok(writer) } else { playlist_manager .create_persistent_playlist_with_role(playlist_id.to_string(), role) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string())) } } /// Met en cache la cover si l'URL est présente. async fn cache_cover_opt(&self, image_url: Option<&str>) -> Option { self.inner .cache_manager .cache_cover(image_url?) .await .ok() } /// Récupère ou crée une playlist lazy pour un album, avec invalidation par version. /// /// `source_version` = `"{released_at}_{tracks_count}"` — change uniquement en cas de /// réédition avec pistes supplémentaires. Cache valide à vie si la version correspond. async fn get_or_create_album_playlist_items( &self, album_id: &str, limit: usize, ) -> Result> { use tracing::{debug, info}; let playlist_id = format!("qobuz-album-{}", album_id); let playlist_manager = pmoplaylist::PlaylistManager(); let parent_id = format!("qobuz:album:{}", album_id); let cached_version = self.cached_source_version(&playlist_id).await; // Charger les métadonnées album (contient déjà les tracks) let album = self .inner .client .get_album(album_id) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; let album_version = Some(format!( "{}_{}", album.released_at.unwrap_or(0), album.tracks_count.unwrap_or(0) )); if cached_version.is_some() && cached_version == album_version { debug!("Album playlist {} cache valid (version {:?})", playlist_id, album_version); let reader = playlist_manager .get_read_handle(&playlist_id) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; let items = reader .to_items(limit) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; return self.adapt_items_to_qobuz(items, &parent_id).await; } info!("Album playlist {} creating/refreshing (version {:?})", playlist_id, album_version); let cover_pk = self.cache_cover_opt(album.image.as_deref()).await; let writer = self .get_or_flush_playlist_writer(&playlist_id, pmoplaylist::PlaylistRole::Album) .await?; writer .set_title(album.title.clone()) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; writer .set_artist(Some(album.artist.name.clone())) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; writer .set_source(Some("qobuz".to_string())) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; writer .set_source_version(album_version) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; if let Some(pk) = cover_pk { writer .set_cover_pk(Some(pk)) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; } // IMPORTANT: Libérer le write lock avant d'appeler add_album_to_playlist drop(writer); self.add_album_to_playlist(&playlist_id, album_id).await?; let reader = playlist_manager .get_read_handle(&playlist_id) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; let items = reader .to_items(limit) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; self.adapt_items_to_qobuz(items, &parent_id).await } /// Récupère ou crée une pmoplaylist pour une playlist Qobuz, avec invalidation par updated_at. /// /// - Si la playlist est en cache et que `updated_at` n'a pas changé → retourne le cache. /// - Sinon → recharge toutes les tracks depuis Qobuz et reconstruit la pmoplaylist. async fn get_or_create_qobuz_playlist_items( &self, qobuz_playlist_id: &str, ) -> Result> { use tracing::{debug, info, warn}; let pmo_playlist_id = format!("qobuz-playlist-{}", qobuz_playlist_id); let playlist_manager = pmoplaylist::PlaylistManager(); let cached_version = self.cached_source_version(&pmo_playlist_id).await; // Obtenir les métadonnées depuis Qobuz pour comparer updated_at let qobuz_meta: crate::models::Playlist = self .inner .client .get_playlist(qobuz_playlist_id) .await .map_err(|e: crate::error::QobuzError| MusicSourceError::BrowseError(e.to_string()))?; let qobuz_version = qobuz_meta.updated_at.map(|t: i64| t.to_string()); // Cache valide si version correspond let cache_valid = cached_version.is_some() && cached_version == qobuz_version; let parent_id = format!("qobuz:playlist:{}", qobuz_playlist_id); if cache_valid { debug!("Qobuz playlist {} cache valid (version {:?})", pmo_playlist_id, qobuz_version); let reader = playlist_manager .get_read_handle(&pmo_playlist_id) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; let (items, total) = reader .to_items_paged(0, usize::MAX) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; // Si des PKs ne sont pas enregistrés dans le cache audio (e.g. playlist créée // avec l'ancien code), on force un refresh pour les ré-enregistrer proprement. if items.len() == total { return self.adapt_items_to_qobuz(items, &parent_id).await; } info!( "Qobuz playlist {} has {}/{} valid items, forcing refresh to repair missing entries", pmo_playlist_id, items.len(), total ); } info!("Qobuz playlist {} creating/refreshing (version {:?})", pmo_playlist_id, qobuz_version); let tracks = self .inner .client .get_playlist_tracks(qobuz_playlist_id) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; let cover_pk = self.cache_cover_opt(qobuz_meta.image.as_deref()).await; let writer = self .get_or_flush_playlist_writer(&pmo_playlist_id, pmoplaylist::PlaylistRole::Source) .await?; writer .set_title(qobuz_meta.name.clone()) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; writer .set_source(Some("qobuz".to_string())) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; writer .set_source_version(qobuz_version) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; if let Some(pk) = cover_pk { writer .set_cover_pk(Some(pk)) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; } let lazy_pks = self.register_tracks_lazy(&tracks).await; let all_registered = lazy_pks.len() == tracks.len(); if !all_registered { warn!( "Qobuz playlist {}: only {}/{} tracks registered, will not mark cache as complete", qobuz_playlist_id, lazy_pks.len(), tracks.len() ); // Invalider la source_version pour forcer un refresh au prochain accès writer .set_source_version(None) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; } writer .push_lazy_batch(lazy_pks) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; drop(writer); let reader = playlist_manager .get_read_handle(&pmo_playlist_id) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; let (items, _total) = reader .to_items_paged(0, usize::MAX) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; self.adapt_items_to_qobuz(items, &parent_id).await } /// Increment update counter (called on catalog changes) async fn increment_update_id(&self) { let mut counter = self.inner.update_counter.write().await; *counter = counter.wrapping_add(1); let mut last = self.inner.last_change.write().await; *last = SystemTime::now(); } /// Construit le container Discover Catalog fn build_discover_catalog_container(&self) -> Container { Container { id: "qobuz:discover".to_string(), parent_id: "qobuz".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "Discover Catalog".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } /// Construit le container Discover Genres fn build_discover_genres_container(&self) -> Container { Container { id: "qobuz:genres".to_string(), parent_id: "qobuz".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "Discover Genres".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } /// Construit le container Favourites (My Music) fn build_favourites_container(&self) -> Container { Container { id: "qobuz:favorites".to_string(), parent_id: "qobuz".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "My Music".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } /// Construit le container Albums favoris fn build_favourite_albums_container(&self) -> Container { Container { id: "qobuz:favorites:albums".to_string(), parent_id: "qobuz:favorites".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "Albums".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } /// Construit le container Tracks favoris fn build_favourite_tracks_container(&self) -> Container { Container { id: "qobuz:favorites:tracks".to_string(), parent_id: "qobuz:favorites".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "Tracks".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } /// Construit le container Artists favoris fn build_favourite_artists_container(&self) -> Container { Container { id: "qobuz:favorites:artists".to_string(), parent_id: "qobuz:favorites".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "Artists".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } /// Construit le container Playlists favoris fn build_favourite_playlists_container(&self) -> Container { Container { id: "qobuz:favorites:playlists".to_string(), parent_id: "qobuz:favorites".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "Playlists".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } /// Cache les covers d'une liste d'items en parallèle (générique via `CoverCacheable`). async fn cache_covers(&self, items: Vec) -> Vec where T: CoverCacheable + Send + 'static, { let futs: Vec<_> = items.into_iter().map(|mut item| { let source = self.clone(); async move { if let Some(image_url) = item.image_url().map(str::to_string) { if let Ok(pk) = source.inner.cache_manager.cache_cover(&image_url).await { if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) { item.set_image_cached(url); } } } item } }).collect(); tokio::task::JoinSet::from_iter(futs).join_all().await } async fn cache_album_covers(&self, albums: Vec) -> Vec { self.cache_covers(albums).await } async fn cache_playlist_covers(&self, playlists: Vec) -> Vec { self.cache_covers(playlists).await } /// Cache les covers d'une liste de tracks en parallèle (via l'image de l'album). async fn cache_track_covers(&self, tracks: Vec) -> Vec { let futs: Vec<_> = tracks.into_iter().map(|mut track| { let source = self.clone(); async move { if let Some(ref mut album) = track.album { if let Some(ref image_url) = album.image.clone() { if let Ok(pk) = source.inner.cache_manager.cache_cover(image_url).await { if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) { album.image_cached = Some(url); } } } } track } }).collect(); tokio::task::JoinSet::from_iter(futs) .join_all() .await } /// Enregistre une liste de tracks comme lazy entries dans l'audio cache (en parallèle). /// /// Contrairement à `add_track_lazy`, cette méthode ne fait PAS d'appel à `get_stream_url` /// (coûteux pour de grandes playlists). L'URL audio est résolue à la demande via /// `QobuzLazyProvider` lors de la première lecture. /// /// Pour chaque track : cache la cover, enregistre la lazy entry, stocke les métadonnées. /// Retourne la liste des lazy PKs enregistrés avec succès. async fn register_tracks_lazy(&self, tracks: &[crate::models::Track]) -> Vec { // Limite la concurrence pour ne pas saturer l'API Qobuz ni la connexion réseau. // Les covers déjà cachées sont retournées immédiatement (pas d'HTTP), donc même // 600 tracks ne génèrent que ~N_albums_uniques téléchargements réels. let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(16)); // On attache l'index original à chaque future pour pouvoir retrier dans l'ordre // d'origine après complétion parallèle (JoinSet retourne dans l'ordre de fin). let futs: Vec<_> = tracks.iter().enumerate().map(|(idx, track)| { let source = self.clone(); let sem = sem.clone(); let track_id = track.id.clone(); let track_title = track.title.clone(); let cover_image_url = track.album.as_ref().and_then(|a| a.image.clone()); let metadata = pmoaudiocache::AudioMetadata { title: Some(track.title.clone()), artist: track.performer.as_ref().map(|p| p.name.clone()), album: track.album.as_ref().map(|a| a.title.clone()), duration_secs: Some(track.duration as u64), year: track.album.as_ref().and_then(|a| { a.release_date.as_ref().and_then(|d| d.split('-').next()?.parse().ok()) }), track_number: Some(track.track_number), track_total: track.album.as_ref().and_then(|a| a.tracks_count), disc_number: Some(track.media_number), disc_total: None, genre: track.album.as_ref().and_then(|a| { if !a.genres.is_empty() { Some(a.genres.join(", ")) } else { None } }), sample_rate: track.sample_rate, channels: track.channels, bitrate: None, conversion: None, }; async move { let _permit = sem.acquire().await.ok()?; let lazy_pk = format!("QOBUZ:{}", track_id); // 1. Cache cover eagerly let cover_pk = if let Some(ref url) = cover_image_url { source.inner.cache_manager.cache_cover(url).await.ok() } else { None }; // 2. Register lazy entry + set cover_pk + seed metadata. // Si le performer est absent (réponse API incomplète), on passe None pour // que le provider appelle get_track et récupère les métadonnées complètes. if metadata.artist.is_none() { tracing::warn!( "register_tracks_lazy: no performer for track {} (id={}), will call provider", track_title, track_id ); } if cover_pk.is_none() && cover_image_url.is_some() { tracing::warn!( "register_tracks_lazy: cover download failed for track {} (id={}), will call provider for cover", track_title, track_id ); } let meta_hint = if metadata.artist.is_some() { Some(metadata) } else { None }; match source.inner.cache_manager .cache_audio_lazy_with_provider(&lazy_pk, meta_hint, cover_pk) .await { Ok(pk) => { let _ = source.inner.cache_manager.set_audio_metadata( &pk, "qobuz_track_id", json!(track_id), ); Some((idx, pk)) } Err(e) => { tracing::warn!("Failed to register lazy track {}: {}", track_title, e); None } } } }).collect(); // Retrier par index original pour conserver l'ordre de la playlist Qobuz let mut results: Vec<(usize, String)> = tokio::task::JoinSet::from_iter(futs) .join_all() .await .into_iter() .flatten() .collect(); results.sort_unstable_by_key(|(i, _)| *i); results.into_iter().map(|(_, pk)| pk).collect() } /// Cache les covers d'une liste d'artistes en parallèle. async fn cache_artist_covers(&self, artists: Vec) -> Vec { let futs: Vec<_> = artists.into_iter().map(|mut artist| { let source = self.clone(); async move { if let Some(ref image_url) = artist.image.clone() { if let Ok(pk) = source.inner.cache_manager.cache_cover(image_url).await { if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) { artist.image_cached = Some(url); } } } artist } }).collect(); tokio::task::JoinSet::from_iter(futs) .join_all() .await } /// Browse Favourites - retourne 4 sous-containers async fn browse_favourites(&self) -> Result { let containers = vec![ self.build_favourite_albums_container(), self.build_favourite_tracks_container(), self.build_favourite_artists_container(), self.build_favourite_playlists_container(), ]; Ok(BrowseResult::Containers(containers)) } /// Browse Favourite Albums async fn browse_favourite_albums(&self) -> Result { let albums = self .inner .client .get_favorite_albums() .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; let albums = self.cache_album_covers(albums).await; let containers: Vec = albums .into_iter() .filter_map(|album| album.to_didl_container("qobuz:favorites:albums").ok()) .collect(); Ok(BrowseResult::Containers(containers)) } /// Browse Favourite Tracks async fn browse_favourite_tracks(&self) -> Result { let tracks = self .inner .client .get_favorite_tracks() .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; let tracks = self.cache_covers(tracks).await; let items: Vec = tracks .into_iter() .filter_map(|track| track.to_didl_item("qobuz:favorites:tracks").ok()) .collect(); Ok(BrowseResult::Items(items)) } /// Browse Favourite Artists async fn browse_favourite_artists(&self) -> Result { let artists = self .inner .client .get_favorite_artists() .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; let artists = self.cache_covers(artists).await; let containers: Vec = artists .into_iter() .map(|artist| Container { id: format!("qobuz:artist:{}", artist.id), parent_id: "qobuz:favorites:artists".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: artist.name.clone(), class: "object.container".to_string(), artist: Some(artist.name.clone()), album_art: artist.image_cached.clone(), containers: vec![], items: vec![], }) .collect(); Ok(BrowseResult::Containers(containers)) } /// Browse Favourite Playlists async fn browse_favourite_playlists(&self) -> Result { let playlists = self .inner .client .get_user_playlists() .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; let playlists = self.cache_playlist_covers(playlists).await; let containers: Vec = playlists .into_iter() .filter_map(|playlist| playlist.to_didl_container("qobuz:favorites:playlists").ok()) .collect(); Ok(BrowseResult::Containers(containers)) } // ===== DISCOVER CATALOG ===== /// Browse Discover Catalog - retourne 5 containers principaux + 10 playlists par tag async fn browse_discover_catalog(&self) -> Result { use crate::models::PlaylistTag; let mut containers = vec![ self.build_discover_playlists_container(), self.build_discover_albums_ideal_container(), self.build_discover_albums_qobuzissime_container(), self.build_discover_albums_new_container(), self.build_discover_artists_container(), ]; // Ajouter les 10 tags de playlists for tag in PlaylistTag::all() { containers.push(Container { id: format!("qobuz:discover:playlists:{}", tag.api_id()), parent_id: "qobuz:discover".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: tag.display_name().to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], }); } Ok(BrowseResult::Containers(containers)) } fn build_discover_playlists_container(&self) -> Container { Container { id: "qobuz:discover:playlists".to_string(), parent_id: "qobuz:discover".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "Playlists".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } fn build_discover_albums_ideal_container(&self) -> Container { Container { id: "qobuz:discover:albums:ideal".to_string(), parent_id: "qobuz:discover".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "Albums (Ideal Discography)".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } fn build_discover_albums_qobuzissime_container(&self) -> Container { Container { id: "qobuz:discover:albums:qobuzissime".to_string(), parent_id: "qobuz:discover".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "Albums (Qobuzissime)".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } fn build_discover_albums_new_container(&self) -> Container { Container { id: "qobuz:discover:albums:new".to_string(), parent_id: "qobuz:discover".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "Albums (New Releases)".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } fn build_discover_artists_container(&self) -> Container { Container { id: "qobuz:discover:artists".to_string(), parent_id: "qobuz:discover".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "Artists".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } /// Helper : récupère des albums featured, cache les covers, retourne des containers DIDL async fn browse_featured_albums(&self, genre_id: Option<&str>, filter: &str, parent_id: &str) -> Result { let albums = self .inner .client .get_featured_albums(genre_id, filter) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; let albums = self.cache_album_covers(albums).await; let containers: Vec = albums .into_iter() .filter_map(|album| album.to_didl_container(parent_id).ok()) .collect(); Ok(BrowseResult::Containers(containers)) } /// Helper : récupère des playlists featured, cache les covers, retourne des containers DIDL async fn browse_featured_playlists(&self, genre_id: Option<&str>, parent_id: &str) -> Result { let playlists = self .inner .client .get_featured_playlists(genre_id, None) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; let playlists = self.cache_playlist_covers(playlists).await; let containers: Vec = playlists .into_iter() .filter_map(|playlist| playlist.to_didl_container(parent_id).ok()) .collect(); Ok(BrowseResult::Containers(containers)) } async fn browse_discover_playlists(&self) -> Result { self.browse_featured_playlists(None, "qobuz:discover:playlists").await } async fn browse_discover_albums_ideal(&self) -> Result { self.browse_featured_albums(None, "ideal-discography", "qobuz:discover:albums:ideal").await } async fn browse_discover_albums_qobuzissime(&self) -> Result { self.browse_featured_albums(None, "qobuzissims", "qobuz:discover:albums:qobuzissime").await } async fn browse_discover_albums_new(&self) -> Result { self.browse_featured_albums(None, "new-releases", "qobuz:discover:albums:new").await } /// Browse Discover Artists (Featured Artists) async fn browse_discover_artists(&self) -> Result { let artists = self .inner .client .get_featured_artists(None, None, None) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; let artists = self.cache_covers(artists).await; let containers: Vec = artists .into_iter() .map(|artist| Container { id: format!("qobuz:artist:{}", artist.id), parent_id: "qobuz:discover:artists".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: artist.name.clone(), class: "object.container".to_string(), artist: Some(artist.name.clone()), album_art: artist.image_cached.clone(), containers: vec![], items: vec![], }) .collect(); Ok(BrowseResult::Containers(containers)) } /// Browse Discover Playlists by tag async fn browse_discover_playlists_tag(&self, tag: &str) -> Result { let playlists = self .inner .client .get_featured_playlists(None, Some(tag)) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; let playlists = self.cache_covers(playlists).await; let parent_id = format!("qobuz:discover:playlists:{}", tag); let containers: Vec = playlists .into_iter() .filter_map(|playlist| playlist.to_didl_container(&parent_id).ok()) .collect(); Ok(BrowseResult::Containers(containers)) } // ===== DISCOVER GENRES ===== /// Browse Discover Genres - liste des genres async fn browse_discover_genres(&self) -> Result { let genres = self .inner .client .get_genres() .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; let containers: Vec = genres .into_iter() .filter_map(|genre| { // Filtrer les genres sans ID genre.id.map(|id| Container { id: format!("qobuz:genre:{}", id), parent_id: "qobuz:genres".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: genre.name.clone(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], }) }) .collect(); Ok(BrowseResult::Containers(containers)) } /// Browse un genre spécifique - retourne 6 sous-containers async fn browse_genre(&self, genre_id: &str) -> Result { let containers = vec![ self.build_genre_new_releases_container(genre_id), self.build_genre_ideal_discography_container(genre_id), self.build_genre_qobuzissime_container(genre_id), self.build_genre_editor_picks_container(genre_id), self.build_genre_press_awards_container(genre_id), self.build_genre_playlists_container(genre_id), ]; Ok(BrowseResult::Containers(containers)) } fn build_genre_new_releases_container(&self, genre_id: &str) -> Container { Container { id: format!("qobuz:genre:{}:new-releases", genre_id), parent_id: format!("qobuz:genre:{}", genre_id), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "New Releases".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } fn build_genre_ideal_discography_container(&self, genre_id: &str) -> Container { Container { id: format!("qobuz:genre:{}:ideal", genre_id), parent_id: format!("qobuz:genre:{}", genre_id), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "Ideal Discography".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } fn build_genre_qobuzissime_container(&self, genre_id: &str) -> Container { Container { id: format!("qobuz:genre:{}:qobuzissime", genre_id), parent_id: format!("qobuz:genre:{}", genre_id), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "Qobuzissime".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } fn build_genre_editor_picks_container(&self, genre_id: &str) -> Container { Container { id: format!("qobuz:genre:{}:editor-picks", genre_id), parent_id: format!("qobuz:genre:{}", genre_id), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "Editor Picks".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } fn build_genre_press_awards_container(&self, genre_id: &str) -> Container { Container { id: format!("qobuz:genre:{}:press-awards", genre_id), parent_id: format!("qobuz:genre:{}", genre_id), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "Press Awards".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } fn build_genre_playlists_container(&self, genre_id: &str) -> Container { Container { id: format!("qobuz:genre:{}:playlists", genre_id), parent_id: format!("qobuz:genre:{}", genre_id), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: "Qobuz Playlists".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![], items: vec![], } } async fn browse_genre_new_releases(&self, genre_id: &str) -> Result { self.browse_featured_albums(Some(genre_id), "new-releases", &format!("qobuz:genre:{}:new-releases", genre_id)).await } async fn browse_genre_ideal_discography(&self, genre_id: &str) -> Result { self.browse_featured_albums(Some(genre_id), "ideal-discography", &format!("qobuz:genre:{}:ideal", genre_id)).await } async fn browse_genre_qobuzissime(&self, genre_id: &str) -> Result { self.browse_featured_albums(Some(genre_id), "qobuzissims", &format!("qobuz:genre:{}:qobuzissime", genre_id)).await } async fn browse_genre_editor_picks(&self, genre_id: &str) -> Result { self.browse_featured_albums(Some(genre_id), "editor-picks", &format!("qobuz:genre:{}:editor-picks", genre_id)).await } async fn browse_genre_press_awards(&self, genre_id: &str) -> Result { self.browse_featured_albums(Some(genre_id), "press-awards", &format!("qobuz:genre:{}:press-awards", genre_id)).await } async fn browse_genre_playlists(&self, genre_id: &str) -> Result { self.browse_featured_playlists(Some(genre_id), &format!("qobuz:genre:{}:playlists", genre_id)).await } /// Parse object_id to determine what to browse /// /// Object IDs follow these patterns: /// - "qobuz" or "0" → Root container /// - "qobuz:discover" → Discover Catalog /// - "qobuz:genres" → Discover Genres /// - "qobuz:favorites" → My Music /// - "qobuz:album:{id}" → Tracks in album /// - "qobuz:playlist:{id}" → Tracks in playlist /// - etc. fn parse_object_id(&self, object_id: &str) -> ObjectIdType { if object_id == "qobuz" || object_id == "0" { return ObjectIdType::Root; } let parts: Vec<&str> = object_id.split(':').collect(); match parts.as_slice() { // Discover Catalog ["qobuz", "discover"] => ObjectIdType::DiscoverCatalog, ["qobuz", "discover", "playlists"] => ObjectIdType::DiscoverPlaylists, ["qobuz", "discover", "albums", "ideal"] => ObjectIdType::DiscoverAlbumsIdeal, ["qobuz", "discover", "albums", "qobuzissime"] => { ObjectIdType::DiscoverAlbumsQobuzissime } ["qobuz", "discover", "albums", "new"] => ObjectIdType::DiscoverAlbumsNew, ["qobuz", "discover", "artists"] => ObjectIdType::DiscoverArtists, ["qobuz", "discover", "playlists", tag] => { ObjectIdType::DiscoverPlaylistsByTag(tag.to_string()) } // Discover Genres ["qobuz", "genres"] => ObjectIdType::DiscoverGenres, ["qobuz", "genre", id] => ObjectIdType::GenreRoot(id.to_string()), ["qobuz", "genre", id, "new-releases"] => { ObjectIdType::GenreNewReleases(id.to_string()) } ["qobuz", "genre", id, "ideal"] => ObjectIdType::GenreIdealDiscography(id.to_string()), ["qobuz", "genre", id, "qobuzissime"] => ObjectIdType::GenreQobuzissime(id.to_string()), ["qobuz", "genre", id, "editor-picks"] => { ObjectIdType::GenreEditorPicks(id.to_string()) } ["qobuz", "genre", id, "press-awards"] => { ObjectIdType::GenrePressAwards(id.to_string()) } ["qobuz", "genre", id, "playlists"] => ObjectIdType::GenrePlaylists(id.to_string()), // Favourites ["qobuz", "favorites"] => ObjectIdType::Favourites, ["qobuz", "favorites", "albums"] => ObjectIdType::FavouriteAlbums, ["qobuz", "favorites", "tracks"] => ObjectIdType::FavouriteTracks, ["qobuz", "favorites", "artists"] => ObjectIdType::FavouriteArtists, ["qobuz", "favorites", "playlists"] => ObjectIdType::FavouritePlaylists, // Items (existant) ["qobuz", "album", id] => ObjectIdType::Album(id.to_string()), ["qobuz", "playlist", id] => ObjectIdType::Playlist(id.to_string()), ["qobuz", "artist", id] => ObjectIdType::Artist(id.to_string()), ["qobuz", "track", id] => ObjectIdType::Track(id.to_string()), _ => ObjectIdType::Unknown, } } } #[derive(Debug)] enum ObjectIdType { Root, // Discover Catalog DiscoverCatalog, DiscoverPlaylists, DiscoverAlbumsIdeal, DiscoverAlbumsQobuzissime, DiscoverAlbumsNew, DiscoverArtists, DiscoverPlaylistsByTag(String), // tag // Discover Genres DiscoverGenres, GenreRoot(String), // genre_id GenreNewReleases(String), // genre_id GenreIdealDiscography(String), // genre_id GenreQobuzissime(String), // genre_id GenreEditorPicks(String), // genre_id GenrePressAwards(String), // genre_id GenrePlaylists(String), // genre_id // Favourites Favourites, FavouriteAlbums, FavouriteTracks, FavouriteArtists, FavouritePlaylists, // Items (existant) Album(String), Playlist(String), Artist(String), Track(String), Unknown, } #[async_trait] impl MusicSource for QobuzSource { fn name(&self) -> &str { "Qobuz" } fn id(&self) -> &str { "qobuz" } fn default_image(&self) -> &[u8] { DEFAULT_IMAGE } async fn root_container(&self) -> Result { // Create the root container with 3 main branches Ok(Container { id: "qobuz".to_string(), parent_id: "0".to_string(), restricted: Some("1".to_string()), child_count: Some("3".to_string()), searchable: Some("1".to_string()), title: "Qobuz".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers: vec![ self.build_discover_catalog_container(), self.build_discover_genres_container(), self.build_favourites_container(), ], items: vec![], }) } async fn browse(&self, object_id: &str) -> Result { match self.parse_object_id(object_id) { ObjectIdType::Root => { // Return the root container's children let root = self.root_container().await?; Ok(BrowseResult::Containers(root.containers)) } // Discover Catalog ObjectIdType::DiscoverCatalog => self.browse_discover_catalog().await, ObjectIdType::DiscoverPlaylists => self.browse_discover_playlists().await, ObjectIdType::DiscoverAlbumsIdeal => self.browse_discover_albums_ideal().await, ObjectIdType::DiscoverAlbumsQobuzissime => { self.browse_discover_albums_qobuzissime().await } ObjectIdType::DiscoverAlbumsNew => self.browse_discover_albums_new().await, ObjectIdType::DiscoverArtists => self.browse_discover_artists().await, ObjectIdType::DiscoverPlaylistsByTag(tag) => { self.browse_discover_playlists_tag(&tag).await } // Discover Genres ObjectIdType::DiscoverGenres => self.browse_discover_genres().await, ObjectIdType::GenreRoot(id) => self.browse_genre(&id).await, ObjectIdType::GenreNewReleases(id) => self.browse_genre_new_releases(&id).await, ObjectIdType::GenreIdealDiscography(id) => { self.browse_genre_ideal_discography(&id).await } ObjectIdType::GenreQobuzissime(id) => self.browse_genre_qobuzissime(&id).await, ObjectIdType::GenreEditorPicks(id) => self.browse_genre_editor_picks(&id).await, ObjectIdType::GenrePressAwards(id) => self.browse_genre_press_awards(&id).await, ObjectIdType::GenrePlaylists(id) => self.browse_genre_playlists(&id).await, // Favourites ObjectIdType::Favourites => self.browse_favourites().await, ObjectIdType::FavouriteAlbums => self.browse_favourite_albums().await, ObjectIdType::FavouriteTracks => self.browse_favourite_tracks().await, ObjectIdType::FavouriteArtists => self.browse_favourite_artists().await, ObjectIdType::FavouritePlaylists => self.browse_favourite_playlists().await, // Items (existant) ObjectIdType::Album(album_id) => { let items = self .get_or_create_album_playlist_items(&album_id, usize::MAX) .await?; Ok(BrowseResult::Items(items)) } ObjectIdType::Playlist(playlist_id) => { let items = self .get_or_create_qobuz_playlist_items(&playlist_id) .await?; Ok(BrowseResult::Items(items)) } ObjectIdType::Artist(artist_id) => { let albums = self .inner .client .get_artist_albums(&artist_id) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; let albums = self.cache_covers(albums).await; let containers: Vec = albums .into_iter() .filter_map(|album| { album .to_didl_container(&format!("qobuz:artist:{}", artist_id)) .ok() }) .collect(); Ok(BrowseResult::Containers(containers)) } ObjectIdType::Track(_) => { // Track object_ids ne sont pas browsables, retourner une erreur Err(MusicSourceError::NotSupported( "Tracks are not browsable containers".to_string(), )) } ObjectIdType::Unknown => Err(MusicSourceError::ObjectNotFound(object_id.to_string())), } } async fn resolve_uri(&self, object_id: &str) -> Result { // Try cache manager first if let Ok(uri) = self.inner.cache_manager.resolve_uri(object_id).await { return Ok(uri); } // If not cached, extract track ID and get streaming URL from Qobuz let track_id = object_id .strip_prefix("qobuz://track/") .unwrap_or(object_id); self.inner .client .get_stream_url(track_id) .await .map_err(|e| MusicSourceError::UriResolutionError(e.to_string())) } async fn get_item(&self, object_id: &str) -> Result { // Parse object_id to extract track ID let track_id = match self.parse_object_id(object_id) { ObjectIdType::Track(id) => id, _ => { return Err(MusicSourceError::ObjectNotFound(format!( "Not a track: {}", object_id ))) } }; // Get track from Qobuz API let track = self .inner .client .get_track(&track_id) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; // Register track in cache with lazy loading (same as albums) let (_track_uri, cache_pk) = self.add_track_lazy(&track).await?; // Build Item with HTTP URL pointing to cache let parent_id = track .album .as_ref() .map(|a| format!("qobuz:album:{}", a.id)) .unwrap_or_else(|| "qobuz".to_string()); // Get cover URL from cache if available let cover_url = if let Some(ref album) = track.album { if let Some(ref image) = album.image { if let Ok(pk) = self.inner.cache_manager.cache_cover(image).await { self.inner.cache_manager.cover_url(&pk, None).ok() } else { Some(image.clone()) } } else { None } } else { None }; // Build the resource with absolute HTTP URL let audio_url = format!("{}/audio/flac/{}", self.inner.base_url, cache_pk); let resource = pmodidl::Resource { protocol_info: format!( "http-get:*:{}:*", track.mime_type.as_deref().unwrap_or("audio/flac") ), bits_per_sample: track.bit_depth.map(|b| b.to_string()), sample_frequency: track.sample_rate.map(|r| r.to_string()), nr_audio_channels: track.channels.map(|c| c.to_string()), duration: Some(format_duration(track.duration)), url: audio_url, }; Ok(Item { id: format!("qobuz:track:{}", track.id), parent_id, restricted: Some("1".to_string()), title: track.title.clone(), creator: track.display_artist().map(|a| a.name.clone()), class: "object.item.audioItem.musicTrack".to_string(), artist: track.display_artist().map(|a| a.name.clone()), album: track.album_name().map(|s| s.to_string()), genre: None, album_art: cover_url, album_art_pk: None, date: track.album.as_ref().and_then(|a| a.release_date.clone()), original_track_number: Some(track.track_number.to_string()), resources: vec![resource], descriptions: Vec::new(), }) } fn supports_fifo(&self) -> bool { // Qobuz is a catalog, not a dynamic stream false } async fn append_track(&self, _track: Item) -> Result<()> { Err(MusicSourceError::FifoNotSupported) } async fn remove_oldest(&self) -> Result> { Err(MusicSourceError::FifoNotSupported) } async fn update_id(&self) -> u32 { *self.inner.update_counter.read().await } async fn last_change(&self) -> Option { Some(*self.inner.last_change.read().await) } async fn get_items(&self, offset: usize, count: usize) -> Result> { // For Qobuz, "get_items" returns favorite tracks with pagination let all_tracks = self .inner .client .get_favorite_tracks() .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; let all_tracks = self.cache_covers(all_tracks).await; let items: Vec = all_tracks .into_iter() .skip(offset) .take(count) .filter_map(|track| track.to_didl_item("qobuz:favorites").ok()) .collect(); Ok(items) } async fn search(&self, query: &str) -> Result { use tracing::debug; debug!(query = %query, "Qobuz search started"); // Search across Qobuz catalog (albums, tracks, artists, playlists) let results = self .inner .client .search(query, None) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; debug!( albums = results.albums.len(), artists = results.artists.len(), tracks = results.tracks.len(), playlists = results.playlists.len(), "Qobuz search API results" ); // Cache covers in parallel for all types let (albums, tracks, artists, playlists) = tokio::join!( self.cache_album_covers(results.albums), self.cache_track_covers(results.tracks), self.cache_artist_covers(results.artists), self.cache_playlist_covers(results.playlists), ); // Build containers from albums let album_containers: Vec = albums .into_iter() .filter_map(|a| a.to_didl_container("qobuz:search").ok()) .collect(); // Build containers from artists (manual construction) let artist_containers: Vec = artists .into_iter() .map(|artist| Container { id: format!("qobuz:artist:{}", artist.id), parent_id: "qobuz:search".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: artist.name.clone(), class: "object.container".to_string(), artist: Some(artist.name.clone()), album_art: artist.image_cached, containers: vec![], items: vec![], }) .collect(); // Build containers from playlists let playlist_containers: Vec = playlists .into_iter() .filter_map(|p| p.to_didl_container("qobuz:search").ok()) .collect(); // Combine all containers let mut all_containers = Vec::new(); all_containers.extend(album_containers); all_containers.extend(artist_containers); all_containers.extend(playlist_containers); // Build items from tracks let track_items: Vec = tracks .into_iter() .filter_map(|t| t.to_didl_item("qobuz:search").ok()) .collect(); debug!( containers = all_containers.len(), items = track_items.len(), "Qobuz search done" ); if !all_containers.is_empty() || !track_items.is_empty() { Ok(BrowseResult::Mixed { containers: all_containers, items: track_items }) } else { Ok(BrowseResult::Items(vec![])) } } // ============= Extended Features Implementation ============= fn capabilities(&self) -> pmosource::SourceCapabilities { pmosource::SourceCapabilities { supports_fifo: false, supports_search: true, supports_favorites: true, supports_playlists: true, supports_user_content: false, supports_high_res_audio: true, max_sample_rate: Some(192_000), // Qobuz supports up to 192kHz supports_multiple_formats: true, supports_advanced_search: false, // TODO: Qobuz API supports it, not yet implemented supports_pagination: true, } } async fn get_available_formats(&self, object_id: &str) -> Result> { use pmosource::AudioFormat; // Extract track ID from object_id let track_id = if let Some(id) = object_id.strip_prefix("qobuz://track/") { id } else { object_id }; // Get track details from Qobuz let track = self .inner .client .get_track(track_id) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; // Qobuz provides multiple formats based on subscription let mut formats = vec![]; // MP3 320 (format_id 5) - available to all formats.push(AudioFormat { format_id: "mp3-320".to_string(), mime_type: "audio/mpeg".to_string(), sample_rate: Some(44100), bit_depth: None, bitrate: Some(320), channels: Some(2), }); // FLAC 16/44.1 (format_id 6) - CD quality formats.push(AudioFormat { format_id: "flac-16-44".to_string(), mime_type: "audio/flac".to_string(), sample_rate: Some(44100), bit_depth: Some(16), bitrate: None, channels: Some(2), }); // Hi-Res formats (if available for this track) if let Some(sample_rate) = track.sample_rate { if sample_rate > 44100 { // FLAC 24-bit Hi-Res let bit_depth = track.bit_depth.map(|d| d as u8).or(Some(24)); formats.push(AudioFormat { format_id: format!("flac-{}-{}", bit_depth.unwrap_or(24), sample_rate / 1000), mime_type: "audio/flac".to_string(), sample_rate: Some(sample_rate), bit_depth, bitrate: None, channels: track.channels, }); } } Ok(formats) } async fn get_cache_status(&self, object_id: &str) -> Result { self.inner.cache_manager.get_cache_status(object_id).await } async fn cache_item(&self, object_id: &str) -> Result { // Extract track ID let track_id = object_id .strip_prefix("qobuz://track/") .unwrap_or(object_id); // Get track details from Qobuz let track = self .inner .client .get_track(track_id) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; // Add track to cache (via manager) let cached_id = self.add_track(&track).await?; // Return the cache status self.get_cache_status(&cached_id).await } async fn add_favorite(&self, object_id: &str) -> Result<()> { // Parse object_id to determine type let parts: Vec<&str> = object_id.split(':').collect(); match parts.as_slice() { ["qobuz", "album", id] | ["qobuz://album", id] => { self.inner .client .add_favorite_album(id) .await .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; } ["qobuz", "track", id] | ["qobuz://track", id] => { self.inner .client .add_favorite_track(id) .await .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; } _ => { return Err(MusicSourceError::NotSupported( "Favorites only supported for albums and tracks".to_string(), )); } } self.increment_update_id().await; Ok(()) } async fn remove_favorite(&self, object_id: &str) -> Result<()> { // Parse object_id to determine type let parts: Vec<&str> = object_id.split(':').collect(); match parts.as_slice() { ["qobuz", "album", id] | ["qobuz://album", id] => { self.inner .client .remove_favorite_album(id) .await .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; } ["qobuz", "track", id] | ["qobuz://track", id] => { self.inner .client .remove_favorite_track(id) .await .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; } _ => { return Err(MusicSourceError::NotSupported( "Favorites only supported for albums and tracks".to_string(), )); } } self.increment_update_id().await; Ok(()) } async fn is_favorite(&self, object_id: &str) -> Result { // Parse object_id to determine type let parts: Vec<&str> = object_id.split(':').collect(); match parts.as_slice() { ["qobuz", "album", id] | ["qobuz://album", id] => { let favorites = self .inner .client .get_favorite_albums() .await .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; Ok(favorites.iter().any(|album| album.id == *id)) } ["qobuz", "track", id] | ["qobuz://track", id] => { let favorites = self .inner .client .get_favorite_tracks() .await .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?; Ok(favorites.iter().any(|track| track.id == *id)) } _ => Err(MusicSourceError::NotSupported( "Favorites only supported for albums and tracks".to_string(), )), } } async fn get_user_playlists(&self) -> Result> { let playlists = self .inner .client .get_user_playlists() .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; let playlists = self.cache_covers(playlists).await; let containers: Vec = playlists .into_iter() .filter_map(|playlist| playlist.to_didl_container("qobuz").ok()) .collect(); Ok(containers) } async fn add_to_playlist(&self, playlist_id: &str, item_id: &str) -> Result<()> { // Extract track ID from item_id let track_id = if let Some(id) = item_id.strip_prefix("qobuz://track/") { id } else if let Some(id) = item_id.strip_prefix("qobuz:track:") { id } else { item_id }; self.inner .client .add_to_playlist(playlist_id, track_id) .await .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?; self.increment_update_id().await; Ok(()) } async fn get_item_count(&self, object_id: &str) -> Result { match self.parse_object_id(object_id) { ObjectIdType::Album(album_id) => { let album = self .inner .client .get_album(&album_id) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; Ok(album.tracks_count.unwrap_or(0) as usize) } ObjectIdType::Playlist(playlist_id) => { let playlist = self .inner .client .get_playlist(&playlist_id) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; Ok(playlist.tracks_count.unwrap_or(0) as usize) } _ => { // Fall back to default implementation let result = self.browse(object_id).await?; Ok(result.count()) } } } async fn browse_paginated( &self, object_id: &str, offset: usize, limit: usize, ) -> Result { match self.parse_object_id(object_id) { ObjectIdType::Album(album_id) => { let all_items = self .get_or_create_album_playlist_items(&album_id, usize::MAX) .await?; let items: Vec = all_items.into_iter().skip(offset).take(limit).collect(); Ok(BrowseResult::Items(items)) } ObjectIdType::FavouriteAlbums => { let albums = self .inner .client .get_favorite_albums() .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; let albums = self.cache_covers(albums).await; let containers: Vec = albums .into_iter() .skip(offset) .take(limit) .filter_map(|album| album.to_didl_container("qobuz:favorites:albums").ok()) .collect(); Ok(BrowseResult::Containers(containers)) } _ => { // Fall back to default implementation self.browse(object_id).await } } } async fn statistics(&self) -> Result { let mut stats = pmosource::SourceStatistics::default(); // Try to get favorite counts if let Ok(albums) = self.inner.client.get_favorite_albums().await { stats.total_containers = Some(albums.len()); } if let Ok(tracks) = self.inner.client.get_favorite_tracks().await { stats.total_items = Some(tracks.len()); } // Get cache statistics from manager let cache_stats = self.inner.cache_manager.statistics().await; stats.cached_items = Some(cache_stats.cached_tracks); Ok(stats) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_default_image_present() { assert!(DEFAULT_IMAGE.len() > 0, "Default image should not be empty"); // Check WebP magic bytes (RIFF...WEBP) assert!( DEFAULT_IMAGE.len() >= 12, "Image too small to be valid WebP" ); assert_eq!(&DEFAULT_IMAGE[0..4], b"RIFF", "Missing RIFF header"); assert_eq!(&DEFAULT_IMAGE[8..12], b"WEBP", "Missing WEBP signature"); } // Note: We can't easily test parse_object_id without creating a real client // which requires authentication. The parsing logic is simple enough that // it's covered by integration tests. }