//! # 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, MusicSource, MusicSourceError}; use pmoutils::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 { 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) } /// 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 = 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) => { 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)); } } 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 = 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 = 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, 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, 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 = 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() ); // 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 mut all_containers = Vec::new(); let mut all_items = Vec::new(); // Rechercher dans toutes les sources qui supportent la recherche for source in list_all_sources().await { if source.capabilities().supports_search { if let Ok(result) = source.search(search_criteria).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)?; // Compute a global update ID from active sources, ensure it starts at 1 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 } }