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.
341 lines
12 KiB
Rust
341 lines
12 KiB
Rust
use crate::handler::{ResolvedContent, ResolvedTrack, UrlResolver, UrlResolverError};
|
|
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, base_url: String) -> Self {
|
|
Self {
|
|
resolver,
|
|
base_url,
|
|
playlists: Arc::new(RwLock::new(HashMap::new())),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl MusicSource for UrlSource {
|
|
fn name(&self) -> &str {
|
|
"URL / Partage"
|
|
}
|
|
|
|
fn id(&self) -> &str {
|
|
"url"
|
|
}
|
|
|
|
fn default_image(&self) -> &[u8] {
|
|
DEFAULT_IMAGE
|
|
}
|
|
|
|
fn capabilities(&self) -> SourceCapabilities {
|
|
SourceCapabilities {
|
|
supports_search: true,
|
|
handles_url_input: true,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
async fn root_container(&self) -> pmosource::Result<Container> {
|
|
Ok(Container {
|
|
id: "url".to_string(),
|
|
parent_id: "0".to_string(),
|
|
restricted: Some("1".to_string()),
|
|
child_count: None,
|
|
searchable: Some("1".to_string()),
|
|
title: "URL / Partage".to_string(),
|
|
class: "object.container".to_string(),
|
|
artist: None,
|
|
album_art: None,
|
|
containers: vec![],
|
|
items: vec![],
|
|
})
|
|
}
|
|
|
|
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:") => {
|
|
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
|
|
}
|
|
_ => Err(MusicSourceError::ObjectNotFound(object_id.to_string())),
|
|
}
|
|
}
|
|
|
|
async fn search(&self, query: &SearchQuery) -> pmosource::Result<BrowseResult> {
|
|
let url = query.text.trim();
|
|
|
|
if url.is_empty() {
|
|
return Ok(BrowseResult::Containers(vec![]));
|
|
}
|
|
|
|
match self.resolver.resolve(url).await {
|
|
Ok(ResolvedContent::SourceContainer {
|
|
source_id,
|
|
container_id,
|
|
}) => {
|
|
if let Some(source) = get_source_from_registry(&source_id).await {
|
|
match source.get_container(&container_id).await {
|
|
Ok(Some(mut container)) => {
|
|
container.parent_id = source_id;
|
|
return Ok(BrowseResult::Containers(vec![container]));
|
|
}
|
|
Ok(None) => {}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
source_id = %source_id,
|
|
container_id = %container_id,
|
|
error = %e,
|
|
"UrlSource: get_container échoué"
|
|
);
|
|
}
|
|
}
|
|
match source.get_item(&container_id).await {
|
|
Ok(item) => return Ok(BrowseResult::Items(vec![item])),
|
|
Err(_) => {}
|
|
}
|
|
}
|
|
let title = display_title_for_url(url);
|
|
let container = Container {
|
|
id: container_id,
|
|
parent_id: source_id,
|
|
restricted: Some("1".to_string()),
|
|
child_count: Some("1".to_string()),
|
|
searchable: Some("1".to_string()),
|
|
title,
|
|
class: "object.container".to_string(),
|
|
artist: None,
|
|
album_art: None,
|
|
containers: vec![],
|
|
items: vec![],
|
|
};
|
|
Ok(BrowseResult::Containers(vec![container]))
|
|
}
|
|
|
|
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)) => {
|
|
// 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 }) => {
|
|
let item = stream_to_item(uri, title, mime_type);
|
|
Ok(BrowseResult::Items(vec![item]))
|
|
}
|
|
|
|
Err(UrlResolverError::NotSupported(_)) => {
|
|
Ok(BrowseResult::Containers(vec![]))
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(url = %url, error = %e, "UrlSource: résolution échouée");
|
|
Err(MusicSourceError::BrowseError(format!(
|
|
"Résolution URL échouée : {}",
|
|
e
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn resolve_uri(&self, object_id: &str) -> pmosource::Result<String> {
|
|
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
|
|
}
|
|
|
|
fn supports_fifo(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
async fn append_track(&self, _track: Item) -> pmosource::Result<()> {
|
|
Err(MusicSourceError::FifoNotSupported)
|
|
}
|
|
|
|
async fn remove_oldest(&self) -> pmosource::Result<Option<Item>> {
|
|
Err(MusicSourceError::FifoNotSupported)
|
|
}
|
|
|
|
async fn update_id(&self) -> u32 {
|
|
1
|
|
}
|
|
|
|
async fn last_change(&self) -> Option<SystemTime> {
|
|
None
|
|
}
|
|
|
|
async fn get_items(&self, _offset: usize, _count: usize) -> pmosource::Result<Vec<Item>> {
|
|
Ok(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
|
|
}
|
|
}
|
|
|
|
/// Convertit un flux continu en `pmodidl::Item`.
|
|
fn stream_to_item(uri: String, title: String, mime_type: String) -> Item {
|
|
let protocol_info = format!("http-get:*:{}:*", mime_type);
|
|
Item {
|
|
id: "url:item:0".to_string(),
|
|
parent_id: "url".to_string(),
|
|
restricted: Some("1".to_string()),
|
|
title,
|
|
creator: None,
|
|
class: "object.item.audioItem.audioBroadcast".to_string(),
|
|
artist: None,
|
|
album: None,
|
|
genre: None,
|
|
album_art: None,
|
|
album_art_pk: None,
|
|
date: None,
|
|
original_track_number: None,
|
|
resources: vec![Resource {
|
|
protocol_info,
|
|
bits_per_sample: None,
|
|
sample_frequency: None,
|
|
nr_audio_channels: None,
|
|
duration: None,
|
|
url: uri,
|
|
}],
|
|
descriptions: vec![],
|
|
}
|
|
}
|
|
|
|
/// Extrait un titre lisible depuis une URL.
|
|
fn display_title_for_url(url: &str) -> String {
|
|
let host = url
|
|
.find("://")
|
|
.and_then(|i| {
|
|
let after = &url[i + 3..];
|
|
let end = after.find('/').unwrap_or(after.len());
|
|
Some(&after[..end])
|
|
})
|
|
.unwrap_or("");
|
|
|
|
let type_label = if url.contains("/album/") {
|
|
"Album"
|
|
} else if url.contains("/track/") {
|
|
"Titre"
|
|
} else if url.contains("/playlist/") {
|
|
"Playlist"
|
|
} else if url.contains("/artist/") {
|
|
"Artiste"
|
|
} else {
|
|
"Contenu"
|
|
};
|
|
|
|
if host.is_empty() {
|
|
type_label.to_string()
|
|
} else {
|
|
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()
|
|
}
|