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.
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RwLock<HashMap<String, (Container, Vec<Item>)>>>;
|
||||
|
||||
#[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<BrowseResult> {
|
||||
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<BrowseResult> {
|
||||
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<Item> = 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<ResolvedTrack>) -> 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<Item> = 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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user