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),
|
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;
|
self.register_music_source(source).await;
|
||||||
|
|
||||||
tracing::info!("✅ URL source registered successfully");
|
tracing::info!("✅ URL source registered successfully");
|
||||||
|
|||||||
@@ -1935,13 +1935,55 @@ impl MusicSource for QobuzSource {
|
|||||||
.get_album(&album_id)
|
.get_album(&album_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
.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 album = self.cache_album_covers(vec![album]).await.into_iter().next().unwrap();
|
||||||
let container = album
|
let container = album
|
||||||
.to_didl_container("qobuz")
|
.to_didl_container("qobuz")
|
||||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
Ok(Some(container))
|
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),
|
_ => Ok(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,32 @@ use async_trait::async_trait;
|
|||||||
use pmodidl::{Container, Item, Resource};
|
use pmodidl::{Container, Item, Resource};
|
||||||
use pmosource::api::get_source as get_source_from_registry;
|
use pmosource::api::get_source as get_source_from_registry;
|
||||||
use pmosource::{BrowseResult, MusicSource, MusicSourceError, SearchQuery, SourceCapabilities};
|
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;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/url-source.webp");
|
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)]
|
#[derive(Debug)]
|
||||||
pub struct UrlSource {
|
pub struct UrlSource {
|
||||||
resolver: UrlResolver,
|
resolver: UrlResolver,
|
||||||
|
base_url: String,
|
||||||
|
playlists: PlaylistStore,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UrlSource {
|
impl UrlSource {
|
||||||
pub fn new(resolver: UrlResolver) -> Self {
|
pub fn new(resolver: UrlResolver, base_url: String) -> Self {
|
||||||
Self { resolver }
|
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> {
|
async fn browse(&self, object_id: &str) -> pmosource::Result<BrowseResult> {
|
||||||
match object_id {
|
match object_id {
|
||||||
"url" => Ok(BrowseResult::Containers(vec![])),
|
"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
|
// Court-circuiter les IDs "url:*" pour éviter des erreurs dans les logs
|
||||||
// des autres sources (items éphémères non persistables par ID).
|
// des autres sources (items éphémères non persistables par ID).
|
||||||
_ if object_id.starts_with("url:") => {
|
_ 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> {
|
async fn search(&self, query: &SearchQuery) -> pmosource::Result<BrowseResult> {
|
||||||
let url = query.text.trim();
|
let url = query.text.trim();
|
||||||
|
|
||||||
@@ -86,36 +104,27 @@ impl MusicSource for UrlSource {
|
|||||||
source_id,
|
source_id,
|
||||||
container_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 {
|
if let Some(source) = get_source_from_registry(&source_id).await {
|
||||||
match source.get_container(&container_id).await {
|
match source.get_container(&container_id).await {
|
||||||
Ok(Some(mut container)) => {
|
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;
|
container.parent_id = source_id;
|
||||||
return Ok(BrowseResult::Containers(vec![container]));
|
return Ok(BrowseResult::Containers(vec![container]));
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {}
|
||||||
tracing::debug!(
|
|
||||||
source_id = %source_id,
|
|
||||||
container_id = %container_id,
|
|
||||||
"UrlSource: get_container non supporté, fallback stub"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
source_id = %source_id,
|
source_id = %source_id,
|
||||||
container_id = %container_id,
|
container_id = %container_id,
|
||||||
error = %e,
|
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 title = display_title_for_url(url);
|
||||||
let container = Container {
|
let container = Container {
|
||||||
id: container_id,
|
id: container_id,
|
||||||
@@ -133,19 +142,20 @@ impl MusicSource for UrlSource {
|
|||||||
Ok(BrowseResult::Containers(vec![container]))
|
Ok(BrowseResult::Containers(vec![container]))
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ResolvedContent::Playlist { title: album, items }) => {
|
Ok(ResolvedContent::Playlist { title: playlist_title, items }) => {
|
||||||
let album = album.or_else(|| Some(display_title_for_url(url)));
|
let title = playlist_title.unwrap_or_else(|| display_title_for_url(url));
|
||||||
let didl_items: Vec<Item> = items
|
let container = self.build_url_playlist(url, title, items);
|
||||||
.into_iter()
|
Ok(BrowseResult::Containers(vec![container]))
|
||||||
.enumerate()
|
|
||||||
.map(|(i, t)| resolved_track_to_item(t, i, album.as_deref()))
|
|
||||||
.collect();
|
|
||||||
Ok(BrowseResult::Items(didl_items))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ResolvedContent::Track(t)) => {
|
Ok(ResolvedContent::Track(t)) => {
|
||||||
let item = resolved_track_to_item(t, 0, None);
|
// Pour un épisode unique, créer une playlist avec 1 item.
|
||||||
Ok(BrowseResult::Items(vec![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 }) => {
|
Ok(ResolvedContent::Stream { uri, title, mime_type }) => {
|
||||||
@@ -154,7 +164,6 @@ impl MusicSource for UrlSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Err(UrlResolverError::NotSupported(_)) => {
|
Err(UrlResolverError::NotSupported(_)) => {
|
||||||
// Texte libre (pas une URL) — les autres sources traitent normalement.
|
|
||||||
Ok(BrowseResult::Containers(vec![]))
|
Ok(BrowseResult::Containers(vec![]))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -196,32 +205,71 @@ impl MusicSource for UrlSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convertit un `ResolvedTrack` en `pmodidl::Item` jouable.
|
impl UrlSource {
|
||||||
fn resolved_track_to_item(t: ResolvedTrack, index: usize, album: Option<&str>) -> Item {
|
/// Crée un container playlist éphémère en mémoire depuis des tracks résolus.
|
||||||
let protocol_info = format!("http-get:*:{}:*", t.mime_type);
|
///
|
||||||
Item {
|
/// Les items gardent leurs URLs directes (RadioFrance, etc.) et leur MIME type
|
||||||
id: format!("url:item:{}", index),
|
/// d'origine — pas de proxy via pmoaudiocache, donc pas de conversion FLAC
|
||||||
parent_id: "url".to_string(),
|
/// et pas de problème avec les formats M4A/AAC.
|
||||||
restricted: Some("1".to_string()),
|
fn build_url_playlist(&self, url: &str, title: String, tracks: Vec<ResolvedTrack>) -> Container {
|
||||||
title: t.title,
|
let playlist_id = format!("urlsource-{:016x}", url_hash(url));
|
||||||
creator: t.artist.clone(),
|
let n = tracks.len();
|
||||||
class: "object.item.audioItem.musicTrack".to_string(),
|
|
||||||
artist: t.artist,
|
// Cover = album_art du premier épisode
|
||||||
album: t.album.or_else(|| album.map(|s| s.to_string())),
|
let album_art = tracks.first().and_then(|t| t.album_art.clone());
|
||||||
genre: None,
|
|
||||||
album_art: t.album_art,
|
let items: Vec<Item> = tracks
|
||||||
album_art_pk: None,
|
.into_iter()
|
||||||
date: None,
|
.enumerate()
|
||||||
original_track_number: Some(format!("{}", index + 1)),
|
.map(|(i, t)| {
|
||||||
resources: vec![Resource {
|
let protocol_info = format!("http-get:*:{}:*", t.mime_type);
|
||||||
protocol_info,
|
Item {
|
||||||
bits_per_sample: None,
|
id: format!("{}:{}", playlist_id, i),
|
||||||
sample_frequency: None,
|
parent_id: playlist_id.clone(),
|
||||||
nr_audio_channels: None,
|
restricted: Some("1".to_string()),
|
||||||
duration: t.duration,
|
title: t.title,
|
||||||
url: t.uri,
|
creator: t.artist.clone(),
|
||||||
}],
|
class: "object.item.audioItem.musicTrack".to_string(),
|
||||||
descriptions: vec![],
|
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.
|
/// 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 {
|
fn display_title_for_url(url: &str) -> String {
|
||||||
// Extraire l'hôte
|
|
||||||
let host = url
|
let host = url
|
||||||
.find("://")
|
.find("://")
|
||||||
.and_then(|i| {
|
.and_then(|i| {
|
||||||
@@ -267,7 +313,6 @@ fn display_title_for_url(url: &str) -> String {
|
|||||||
})
|
})
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
|
|
||||||
// Extraire le premier segment du path
|
|
||||||
let type_label = if url.contains("/album/") {
|
let type_label = if url.contains("/album/") {
|
||||||
"Album"
|
"Album"
|
||||||
} else if url.contains("/track/") {
|
} else if url.contains("/track/") {
|
||||||
@@ -286,3 +331,10 @@ fn display_title_for_url(url: &str) -> String {
|
|||||||
format!("{} ({})", type_label, host)
|
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