Files
pmomusic/pmomediaserver/src/content_handler.rs
Eric Coissac d49bb604ca feat: add UrlSource for arbitrary URL-based media playback
Introduces the pmourlsource crate as a standard MusicSource that resolves HTTP/HTTPS URLs via a priority-ordered UrlHandler registry. Includes specialized handlers for Qobuz and Radio France alongside an SSRF-safe generic scraper supporting playlists, feeds, and HTML audio. Integrates with existing browse flows, REST endpoints, and Android Web Share targets while updating source capability flags to correctly route URL queries.
2026-06-21 15:01:51 +02:00

744 lines
28 KiB
Rust

//! # ContentDirectory Handler - Gestionnaire du service ContentDirectory
//!
//! Ce module implémente la logique métier du service ContentDirectory en intégrant
//! les sources musicales enregistrées dans le registre.
//!
//! ## Fonctionnalités
//!
//! - **Navigation multi-sources** : Combine toutes les sources dans une hiérarchie
//! - **Browse** : Parcours des containers et items
//! - **Search** : Recherche dans les sources qui le supportent
//! - **Update ID** : Suivi des changements pour les notifications UPnP
use pmodidl::{Container, DIDLLite};
use pmosource::api::{get_source as get_source_from_registry, list_all_sources};
use pmosource::{BrowseResult, MediaSearchType, MusicSource, MusicSourceError, SearchQuery, SearchScope};
use pmodidl::ToXmlElement;
use std::collections::HashSet;
use std::sync::Arc;
/// Convertit des containers et items en XML DIDL-Lite
fn to_didl_lite(containers: &[Container], items: &[pmodidl::Item]) -> Result<String, String> {
let didl = DIDLLite {
xmlns: "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/".to_string(),
xmlns_upnp: Some("urn:schemas-upnp-org:metadata-1-0/upnp/".to_string()),
xmlns_dc: Some("http://purl.org/dc/elements/1.1/".to_string()),
xmlns_dlna: Some("urn:schemas-dlna-org:metadata-1-0/".to_string()),
xmlns_pv: None,
xmlns_sec: None,
containers: containers.to_vec(),
items: items.to_vec(),
};
// Retourne uniquement le corps DIDL, sans préfixer une seconde déclaration XML.
let body = didl.to_xml();
// Log le DIDL généré pour déboguer (limité aux 500 premiers caractères)
if !items.is_empty() {
tracing::debug!(
"Generated DIDL-Lite with {} items: {}",
items.len(),
&body[..body.len().min(800)]
);
}
Ok(body)
}
/// Extrait le texte de recherche depuis un critère UPnP SearchCriteria.
///
/// Exemples supportés :
/// - `dc:title contains "Pink Floyd"` → `"Pink Floyd"`
/// - `upnp:artist contains "Miles"` → `"Miles"`
/// - `Pink Floyd` (texte nu) → `"Pink Floyd"`
/// - `*` → `""`
fn extract_search_text(criteria: &str) -> &str {
let trimmed = criteria.trim();
if trimmed == "*" || trimmed.is_empty() {
return "";
}
// Pattern : <property> contains "<query>" (UPnP CDS search syntax)
if let Some(pos) = trimmed.find("contains") {
let after = trimmed[pos + "contains".len()..].trim();
if after.starts_with('"') && after.ends_with('"') && after.len() >= 2 {
return &after[1..after.len() - 1];
}
}
trimmed
}
/// Détermine le scope et le type de média depuis le ContainerID UPnP.
fn container_to_search_context(container_id: &str) -> (SearchScope, MediaSearchType) {
// Containers virtuels de résultats de recherche : qobuz:search:{scope}:{type}:{query}
// Le CP peut appeler Search sur ces containers — on préserve leur scope+type.
let search_parts: Vec<&str> = container_id.splitn(5, ':').collect();
if search_parts.len() >= 4 && search_parts[0] == "qobuz" && search_parts[1] == "search" {
let scope = if search_parts[2] == "favorites" {
SearchScope::UserLibrary
} else {
SearchScope::Catalog
};
let media_type = match search_parts.get(3).copied().unwrap_or("") {
"albums" => MediaSearchType::Albums,
"tracks" => MediaSearchType::Tracks,
"artists" => MediaSearchType::Artists,
"playlists" => MediaSearchType::Playlists,
_ => MediaSearchType::All,
};
return (scope, media_type);
}
// Scope UserLibrary : tout ce qui est sous qobuz:favorites
if container_id.starts_with("qobuz:favorites") {
let media_type = match container_id {
"qobuz:favorites:albums" => MediaSearchType::Albums,
"qobuz:favorites:tracks" => MediaSearchType::Tracks,
"qobuz:favorites:artists" => MediaSearchType::Artists,
"qobuz:favorites:playlists" => MediaSearchType::Playlists,
_ => MediaSearchType::All,
};
return (SearchScope::UserLibrary, media_type);
}
// Un artiste spécifique → rechercher ses albums dans le catalog
if container_id.starts_with("qobuz:artist:") {
return (SearchScope::Catalog, MediaSearchType::Albums);
}
// Un album spécifique → rechercher ses pistes
if container_id.starts_with("qobuz:album:") {
return (SearchScope::Catalog, MediaSearchType::Tracks);
}
// Scope Catalog : reste de la hiérarchie
let media_type = if container_id.starts_with("qobuz:discover:artists") {
MediaSearchType::Artists
} else if container_id.starts_with("qobuz:discover:albums") {
MediaSearchType::Albums
} else {
MediaSearchType::All
};
(SearchScope::Catalog, media_type)
}
/// Handler pour le service ContentDirectory
///
/// Ce handler gère toutes les opérations du ContentDirectory en utilisant
/// les sources musicales enregistrées dans le registre global.
pub struct ContentHandler;
impl ContentHandler {
/// Crée un nouveau ContentHandler
pub fn new() -> Self {
Self
}
/// Browse un container ou récupère les métadonnées d'un objet
///
/// # Arguments
///
/// * `object_id` - L'ID de l'objet à parcourir ("0" pour la racine)
/// * `browse_flag` - "BrowseMetadata" ou "BrowseDirectChildren"
/// * `starting_index` - Index de départ pour la pagination
/// * `requested_count` - Nombre d'éléments demandés (0 = tous)
///
/// # Returns
///
/// Un tuple contenant:
/// - Le résultat DIDL-Lite XML
/// - Le nombre d'éléments retournés
/// - Le nombre total d'éléments
/// - L'update ID
///
/// # Examples
///
/// ```ignore
/// let handler = ContentHandler::new();
/// let (didl, returned, total, update_id) =
/// handler.browse("0", "BrowseDirectChildren", 0, 0).await?;
/// ```
pub async fn browse(
&self,
object_id: &str,
browse_flag: &str,
starting_index: u32,
requested_count: u32,
) -> Result<(String, u32, u32, u32), String> {
tracing::debug!(
object_id = %object_id,
browse_flag = %browse_flag,
starting_index = %starting_index,
requested_count = %requested_count,
"ContentDirectory::Browse"
);
// Log rapide sur la branche flatten vs agrégée
if object_id == "0" {
let sources = list_all_sources().await;
tracing::info!(
"Browse root: sources.len() = {}, flatten = {}",
sources.len(),
sources.len() == 1
);
}
match browse_flag {
"BrowseMetadata" => self.browse_metadata(object_id).await,
"BrowseDirectChildren" => {
self.browse_direct_children(object_id, starting_index, requested_count)
.await
}
_ => Err(format!("Invalid BrowseFlag: {}", browse_flag)),
}
}
/// Browse les métadonnées d'un objet spécifique
async fn browse_metadata(&self, object_id: &str) -> Result<(String, u32, u32, u32), String> {
if object_id == "0" {
// Si un seul enfant, publier ce fils comme racine
let sources = list_all_sources().await;
if sources.len() == 1 {
let source = sources.into_iter().next().unwrap();
let mut container = source
.root_container()
.await
.map_err(|e| format!("Failed to get root container: {}", e))?;
// Le présenter comme la racine (id=0, parent=-1)
container.id = "0".to_string();
container.parent_id = "-1".to_string();
container.child_count = None; // compatibilité CP
let didl = to_didl_lite(&[container], &[])?;
let update_id = source.update_id().await.max(1);
tracing::debug!("BrowseMetadata root (flatten) didl_len={}B", didl.len());
return Ok((didl, 1, 1, update_id));
}
// Sinon retourner le container racine agrégé
let root = self.build_root_container().await;
let didl = to_didl_lite(&[root], &[])?;
tracing::debug!("BrowseMetadata root (aggregate) didl_len={}B", didl.len());
Ok((didl, 1, 1, 1))
} else {
// Essayer de trouver l'objet dans les sources
// Vérifier si c'est un container racine d'une source
if let Some(source) = get_source_from_registry(object_id).await {
let container = source
.root_container()
.await
.map_err(|e| format!("Failed to get root container: {}", e))?;
let didl = to_didl_lite(&[container], &[])?;
let update_id = source.update_id().await.max(1);
tracing::debug!(
"BrowseMetadata source_root id={} didl_len={}B",
object_id,
didl.len()
);
return Ok((didl, 1, 1, update_id));
}
// Try to get item metadata first (for leaf items)
for source in list_all_sources().await {
match source.get_item(object_id).await {
Ok(item) => {
let didl = to_didl_lite(&[], &[item])?;
let update_id = source.update_id().await.max(1);
tracing::debug!(
"BrowseMetadata item id={} didl_len={}B",
object_id,
didl.len()
);
return Ok((didl, 1, 1, update_id));
}
Err(MusicSourceError::ObjectNotFound(_))
| Err(MusicSourceError::NotSupported(_)) => continue,
Err(e) => {
tracing::debug!("get_item failed for {}: {}", object_id, e);
continue;
}
}
}
// Fallback to browse for containers
let mut non_not_found_error: Option<String> = None;
for source in list_all_sources().await {
match source.browse(object_id).await {
Ok(result) => {
// L'objet a été trouvé, retourner ses métadonnées
match result {
BrowseResult::Containers(containers) => {
if let Some(container) = containers.first() {
let didl = to_didl_lite(&[container.clone()], &[])?;
let update_id = source.update_id().await.max(1);
return Ok((didl, 1, 1, update_id));
}
}
BrowseResult::Items(items) => {
// object_id is a container (album, playlist…) whose
// browse() returns its children as Items. BrowseMetadata
// must return the container itself, not the first child.
let title = items
.first()
.and_then(|i| i.album.as_deref())
.unwrap_or(object_id)
.to_string();
let album_art =
items.first().and_then(|i| i.album_art.clone());
let container = Container {
id: object_id.to_string(),
parent_id: "0".to_string(),
restricted: Some("1".to_string()),
child_count: Some(items.len().to_string()),
searchable: Some("0".to_string()),
title,
class: "object.container".to_string(),
artist: None,
album_art,
containers: vec![],
items: vec![],
};
let didl = to_didl_lite(&[container], &[])?;
let update_id = source.update_id().await.max(1);
return Ok((didl, 1, 1, update_id));
}
BrowseResult::Mixed { containers, items } => {
if let Some(container) = containers.first() {
let didl = to_didl_lite(&[container.clone()], &[])?;
let update_id = source.update_id().await.max(1);
return Ok((didl, 1, 1, update_id));
} else if let Some(item) = items.first() {
let didl = to_didl_lite(&[], &[item.clone()])?;
let update_id = source.update_id().await.max(1);
return Ok((didl, 1, 1, update_id));
}
}
}
}
Err(MusicSourceError::ObjectNotFound(_)) => continue,
Err(e) => {
non_not_found_error = Some(e.to_string());
break;
}
}
}
if let Some(err) = non_not_found_error {
Err(format!("Browse failed: {}", err))
} else {
Err(format!("Object not found: {}", object_id))
}
}
}
/// Browse les enfants directs d'un container
async fn browse_direct_children(
&self,
object_id: &str,
starting_index: u32,
requested_count: u32,
) -> Result<(String, u32, u32, u32), String> {
if object_id == "0" {
// Si un seul enfant, publier directement ses enfants comme racine
let sources = list_all_sources().await;
if sources.len() == 1 {
let source = sources.into_iter().next().unwrap();
let source_id = source.id().to_string();
// Récupérer les enfants du container racine de la source
let mut result = source
.browse(&source_id)
.await
.map_err(|e| format!("Browse failed: {}", e))?;
// Re-mapper parentID sur "0" pour éviter des parents inexistants côté CP
match &mut result {
BrowseResult::Containers(c) => {
for cont in c.iter_mut() {
if cont.parent_id == source_id {
cont.parent_id = "0".to_string();
}
}
}
BrowseResult::Items(i) => {
for item in i.iter_mut() {
if item.parent_id == source_id {
item.parent_id = "0".to_string();
}
}
}
BrowseResult::Mixed { containers, items } => {
for cont in containers.iter_mut() {
if cont.parent_id == source_id {
cont.parent_id = "0".to_string();
}
}
for item in items.iter_mut() {
if item.parent_id == source_id {
item.parent_id = "0".to_string();
}
}
}
}
return self
.browse_result_to_didl("0", result, source, starting_index, requested_count)
.await;
}
// Retourner toutes les sources comme enfants de la racine
return self.browse_root(starting_index, requested_count).await;
}
// Vérifier si c'est le container racine d'une source
if let Some(source) = get_source_from_registry(object_id).await {
return self
.browse_source_root(source, starting_index, requested_count)
.await;
}
// Sinon, chercher dans les sources
let mut non_not_found_error: Option<String> = None;
for source in list_all_sources().await {
match source.browse(object_id).await {
Ok(result) => {
return self
.browse_result_to_didl(
object_id,
result,
source,
starting_index,
requested_count,
)
.await;
}
Err(MusicSourceError::ObjectNotFound(_)) => continue,
Err(e) => {
non_not_found_error = Some(e.to_string());
break;
}
}
}
if let Some(err) = non_not_found_error {
Err(format!("Browse failed: {}", err))
} else {
Err(format!("Container not found: {}", object_id))
}
}
/// Browse la racine (liste toutes les sources)
async fn browse_root(
&self,
starting_index: u32,
requested_count: u32,
) -> Result<(String, u32, u32, u32), String> {
let sources = list_all_sources().await;
let mut containers = Vec::new();
for source in sources.iter() {
let container = source
.root_container()
.await
.map_err(|e| format!("Failed to get root container: {}", e))?;
let mut container = container;
container.searchable = container.searchable.or_else(|| Some("1".to_string()));
containers.push(container);
}
// Appliquer la pagination
let total = containers.len();
let start = starting_index as usize;
let count = if requested_count == 0 {
total - start
} else {
requested_count as usize
};
let paginated: Vec<Container> = containers.into_iter().skip(start).take(count).collect();
let returned = paginated.len();
let didl = to_didl_lite(&paginated, &[])?;
// Aggregate update IDs across all sources
let mut combined_id = 0u32;
for source in list_all_sources().await {
combined_id = combined_id.wrapping_add(source.update_id().await);
}
let update_id = combined_id.max(1);
Ok((didl, returned as u32, total as u32, update_id))
}
/// Browse le container racine d'une source spécifique
async fn browse_source_root(
&self,
source: Arc<dyn MusicSource>,
starting_index: u32,
requested_count: u32,
) -> Result<(String, u32, u32, u32), String> {
let source_id = source.id().to_string();
let result = source
.browse(&source_id)
.await
.map_err(|e| format!("Browse failed: {}", e))?;
self.browse_result_to_didl(&source_id, result, source, starting_index, requested_count)
.await
}
/// Convertit un BrowseResult en DIDL-Lite XML avec pagination
async fn browse_result_to_didl(
&self,
object_id: &str,
result: BrowseResult,
source: Arc<dyn MusicSource>,
starting_index: u32,
requested_count: u32,
) -> Result<(String, u32, u32, u32), String> {
let (containers, items) = match result {
BrowseResult::Containers(c) => (c, vec![]),
BrowseResult::Items(i) => (vec![], i),
BrowseResult::Mixed { containers, items } => (containers, items),
};
// Filter out any container that matches the object_id being browsed
// (to avoid containers appearing as children of themselves)
let mut containers: Vec<Container> = containers
.into_iter()
.filter(|c| c.id != object_id)
.collect();
let mut items = items;
// Log avant déduplication
tracing::debug!(
"BrowseResult before dedup: containers={}, items={}",
containers.len(),
items.len()
);
// Deduplicate containers/items by id to avoid doubles in the response
let mut seen_containers = HashSet::new();
containers.retain(|c| seen_containers.insert(c.id.clone()));
let mut seen_items = HashSet::new();
items.retain(|i| seen_items.insert(i.id.clone()));
// Log après déduplication
tracing::debug!(
"BrowseResult after dedup: containers={}, items={}",
containers.len(),
items.len()
);
// Trier les containers par titre pour ordre déterministe (alphabétique, insensible à la casse)
// Les HashMap/HashSet utilisés pour la déduplication ne garantissent pas l'ordre,
// et les APIs externes peuvent renvoyer dans un ordre non-déterministe
// On NE trie PAS les items (pistes) car l'ordre original (numéro de piste) est important
containers.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
// Calculer le total avant pagination
let total = (containers.len() + items.len()) as u32;
// Appliquer la pagination
let start = starting_index as usize;
let count = if requested_count == 0 {
total as usize - start
} else {
requested_count as usize
};
// Pagination sur les containers d'abord, puis les items
let total_containers = containers.len();
if start < total_containers {
// On commence dans les containers
containers = containers.into_iter().skip(start).collect();
let remaining = count.saturating_sub(containers.len());
containers.truncate(count);
if remaining > 0 && !items.is_empty() {
items.truncate(remaining);
} else {
items.clear();
}
} else {
// On commence dans les items
containers.clear();
let item_start = start - total_containers;
items = items.into_iter().skip(item_start).take(count).collect();
}
let returned = (containers.len() + items.len()) as u32;
let didl = to_didl_lite(&containers, &items)?;
let update_id = source.update_id().await.max(1);
Ok((didl, returned, total, update_id))
}
/// Construit le container racine du MediaServer
async fn build_root_container(&self) -> Container {
let sources = list_all_sources().await;
let _child_count = sources.len();
Container {
id: "0".to_string(),
parent_id: "-1".to_string(),
restricted: Some("1".to_string()),
// Laisser childCount absent sur la racine pour maximiser la compatibilité (BubbleUPnP)
child_count: None,
searchable: Some("1".to_string()),
title: "PMOMusic".to_string(),
class: "object.container".to_string(),
artist: None,
album_art: None,
containers: vec![],
items: vec![],
}
}
/// Recherche dans toutes les sources qui supportent la recherche
///
/// # Arguments
///
/// * `container_id` - ID du container dans lequel rechercher ("0" = partout)
/// * `search_criteria` - Critères de recherche UPnP
///
/// # Returns
///
/// Les mêmes informations que browse()
pub async fn search(
&self,
container_id: &str,
search_criteria: &str,
) -> Result<(String, u32, u32, u32), String> {
tracing::debug!(
container_id = %container_id,
search_criteria = %search_criteria,
"ContentDirectory::Search"
);
let text = extract_search_text(search_criteria);
let (scope, media_type) = container_to_search_context(container_id);
tracing::debug!(
container_id,
search_criteria,
extracted_text = text,
scope = ?scope,
media_type = ?media_type,
"ContentHandler::search resolved"
);
let query = SearchQuery {
text: text.to_string(),
media_type,
scope,
limit: 200,
offset: 0,
};
let mut all_containers = Vec::new();
let mut all_items = Vec::new();
// Si le texte ressemble à une URL, seules les sources qui gèrent les URLs
// sont interrogées — les autres (Qobuz, etc.) interpréteraient l'URL comme
// du texte libre et renverraient des résultats parasites.
let is_url_query = text.starts_with("http://") || text.starts_with("https://");
for source in list_all_sources().await {
let caps = source.capabilities();
if !caps.supports_search {
continue;
}
if is_url_query && !caps.handles_url_input {
continue;
}
if let Ok(result) = source.search(&query).await {
match result {
BrowseResult::Containers(c) => all_containers.extend(c),
BrowseResult::Items(i) => all_items.extend(i),
BrowseResult::Mixed { containers, items } => {
all_containers.extend(containers);
all_items.extend(items);
}
}
}
}
let total = (all_containers.len() + all_items.len()) as u32;
let didl = to_didl_lite(&all_containers, &all_items)?;
let update_id = if total > 0 {
let sources = list_all_sources().await;
let mut combined_id = 0u32;
for source in sources {
combined_id = combined_id.wrapping_add(source.update_id().await);
}
combined_id.max(1)
} else {
1
};
Ok((didl, total, total, update_id))
}
/// Retourne les capacités de recherche
pub async fn get_search_capabilities(&self) -> String {
// Capacités de recherche de base UPnP
"dc:title,dc:creator,upnp:artist,upnp:album,upnp:genre".to_string()
}
/// Retourne les capacités de tri
pub async fn get_sort_capabilities(&self) -> String {
// Capacités de tri de base UPnP
"dc:title,dc:date,upnp:artist,upnp:album".to_string()
}
/// Retourne le system update ID global
pub async fn get_system_update_id(&self) -> u32 {
let sources = list_all_sources().await;
// Combiner les update IDs de toutes les sources
let mut combined_id = 0u32;
for source in sources {
combined_id = combined_id.wrapping_add(source.update_id().await);
}
combined_id.max(1)
}
}
impl Default for ContentHandler {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_content_handler_creation() {
let handler = ContentHandler::new();
let capabilities = handler.get_search_capabilities().await;
assert!(capabilities.contains("dc:title"));
}
#[tokio::test]
async fn test_browse_root_empty() {
let handler = ContentHandler::new();
let result = handler.browse("0", "BrowseDirectChildren", 0, 0).await;
assert!(result.is_ok());
let (didl, returned, total, update_id) = result.unwrap();
assert_eq!(returned, 0);
assert_eq!(total, 0);
assert_eq!(update_id, 1);
assert!(didl.contains("DIDL-Lite"));
}
#[tokio::test]
async fn test_get_system_update_id() {
let handler = ContentHandler::new();
let update_id = handler.get_system_update_id().await;
assert_eq!(update_id, 1); // No sources registered -> minimum 1
}
}