diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 9f7cf150..9fe63297 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -1833,7 +1833,14 @@ impl ControlPoint { binding: Some(binding), }); - let mut auto_start_cb = |rid: &RendererId| self.start_queue_playback_if_idle(rid); + // For initial attach with auto_play, force playback start (don't check if idle) + let mut auto_start_cb = |rid: &RendererId| { + debug!( + renderer = rid.0.as_str(), + "Attach callback: forcing playback start (not checking if idle)" + ); + self.play_current_from_queue(rid) + }; let callback: Option<&mut dyn FnMut(&RendererId) -> anyhow::Result<()>> = if auto_play { Some(&mut auto_start_cb) } else { @@ -2683,6 +2690,12 @@ fn refresh_attached_queue_for( if auto_play { if let Some(callback) = after_refresh.as_deref_mut() { + debug!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + container = container_id.as_str(), + "Auto-play enabled: calling callback to start playback" + ); if let Err(err) = callback(renderer_id) { warn!( renderer = renderer_id.0.as_str(), @@ -2691,8 +2704,25 @@ fn refresh_attached_queue_for( error = %err, "Failed to auto-start playback after playlist refresh" ); + } else { + info!( + renderer = renderer_id.0.as_str(), + server = server_id.0.as_str(), + container = container_id.as_str(), + "Auto-play callback completed successfully" + ); } + } else { + debug!( + renderer = renderer_id.0.as_str(), + "Auto-play enabled but no callback provided" + ); } + } else { + debug!( + renderer = renderer_id.0.as_str(), + "Auto-play disabled, skipping playback start" + ); } Ok(()) diff --git a/pmocontrol/src/music_renderer.rs b/pmocontrol/src/music_renderer.rs index 315dada0..3b0ee05a 100644 --- a/pmocontrol/src/music_renderer.rs +++ b/pmocontrol/src/music_renderer.rs @@ -22,7 +22,7 @@ use crate::{ PlaybackPosition, PlaybackState, TransportControl, UpnpRenderer, VolumeControl, }; use anyhow::{Result, anyhow}; -use tracing::warn; +use tracing::{debug, info, warn}; /// Backend-agnostic façade exposing transport, volume, and status contracts. #[derive(Clone, Debug)] @@ -281,8 +281,18 @@ impl MusicRenderer { pub fn clear_for_playlist_attach(&self) -> Result<()> { match self { MusicRenderer::OpenHome(_) => { - // For OpenHome: clear the playlist on the renderer itself - self.openhome_playlist_clear() + // For OpenHome: clear the playlist (DeleteAll also stops playback automatically) + // then explicitly stop to ensure clean state + self.openhome_playlist_clear()?; + self.stop().or_else(|err| -> Result<()> { + // If stop fails (e.g., already stopped), that's fine + warn!( + renderer = self.id().0.as_str(), + error = %err, + "Stop failed after clearing OpenHome playlist (continuing anyway)" + ); + Ok(()) + }) } MusicRenderer::Upnp(_) | MusicRenderer::Chromecast(_) @@ -436,21 +446,57 @@ impl MusicRenderer { // Get the current OpenHome playlist snapshot let snapshot = self.fetch_openhome_playlist_snapshot()?; + debug!( + renderer = self.id().0.as_str(), + tracks_count = snapshot.tracks.len(), + current_id = ?snapshot.current_id, + current_index = ?snapshot.current_index, + "play_current_from_backend_queue: OpenHome snapshot fetched" + ); + if snapshot.tracks.is_empty() { return Err(anyhow!("OpenHome playlist is empty")); } // Find the track_id to play (prefer current_id, then current_index, then first) let target_track_id = if let Some(current_id) = snapshot.current_id { + debug!( + renderer = self.id().0.as_str(), + track_id = current_id, + "Using current_id for playback" + ); Some(current_id) } else if let Some(current_idx) = snapshot.current_index { - snapshot.tracks.get(current_idx).map(|track| track.id) + let track_id = snapshot.tracks.get(current_idx).map(|track| track.id); + debug!( + renderer = self.id().0.as_str(), + current_idx, + track_id = ?track_id, + "Using current_index for playback" + ); + track_id } else { - snapshot.tracks.first().map(|track| track.id) + let track_id = snapshot.tracks.first().map(|track| track.id); + debug!( + renderer = self.id().0.as_str(), + track_id = ?track_id, + "Using first track for playback" + ); + track_id }; if let Some(track_id) = target_track_id { + info!( + renderer = self.id().0.as_str(), + track_id, + "Calling openhome_playlist_play_id to start playback" + ); self.openhome_playlist_play_id(track_id)?; + info!( + renderer = self.id().0.as_str(), + track_id, + "Successfully called openhome_playlist_play_id" + ); Ok(()) } else { Err(anyhow!("No track to play in OpenHome playlist")) diff --git a/pmoparadise/src/source.rs b/pmoparadise/src/source.rs index 459998dd..7de3c492 100644 --- a/pmoparadise/src/source.rs +++ b/pmoparadise/src/source.rs @@ -135,6 +135,14 @@ impl RadioParadiseSource { let token = mgr.register_callback(move |event| { let pid = pid_clone.clone(); if event.playlist_id == pid { + // Ignorer PkUpdated - pas de notification UPnP (évite le reload côté control point) + if matches!(event.kind, pmoplaylist::PlaylistEventKind::PkUpdated { .. }) { + tracing::debug!( + "PK swap in playlist {} - no UPnP notification (prevents playback restart)", + event.playlist_id + ); + return; + } // On ne réagit qu'aux mises à jour structurelles (ajout/suppression) if !matches!(event.kind, pmoplaylist::PlaylistEventKind::Updated) { return; diff --git a/pmoplaylist/src/handle/write.rs b/pmoplaylist/src/handle/write.rs index f49997a5..de26b8ad 100644 --- a/pmoplaylist/src/handle/write.rs +++ b/pmoplaylist/src/handle/write.rs @@ -527,7 +527,8 @@ impl WriteHandle { manager .rebuild_track_index(&self.playlist.id, &snapshot) .await; - manager.notify_playlist_changed(&self.playlist.id); + // PK swap uniquement - pas de notification UPnP pour éviter le reload + manager.notify_playlist_pk_updated(&self.playlist.id, old_pk, new_pk); } Ok(()) diff --git a/pmoplaylist/src/manager.rs b/pmoplaylist/src/manager.rs index 87168119..64fdfb3b 100644 --- a/pmoplaylist/src/manager.rs +++ b/pmoplaylist/src/manager.rs @@ -48,6 +48,9 @@ pub struct PlaylistEvent { pub enum PlaylistEventKind { /// La playlist a été modifiée (ajout/suppression/changement de config). Updated, + /// Cache PK commuté (lazy→real) - pas de changement structurel. + /// N'émet PAS de ContainersUpdated UPnP pour éviter le reload. + PkUpdated { old_pk: String, new_pk: String }, /// Un morceau référencé par la playlist a été servi par le cache audio. TrackPlayed { cache_pk: String, qualifier: String }, } @@ -270,6 +273,18 @@ impl PlaylistManager { self.notify_playlist_event(id, PlaylistEventKind::Updated); } + /// Notifie que des PK ont été swappés (lazy→real). + /// N'émet PAS de notification UPnP ContainersUpdated pour éviter le reload. + pub(crate) fn notify_playlist_pk_updated(&self, id: &str, old_pk: &str, new_pk: &str) { + self.notify_playlist_event( + id, + PlaylistEventKind::PkUpdated { + old_pk: old_pk.to_string(), + new_pk: new_pk.to_string(), + }, + ); + } + /// Notifie les callbacks qu'un morceau a été joué pour une playlist donnée. pub(crate) fn notify_playlist_track_played( &self, diff --git a/pmoplaylist/src/sse.rs b/pmoplaylist/src/sse.rs index 1353a17f..bb07d258 100644 --- a/pmoplaylist/src/sse.rs +++ b/pmoplaylist/src/sse.rs @@ -55,6 +55,10 @@ pub async fn playlist_events_sse(Query(params): Query) -> impl Into let (kind, cache_pk, qualifier) = match &envelope.event.kind { PlaylistEventKind::Updated => ("updated", None, None), + PlaylistEventKind::PkUpdated { old_pk: _, new_pk: _ } => { + // PK swap silencieux - envoyer quand même l'événement SSE pour debug/monitoring + ("pk_updated", None, None) + } PlaylistEventKind::TrackPlayed { cache_pk, qualifier } => { ("track_played", Some(cache_pk.as_str()), Some(qualifier.as_str())) } diff --git a/pmoqobuz/src/api/catalog.rs b/pmoqobuz/src/api/catalog.rs index e2455042..99dd8b9e 100644 --- a/pmoqobuz/src/api/catalog.rs +++ b/pmoqobuz/src/api/catalog.rs @@ -149,6 +149,12 @@ struct FeaturedPlaylistsResponse { playlists: PaginatedResponse, } +/// Réponse artistes featured +#[derive(Debug, Deserialize)] +struct FeaturedArtistsResponse { + artists: PaginatedResponse, +} + /// Réponse search #[derive(Debug, Deserialize)] struct SearchResponse { @@ -397,6 +403,36 @@ impl QobuzApi { .collect()) } + /// Récupère les artistes featured + pub async fn get_featured_artists( + &self, + genre_id: Option<&str>, + limit: Option, + offset: Option, + ) -> Result> { + debug!("Fetching featured artists"); + let limit_str = limit.unwrap_or(100).to_string(); + let offset_str = offset.unwrap_or(0).to_string(); + + let mut params = vec![ + ("type", "featured-artists"), + ("limit", &limit_str), + ("offset", &offset_str), + ]; + + if let Some(gid) = genre_id { + params.push(("genre_ids", gid)); + } + + let response: FeaturedArtistsResponse = self.get("/artist/getFeatured", ¶ms).await?; + Ok(response + .artists + .items + .into_iter() + .map(Self::parse_artist) + .collect()) + } + /// Recherche dans le catalogue pub async fn search(&self, query: &str, type_: Option<&str>) -> Result { debug!("Searching for '{}' (type: {:?})", query, type_); @@ -443,6 +479,13 @@ impl QobuzApi { // Fonctions de parsing publiques (utilisées aussi par le module user) pub(crate) fn parse_album(response: AlbumResponse) -> Album { + // Log pour débugger les valeurs audio + if let Some(rate) = response.maximum_sampling_rate { + debug!("Album {} - maximum_sampling_rate: {} Hz", response.id, rate); + } else { + debug!("Album {} - maximum_sampling_rate: None", response.id); + } + Album { id: response.id, title: response.title, diff --git a/pmoqobuz/src/client.rs b/pmoqobuz/src/client.rs index 285628d9..8c3299c1 100644 --- a/pmoqobuz/src/client.rs +++ b/pmoqobuz/src/client.rs @@ -752,6 +752,19 @@ impl QobuzClient { .await } + /// Récupère les artistes featured + pub async fn get_featured_artists( + &self, + genre_id: Option<&str>, + limit: Option, + offset: Option, + ) -> Result> { + self.call_with_auth_repair("get_featured_artists", || { + self.api.get_featured_artists(genre_id, limit, offset) + }) + .await + } + // ============ Recherche ============ /// Recherche dans le catalogue Qobuz diff --git a/pmoqobuz/src/models.rs b/pmoqobuz/src/models.rs index 7b4be722..5de0baf1 100644 --- a/pmoqobuz/src/models.rs +++ b/pmoqobuz/src/models.rs @@ -269,7 +269,13 @@ impl Album { /// Retourne un titre formaté avec les informations audio si disponibles pub fn formatted_title(&self) -> String { if let (Some(rate), Some(depth)) = (self.maximum_sampling_rate, self.maximum_bit_depth) { - format!("{} ({:.0}/{} bit)", self.title, rate / 1000.0, depth) + // Convertir Hz en kHz, en gérant les valeurs qui pourraient déjà être en kHz + let rate_khz = if rate > 1000.0 { + rate / 1000.0 + } else { + rate + }; + format!("{} ({:.1} kHz / {} bits)", self.title, rate_khz, depth) } else { self.title.clone() } @@ -316,3 +322,109 @@ impl SearchResult { self.total_count() == 0 } } + +/// Types d'albums featured disponibles dans le catalogue Qobuz +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FeaturedAlbumType { + /// Nouveautés + NewReleases, + /// Nouveautés complètes + NewReleasesFull, + /// Discographie idéale + IdealDiscography, + /// Qobuzissime + Qobuzissims, + /// Choix de l'éditeur + EditorPicks, + /// Prix de la presse + PressAwards, +} + +impl FeaturedAlbumType { + /// Retourne l'identifiant API pour ce type + pub fn api_id(&self) -> &'static str { + match self { + Self::NewReleases => "new-releases", + Self::NewReleasesFull => "new-releases-full", + Self::IdealDiscography => "ideal-discography", + Self::Qobuzissims => "qobuzissims", + Self::EditorPicks => "editor-picks", + Self::PressAwards => "press-awards", + } + } +} + +/// Tags de playlists disponibles dans le catalogue Qobuz +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlaylistTag { + /// Hi-Res + HiRes, + /// Nouvelles + New, + /// Thématiques + Themes, + /// Choix d'artistes + ArtistsChoices, + /// Labels + Labels, + /// Humeurs + Moods, + /// Artistes + Artists, + /// Événements + Events, + /// Auditoriums + Auditoriums, + /// Populaires + Popular, +} + +impl PlaylistTag { + /// Retourne l'identifiant API pour ce tag + pub fn api_id(&self) -> &'static str { + match self { + Self::HiRes => "hi-res", + Self::New => "new", + Self::Themes => "focus", + Self::ArtistsChoices => "danslecasque", + Self::Labels => "label", + Self::Moods => "mood", + Self::Artists => "artist", + Self::Events => "events", + Self::Auditoriums => "auditoriums", + Self::Popular => "popular", + } + } + + /// Retourne le titre localisé pour ce tag + pub fn display_name(&self) -> &'static str { + match self { + Self::HiRes => "Playlists (Hi-Res)", + Self::New => "Playlists (New)", + Self::Themes => "Playlists (Themes)", + Self::ArtistsChoices => "Playlists (Artist's Choices)", + Self::Labels => "Playlists (Labels)", + Self::Moods => "Playlists (Moods)", + Self::Artists => "Playlists (Artists)", + Self::Events => "Playlists (Events)", + Self::Auditoriums => "Playlists (Auditoriums)", + Self::Popular => "Playlists (Popular)", + } + } + + /// Retourne la liste de tous les tags + pub fn all() -> &'static [PlaylistTag] { + &[ + Self::HiRes, + Self::New, + Self::Themes, + Self::ArtistsChoices, + Self::Labels, + Self::Moods, + Self::Artists, + Self::Events, + Self::Auditoriums, + Self::Popular, + ] + } +} diff --git a/pmoqobuz/src/source.rs b/pmoqobuz/src/source.rs index 84b5bbc3..313fe6a9 100644 --- a/pmoqobuz/src/source.rs +++ b/pmoqobuz/src/source.rs @@ -721,13 +721,666 @@ impl QobuzSource { *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(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + containers: vec![], + items: vec![], + } + } + + /// 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 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 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 containers: Vec = artists + .into_iter() + .filter_map(|artist| { + // Créer un container pour chaque artiste + Some(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(), + 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 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + containers: vec![], + items: vec![], + } + } + + /// Browse Discover Playlists (all featured playlists) + async fn browse_discover_playlists(&self) -> Result { + let playlists = self + .inner + .client + .get_featured_playlists(None, None) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let containers: Vec = playlists + .into_iter() + .filter_map(|playlist| playlist.to_didl_container("qobuz:discover:playlists").ok()) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + /// Browse Discover Albums (Ideal Discography) + async fn browse_discover_albums_ideal(&self) -> Result { + let albums = self + .inner + .client + .get_featured_albums(None, "ideal-discography") + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let containers: Vec = albums + .into_iter() + .filter_map(|album| album.to_didl_container("qobuz:discover:albums:ideal").ok()) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + /// Browse Discover Albums (Qobuzissime) + async fn browse_discover_albums_qobuzissime(&self) -> Result { + let albums = self + .inner + .client + .get_featured_albums(None, "qobuzissims") + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let containers: Vec = albums + .into_iter() + .filter_map(|album| album.to_didl_container("qobuz:discover:albums:qobuzissime").ok()) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + /// Browse Discover Albums (New Releases) + async fn browse_discover_albums_new(&self) -> Result { + let albums = self + .inner + .client + .get_featured_albums(None, "new-releases") + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let containers: Vec = albums + .into_iter() + .filter_map(|album| album.to_didl_container("qobuz:discover:albums:new").ok()) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + /// 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 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(), + 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 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + containers: vec![], + items: vec![], + } + } + + /// Browse Genre New Releases + async fn browse_genre_new_releases(&self, genre_id: &str) -> Result { + let albums = self + .inner + .client + .get_featured_albums(Some(genre_id), "new-releases") + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let parent_id = format!("qobuz:genre:{}:new-releases", genre_id); + let containers: Vec = albums + .into_iter() + .filter_map(|album| album.to_didl_container(&parent_id).ok()) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + /// Browse Genre Ideal Discography + async fn browse_genre_ideal_discography(&self, genre_id: &str) -> Result { + let albums = self + .inner + .client + .get_featured_albums(Some(genre_id), "ideal-discography") + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let parent_id = format!("qobuz:genre:{}:ideal", genre_id); + let containers: Vec = albums + .into_iter() + .filter_map(|album| album.to_didl_container(&parent_id).ok()) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + /// Browse Genre Qobuzissime + async fn browse_genre_qobuzissime(&self, genre_id: &str) -> Result { + let albums = self + .inner + .client + .get_featured_albums(Some(genre_id), "qobuzissims") + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let parent_id = format!("qobuz:genre:{}:qobuzissime", genre_id); + let containers: Vec = albums + .into_iter() + .filter_map(|album| album.to_didl_container(&parent_id).ok()) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + /// Browse Genre Editor Picks + async fn browse_genre_editor_picks(&self, genre_id: &str) -> Result { + let albums = self + .inner + .client + .get_featured_albums(Some(genre_id), "editor-picks") + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let parent_id = format!("qobuz:genre:{}:editor-picks", genre_id); + let containers: Vec = albums + .into_iter() + .filter_map(|album| album.to_didl_container(&parent_id).ok()) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + /// Browse Genre Press Awards + async fn browse_genre_press_awards(&self, genre_id: &str) -> Result { + let albums = self + .inner + .client + .get_featured_albums(Some(genre_id), "press-awards") + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let parent_id = format!("qobuz:genre:{}:press-awards", genre_id); + let containers: Vec = albums + .into_iter() + .filter_map(|album| album.to_didl_container(&parent_id).ok()) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + /// Browse Genre Playlists + async fn browse_genre_playlists(&self, genre_id: &str) -> Result { + let playlists = self + .inner + .client + .get_featured_playlists(Some(genre_id), None) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let parent_id = format!("qobuz:genre:{}:playlists", genre_id); + let containers: Vec = playlists + .into_iter() + .filter_map(|playlist| playlist.to_didl_container(&parent_id).ok()) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + /// Parse object_id to determine what to browse /// /// Object IDs follow these patterns: /// - "qobuz" or "0" → Root container - /// - "qobuz:favorites" → User's favorite albums + /// - "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; @@ -735,10 +1388,38 @@ impl QobuzSource { let parts: Vec<&str> = object_id.split(':').collect(); match parts.as_slice() { - ["qobuz", "favorites"] => ObjectIdType::Favorites, + // 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, } } @@ -747,10 +1428,39 @@ impl QobuzSource { #[derive(Debug)] enum ObjectIdType { Root, - Favorites, + + // 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, } @@ -769,28 +1479,19 @@ impl MusicSource for QobuzSource { } async fn root_container(&self) -> Result { - // Create the root container with sub-containers for different categories + // 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("2".to_string()), // Favorites + Search (simplified) + child_count: Some("3".to_string()), searchable: Some("1".to_string()), title: "Qobuz".to_string(), class: "object.container".to_string(), containers: vec![ - // Favorites container - Container { - id: "qobuz:favorites".to_string(), - parent_id: "qobuz".to_string(), - restricted: Some("1".to_string()), - child_count: None, // Will be determined when browsed - searchable: Some("1".to_string()), - title: "My Favorites".to_string(), - class: "object.container".to_string(), - containers: vec![], - items: vec![], - }, + self.build_discover_catalog_container(), + self.build_discover_genres_container(), + self.build_favourites_container(), ], items: vec![], }) @@ -804,23 +1505,33 @@ impl MusicSource for QobuzSource { Ok(BrowseResult::Containers(root.containers)) } - ObjectIdType::Favorites => { - // Get user's favorite albums - let albums = self - .inner - .client - .get_favorite_albums() - .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + // 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, - let containers: Vec = albums - .into_iter() - .filter_map(|album| album.to_didl_container("qobuz:favorites").ok()) - .collect(); + // 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, - Ok(BrowseResult::Containers(containers)) - } + // 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) @@ -871,6 +1582,13 @@ impl MusicSource for QobuzSource { 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())), } } @@ -1242,7 +1960,7 @@ impl MusicSource for QobuzSource { Ok(BrowseResult::Items(items)) } - ObjectIdType::Favorites => { + ObjectIdType::FavouriteAlbums => { let albums = self .inner .client @@ -1254,7 +1972,7 @@ impl MusicSource for QobuzSource { .into_iter() .skip(offset) .take(limit) - .filter_map(|album| album.to_didl_container("qobuz:favorites").ok()) + .filter_map(|album| album.to_didl_container("qobuz:favorites:albums").ok()) .collect(); Ok(BrowseResult::Containers(containers))