- Update crate and project versions from `v0.3.39` to "\"" - Reorder imports in media_server.rs for consistency – Move `error_codes` import before other soap modules in pmoupnp – Reorder crate imports to group external and local deps logically (soap_client, then core types) - Improve Browse response formatting: wrap long expressions for readability – Add deterministic sort by `+dc:title` in Browse requests (alphabetical, ascending) – Preserve track ordering by NOT sorting items (track number preserved), only containers - Add explicit sort of container list in content_handler.rs for deterministic output
621 lines
23 KiB
Rust
621 lines
23 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, MusicSource, MusicSourceError};
|
|
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)
|
|
}
|
|
|
|
/// 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) => {
|
|
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<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 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
|
|
}
|
|
}
|