2026-06-14 13:55:38 +02:00
|
|
|
use crate::handler::{ResolvedContent, ResolvedTrack, UrlResolver, UrlResolverError};
|
|
|
|
|
use async_trait::async_trait;
|
|
|
|
|
use pmodidl::{Container, Item, Resource};
|
2026-06-14 14:31:51 +02:00
|
|
|
use pmosource::api::get_source as get_source_from_registry;
|
2026-06-14 13:55:38 +02:00
|
|
|
use pmosource::{BrowseResult, MusicSource, MusicSourceError, SearchQuery, SourceCapabilities};
|
2026-06-14 21:55:01 +02:00
|
|
|
use std::collections::hash_map::DefaultHasher;
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use std::hash::{Hash, Hasher};
|
|
|
|
|
use std::sync::RwLock;
|
|
|
|
|
use std::sync::Arc;
|
2026-06-14 13:55:38 +02:00
|
|
|
use std::time::SystemTime;
|
|
|
|
|
|
|
|
|
|
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/url-source.webp");
|
|
|
|
|
|
2026-06-14 21:55:01 +02:00
|
|
|
/// Store éphémère pour les playlists URL : playlist_id → (container, items)
|
|
|
|
|
type PlaylistStore = Arc<RwLock<HashMap<String, (Container, Vec<Item>)>>>;
|
|
|
|
|
|
2026-06-14 13:55:38 +02:00
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub struct UrlSource {
|
|
|
|
|
resolver: UrlResolver,
|
2026-06-14 21:55:01 +02:00
|
|
|
base_url: String,
|
|
|
|
|
playlists: PlaylistStore,
|
2026-06-14 13:55:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl UrlSource {
|
2026-06-14 21:55:01 +02:00
|
|
|
pub fn new(resolver: UrlResolver, base_url: String) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
resolver,
|
|
|
|
|
base_url,
|
|
|
|
|
playlists: Arc::new(RwLock::new(HashMap::new())),
|
|
|
|
|
}
|
2026-06-14 13:55:38 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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![])),
|
2026-06-14 21:55:01 +02:00
|
|
|
// 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())),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-14 15:30:23 +02:00
|
|
|
// 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()))
|
|
|
|
|
}
|
2026-06-14 13:55:38 +02:00
|
|
|
_ => 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 {
|
2026-06-14 14:31:51 +02:00
|
|
|
source_id,
|
2026-06-14 13:55:38 +02:00
|
|
|
container_id,
|
|
|
|
|
}) => {
|
2026-06-14 14:31:51 +02:00
|
|
|
if let Some(source) = get_source_from_registry(&source_id).await {
|
2026-06-14 15:30:23 +02:00
|
|
|
match source.get_container(&container_id).await {
|
|
|
|
|
Ok(Some(mut container)) => {
|
|
|
|
|
container.parent_id = source_id;
|
2026-06-14 14:31:51 +02:00
|
|
|
return Ok(BrowseResult::Containers(vec![container]));
|
|
|
|
|
}
|
2026-06-14 21:55:01 +02:00
|
|
|
Ok(None) => {}
|
2026-06-14 14:31:51 +02:00
|
|
|
Err(e) => {
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
source_id = %source_id,
|
|
|
|
|
container_id = %container_id,
|
|
|
|
|
error = %e,
|
2026-06-14 21:55:01 +02:00
|
|
|
"UrlSource: get_container échoué"
|
2026-06-14 14:31:51 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-14 21:55:01 +02:00
|
|
|
match source.get_item(&container_id).await {
|
|
|
|
|
Ok(item) => return Ok(BrowseResult::Items(vec![item])),
|
|
|
|
|
Err(_) => {}
|
|
|
|
|
}
|
2026-06-14 14:31:51 +02:00
|
|
|
}
|
2026-06-14 13:55:38 +02:00
|
|
|
let title = display_title_for_url(url);
|
|
|
|
|
let container = Container {
|
|
|
|
|
id: container_id,
|
2026-06-14 14:31:51 +02:00
|
|
|
parent_id: source_id,
|
2026-06-14 13:55:38 +02:00
|
|
|
restricted: Some("1".to_string()),
|
2026-06-14 15:30:23 +02:00
|
|
|
child_count: Some("1".to_string()),
|
|
|
|
|
searchable: Some("1".to_string()),
|
2026-06-14 13:55:38 +02:00
|
|
|
title,
|
|
|
|
|
class: "object.container".to_string(),
|
|
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
|
|
|
|
containers: vec![],
|
|
|
|
|
items: vec![],
|
|
|
|
|
};
|
|
|
|
|
Ok(BrowseResult::Containers(vec![container]))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 21:55:01 +02:00
|
|
|
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]))
|
2026-06-14 13:55:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(ResolvedContent::Track(t)) => {
|
2026-06-14 21:55:01 +02:00
|
|
|
// 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]))
|
2026-06-14 13:55:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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![])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 21:55:01 +02:00
|
|
|
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
|
2026-06-14 13:55:38 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-14 21:55:01 +02:00
|
|
|
|
|
|
|
|
/// 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()
|
|
|
|
|
}
|