From 009545de402e29d9bc84f50257c3b96af542d7dd Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 14 Jun 2026 21:55:01 +0200 Subject: [PATCH] feat(media): enhance source routing and add playlist caching Update UrlSource::new() to accept a base_url parameter for relative path resolution. Extend the Qobuz router with Playlist and Artist variants to fetch metadata and construct DIDL containers. Introduce an in-memory PlaylistStore cache, refactor search() and browse() to route and cache dynamic playlist items, and add helper utilities for deterministic ID generation. --- pmomediaserver/src/sources.rs | 3 +- pmoqobuz/src/source.rs | 44 ++++++++- pmourlsource/src/source.rs | 178 ++++++++++++++++++++++------------ 3 files changed, 160 insertions(+), 65 deletions(-) diff --git a/pmomediaserver/src/sources.rs b/pmomediaserver/src/sources.rs index 8e399eb0..d70728d9 100644 --- a/pmomediaserver/src/sources.rs +++ b/pmomediaserver/src/sources.rs @@ -313,7 +313,8 @@ impl SourcesExt for Server { Err(e) => tracing::warn!("Failed to build GenericUrlHandler HTTP client: {}", e), } - let source = Arc::new(UrlSource::new(resolver)); + let base_url = self.base_url().to_string(); + let source = Arc::new(UrlSource::new(resolver, base_url)); self.register_music_source(source).await; tracing::info!("✅ URL source registered successfully"); diff --git a/pmoqobuz/src/source.rs b/pmoqobuz/src/source.rs index dbbed199..0a95bfe0 100644 --- a/pmoqobuz/src/source.rs +++ b/pmoqobuz/src/source.rs @@ -1935,13 +1935,55 @@ impl MusicSource for QobuzSource { .get_album(&album_id) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - // Cache la pochette si nécessaire let album = self.cache_album_covers(vec![album]).await.into_iter().next().unwrap(); let container = album .to_didl_container("qobuz") .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; Ok(Some(container)) } + + ObjectIdType::Playlist(playlist_id) => { + let playlist = self + .inner + .client + .get_playlist(&playlist_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let container = playlist + .to_didl_container("qobuz") + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + Ok(Some(container)) + } + + ObjectIdType::Artist(artist_id) => { + // Pas d'endpoint artist direct — on tire le nom/image depuis les albums + let albums = self + .inner + .client + .get_artist_albums(&artist_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + let first = albums.first(); + let artist_name = first + .map(|a| a.artist.name.clone()) + .unwrap_or_else(|| format!("Artiste {}", artist_id)); + let album_art = first.and_then(|a| a.image_cached.clone().or_else(|| a.image.clone())); + let container = pmodidl::Container { + id: object_id.to_string(), + parent_id: "qobuz".to_string(), + restricted: Some("1".to_string()), + child_count: Some(albums.len().to_string()), + searchable: Some("1".to_string()), + title: artist_name.clone(), + class: "object.container.person.musicArtist".to_string(), + artist: Some(artist_name), + album_art, + containers: vec![], + items: vec![], + }; + Ok(Some(container)) + } + _ => Ok(None), } } diff --git a/pmourlsource/src/source.rs b/pmourlsource/src/source.rs index c2e727d2..06ff121e 100644 --- a/pmourlsource/src/source.rs +++ b/pmourlsource/src/source.rs @@ -3,18 +3,32 @@ use async_trait::async_trait; use pmodidl::{Container, Item, Resource}; use pmosource::api::get_source as get_source_from_registry; use pmosource::{BrowseResult, MusicSource, MusicSourceError, SearchQuery, SourceCapabilities}; +use std::collections::hash_map::DefaultHasher; +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::RwLock; +use std::sync::Arc; use std::time::SystemTime; const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/url-source.webp"); +/// Store éphémère pour les playlists URL : playlist_id → (container, items) +type PlaylistStore = Arc)>>>; + #[derive(Debug)] pub struct UrlSource { resolver: UrlResolver, + base_url: String, + playlists: PlaylistStore, } impl UrlSource { - pub fn new(resolver: UrlResolver) -> Self { - Self { resolver } + pub fn new(resolver: UrlResolver, base_url: String) -> Self { + Self { + resolver, + base_url, + playlists: Arc::new(RwLock::new(HashMap::new())), + } } } @@ -59,6 +73,16 @@ impl MusicSource for UrlSource { async fn browse(&self, object_id: &str) -> pmosource::Result { match object_id { "url" => Ok(BrowseResult::Containers(vec![])), + // Playlist éphémère créée par build_url_playlist + _ if object_id.starts_with("urlsource-") => { + let store = self.playlists.read().map_err(|_| { + MusicSourceError::BrowseError("playlist store lock poisoned".to_string()) + })?; + match store.get(object_id) { + Some((_, items)) => Ok(BrowseResult::Items(items.clone())), + None => Err(MusicSourceError::ObjectNotFound(object_id.to_string())), + } + } // Court-circuiter les IDs "url:*" pour éviter des erreurs dans les logs // des autres sources (items éphémères non persistables par ID). _ if object_id.starts_with("url:") => { @@ -68,12 +92,6 @@ impl MusicSource for UrlSource { } } - /// Résout une URL collée dans la barre de recherche. - /// - /// Le `query.text` est l'URL brute saisie par l'utilisateur. - /// Retourne un stub container dont l'ID correspond au container_id - /// de la source cible (ex. `qobuz:album:l46fxnqnxp5vs`). Le content - /// directory handler route le browse() ultérieur vers la bonne source. async fn search(&self, query: &SearchQuery) -> pmosource::Result { let url = query.text.trim(); @@ -86,36 +104,27 @@ impl MusicSource for UrlSource { source_id, container_id, }) => { - // Récupérer les métadonnées du container via get_container() — - // appel léger (pas de chargement des enfants ni des URLs audio). - // La source retourne un Container avec le bon class UPnP, le bon titre, - // artiste, pochette et child_count. Si non supporté, fallback stub. if let Some(source) = get_source_from_registry(&source_id).await { match source.get_container(&container_id).await { Ok(Some(mut container)) => { - // Forcer parent_id = source_id pour que le frontend - // route les browse() ultérieurs vers la bonne source. container.parent_id = source_id; return Ok(BrowseResult::Containers(vec![container])); } - Ok(None) => { - tracing::debug!( - source_id = %source_id, - container_id = %container_id, - "UrlSource: get_container non supporté, fallback stub" - ); - } + Ok(None) => {} Err(e) => { tracing::warn!( source_id = %source_id, container_id = %container_id, error = %e, - "UrlSource: get_container échoué, fallback stub" + "UrlSource: get_container échoué" ); } } + match source.get_item(&container_id).await { + Ok(item) => return Ok(BrowseResult::Items(vec![item])), + Err(_) => {} + } } - // Fallback : stub minimaliste si la source ne supporte pas get_container let title = display_title_for_url(url); let container = Container { id: container_id, @@ -133,19 +142,20 @@ impl MusicSource for UrlSource { Ok(BrowseResult::Containers(vec![container])) } - Ok(ResolvedContent::Playlist { title: album, items }) => { - let album = album.or_else(|| Some(display_title_for_url(url))); - let didl_items: Vec = items - .into_iter() - .enumerate() - .map(|(i, t)| resolved_track_to_item(t, i, album.as_deref())) - .collect(); - Ok(BrowseResult::Items(didl_items)) + Ok(ResolvedContent::Playlist { title: playlist_title, items }) => { + let title = playlist_title.unwrap_or_else(|| display_title_for_url(url)); + let container = self.build_url_playlist(url, title, items); + Ok(BrowseResult::Containers(vec![container])) } Ok(ResolvedContent::Track(t)) => { - let item = resolved_track_to_item(t, 0, None); - Ok(BrowseResult::Items(vec![item])) + // Pour un épisode unique, créer une playlist avec 1 item. + // Titre de la playlist = nom du podcast (album) ou titre de l'épisode. + let title = t.album.clone() + .or_else(|| Some(t.title.clone())) + .unwrap_or_else(|| display_title_for_url(url)); + let container = self.build_url_playlist(url, title, vec![t]); + Ok(BrowseResult::Containers(vec![container])) } Ok(ResolvedContent::Stream { uri, title, mime_type }) => { @@ -154,7 +164,6 @@ impl MusicSource for UrlSource { } Err(UrlResolverError::NotSupported(_)) => { - // Texte libre (pas une URL) — les autres sources traitent normalement. Ok(BrowseResult::Containers(vec![])) } Err(e) => { @@ -196,32 +205,71 @@ impl MusicSource for UrlSource { } } -/// Convertit un `ResolvedTrack` en `pmodidl::Item` jouable. -fn resolved_track_to_item(t: ResolvedTrack, index: usize, album: Option<&str>) -> Item { - let protocol_info = format!("http-get:*:{}:*", t.mime_type); - Item { - id: format!("url:item:{}", index), - parent_id: "url".to_string(), - restricted: Some("1".to_string()), - title: t.title, - creator: t.artist.clone(), - class: "object.item.audioItem.musicTrack".to_string(), - artist: t.artist, - album: t.album.or_else(|| album.map(|s| s.to_string())), - genre: None, - album_art: t.album_art, - album_art_pk: None, - date: None, - original_track_number: Some(format!("{}", index + 1)), - resources: vec![Resource { - protocol_info, - bits_per_sample: None, - sample_frequency: None, - nr_audio_channels: None, - duration: t.duration, - url: t.uri, - }], - descriptions: vec![], +impl UrlSource { + /// Crée un container playlist éphémère en mémoire depuis des tracks résolus. + /// + /// Les items gardent leurs URLs directes (RadioFrance, etc.) et leur MIME type + /// d'origine — pas de proxy via pmoaudiocache, donc pas de conversion FLAC + /// et pas de problème avec les formats M4A/AAC. + fn build_url_playlist(&self, url: &str, title: String, tracks: Vec) -> Container { + let playlist_id = format!("urlsource-{:016x}", url_hash(url)); + let n = tracks.len(); + + // Cover = album_art du premier épisode + let album_art = tracks.first().and_then(|t| t.album_art.clone()); + + let items: Vec = tracks + .into_iter() + .enumerate() + .map(|(i, t)| { + let protocol_info = format!("http-get:*:{}:*", t.mime_type); + Item { + id: format!("{}:{}", playlist_id, i), + parent_id: playlist_id.clone(), + restricted: Some("1".to_string()), + title: t.title, + creator: t.artist.clone(), + class: "object.item.audioItem.musicTrack".to_string(), + artist: t.artist, + album: t.album.or_else(|| Some(title.clone())), + genre: None, + album_art: t.album_art, + album_art_pk: None, + date: None, + original_track_number: Some(format!("{}", i + 1)), + resources: vec![Resource { + protocol_info, + bits_per_sample: None, + sample_frequency: None, + nr_audio_channels: None, + duration: t.duration, + url: t.uri, + }], + descriptions: vec![], + } + }) + .collect(); + + let container = Container { + id: playlist_id.clone(), + parent_id: "url".to_string(), + restricted: Some("1".to_string()), + child_count: Some(n.to_string()), + searchable: Some("0".to_string()), + title: title.clone(), + class: "object.container.playlistContainer".to_string(), + artist: None, + album_art, + containers: vec![], + items: vec![], + }; + + // Stocker dans le store éphémère (écrase toute entrée précédente) + if let Ok(mut store) = self.playlists.write() { + store.insert(playlist_id, (container.clone(), items)); + } + + container } } @@ -255,9 +303,7 @@ fn stream_to_item(uri: String, title: String, mime_type: String) -> Item { } /// Extrait un titre lisible depuis une URL. -/// Ex: "https://open.qobuz.com/album/abc" → "Album (open.qobuz.com)" fn display_title_for_url(url: &str) -> String { - // Extraire l'hôte let host = url .find("://") .and_then(|i| { @@ -267,7 +313,6 @@ fn display_title_for_url(url: &str) -> String { }) .unwrap_or(""); - // Extraire le premier segment du path let type_label = if url.contains("/album/") { "Album" } else if url.contains("/track/") { @@ -286,3 +331,10 @@ fn display_title_for_url(url: &str) -> String { format!("{} ({})", type_label, host) } } + +/// Hash stable d'une URL pour construire un ID de playlist déterministe. +fn url_hash(url: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + url.hash(&mut hasher); + hasher.finish() +}