push-zwznsplyvnyp #103

Merged
eric merged 5 commits from push-zwznsplyvnyp into main 2026-06-21 18:41:47 +02:00
3 changed files with 160 additions and 65 deletions
Showing only changes of commit 009545de40 - Show all commits

View File

@@ -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");

View File

@@ -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),
}
}

View File

@@ -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()
}