Cette mise à jour ajoute la gestion de la durée dans les métadonnées UPnP et OpenHome. Les changements incluent : - Ajout de la dépendance `pmoutils` dans `pmocontrol` - Mise à jour du modèle `Item` pour inclure le champ `duration` - Implémentation de la sérialisation DIDL-Lite avec `pmodidl` et `pmoutils` - Ajout de la gestion de la durée dans les renderers UPnP et OpenHome - Extraction de la durée depuis les métadonnées DIDL-Lite - Support du fallback vers la durée du DIDL lorsque l'ampli ne la retourne pas - Mise à jour des constructeurs d'items pour inclure la durée calculée pour les streams radio Ces modifications permettent une meilleure gestion des durées de pistes audio, notamment pour les streams radio avec des durées calculées à partir des timestamps de début/fin.
615 lines
23 KiB
Rust
615 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 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<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()
|
|
);
|
|
|
|
// 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
|
|
}
|
|
}
|