2025-10-17 09:01:24 +02:00
|
|
|
//! # 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};
|
2025-10-19 13:42:29 +02:00
|
|
|
use pmosource::api::{get_source as get_source_from_registry, list_all_sources};
|
2026-06-11 20:02:00 +02:00
|
|
|
use pmosource::{BrowseResult, MediaSearchType, MusicSource, MusicSourceError, SearchQuery, SearchScope};
|
2026-03-01 21:43:00 +01:00
|
|
|
use pmodidl::ToXmlElement;
|
2025-11-28 22:15:44 +01:00
|
|
|
use std::collections::HashSet;
|
2025-10-17 09:01:24 +02:00
|
|
|
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(),
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-28 19:03:27 +01:00
|
|
|
// Retourne uniquement le corps DIDL, sans préfixer une seconde déclaration XML.
|
|
|
|
|
let body = didl.to_xml();
|
2026-01-23 22:02:03 +01:00
|
|
|
|
|
|
|
|
// 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)]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-28 19:03:27 +01:00
|
|
|
Ok(body)
|
2025-10-17 09:01:24 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 20:02:00 +02:00
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-17 09:01:24 +02:00
|
|
|
/// 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"
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-28 22:15:44 +01:00
|
|
|
// 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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-17 09:01:24 +02:00
|
|
|
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" {
|
2025-11-28 22:15:44 +01:00
|
|
|
// 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);
|
2025-11-29 14:19:16 +01:00
|
|
|
tracing::debug!("BrowseMetadata root (flatten) didl_len={}B", didl.len());
|
2025-11-28 22:15:44 +01:00
|
|
|
return Ok((didl, 1, 1, update_id));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sinon retourner le container racine agrégé
|
2025-10-17 09:01:24 +02:00
|
|
|
let root = self.build_root_container().await;
|
2025-10-27 22:06:24 +01:00
|
|
|
let didl = to_didl_lite(&[root], &[])?;
|
2025-11-29 14:19:16 +01:00
|
|
|
tracing::debug!("BrowseMetadata root (aggregate) didl_len={}B", didl.len());
|
2025-10-27 22:06:24 +01:00
|
|
|
Ok((didl, 1, 1, 1))
|
2025-10-17 09:01:24 +02:00
|
|
|
} else {
|
|
|
|
|
// Essayer de trouver l'objet dans les sources
|
|
|
|
|
// Vérifier si c'est un container racine d'une source
|
2025-10-17 12:45:18 +02:00
|
|
|
if let Some(source) = get_source_from_registry(object_id).await {
|
2025-10-17 09:01:24 +02:00
|
|
|
let container = source
|
|
|
|
|
.root_container()
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| format!("Failed to get root container: {}", e))?;
|
|
|
|
|
let didl = to_didl_lite(&[container], &[])?;
|
2025-11-28 20:45:31 +01:00
|
|
|
let update_id = source.update_id().await.max(1);
|
2025-11-28 23:13:59 +01:00
|
|
|
tracing::debug!(
|
|
|
|
|
"BrowseMetadata source_root id={} didl_len={}B",
|
|
|
|
|
object_id,
|
|
|
|
|
didl.len()
|
|
|
|
|
);
|
2025-10-17 09:01:24 +02:00
|
|
|
return Ok((didl, 1, 1, update_id));
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-26 21:40:24 +01:00
|
|
|
// 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])?;
|
2025-11-28 20:45:31 +01:00
|
|
|
let update_id = source.update_id().await.max(1);
|
2025-11-28 23:13:59 +01:00
|
|
|
tracing::debug!(
|
|
|
|
|
"BrowseMetadata item id={} didl_len={}B",
|
|
|
|
|
object_id,
|
|
|
|
|
didl.len()
|
|
|
|
|
);
|
2025-11-26 21:40:24 +01:00
|
|
|
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
|
2025-10-19 19:29:10 +02:00
|
|
|
let mut non_not_found_error: Option<String> = None;
|
2025-10-17 12:45:18 +02:00
|
|
|
for source in list_all_sources().await {
|
2025-10-19 19:29:10 +02:00
|
|
|
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()], &[])?;
|
2025-11-28 20:45:31 +01:00
|
|
|
let update_id = source.update_id().await.max(1);
|
2025-10-19 19:29:10 +02:00
|
|
|
return Ok((didl, 1, 1, update_id));
|
|
|
|
|
}
|
2025-10-17 09:01:24 +02:00
|
|
|
}
|
2025-10-19 19:29:10 +02:00
|
|
|
BrowseResult::Items(items) => {
|
2026-06-14 13:55:38 +02:00
|
|
|
// 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));
|
2025-10-17 09:01:24 +02:00
|
|
|
}
|
2025-10-19 19:29:10 +02:00
|
|
|
BrowseResult::Mixed { containers, items } => {
|
|
|
|
|
if let Some(container) = containers.first() {
|
|
|
|
|
let didl = to_didl_lite(&[container.clone()], &[])?;
|
2025-11-28 20:45:31 +01:00
|
|
|
let update_id = source.update_id().await.max(1);
|
2025-10-19 19:29:10 +02:00
|
|
|
return Ok((didl, 1, 1, update_id));
|
|
|
|
|
} else if let Some(item) = items.first() {
|
|
|
|
|
let didl = to_didl_lite(&[], &[item.clone()])?;
|
2025-11-28 20:45:31 +01:00
|
|
|
let update_id = source.update_id().await.max(1);
|
2025-10-19 19:29:10 +02:00
|
|
|
return Ok((didl, 1, 1, update_id));
|
|
|
|
|
}
|
2025-10-17 09:01:24 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-19 19:29:10 +02:00
|
|
|
Err(MusicSourceError::ObjectNotFound(_)) => continue,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
non_not_found_error = Some(e.to_string());
|
|
|
|
|
break;
|
|
|
|
|
}
|
2025-10-17 09:01:24 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-19 19:29:10 +02:00
|
|
|
if let Some(err) = non_not_found_error {
|
|
|
|
|
Err(format!("Browse failed: {}", err))
|
|
|
|
|
} else {
|
|
|
|
|
Err(format!("Object not found: {}", object_id))
|
|
|
|
|
}
|
2025-10-17 09:01:24 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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" {
|
2025-11-28 22:15:44 +01:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-17 09:01:24 +02:00
|
|
|
// 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
|
2025-10-17 12:45:18 +02:00
|
|
|
if let Some(source) = get_source_from_registry(object_id).await {
|
2025-10-17 09:01:24 +02:00
|
|
|
return self
|
|
|
|
|
.browse_source_root(source, starting_index, requested_count)
|
|
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sinon, chercher dans les sources
|
2025-10-19 19:29:10 +02:00
|
|
|
let mut non_not_found_error: Option<String> = None;
|
2025-10-17 12:45:18 +02:00
|
|
|
for source in list_all_sources().await {
|
2025-10-19 19:29:10 +02:00
|
|
|
match source.browse(object_id).await {
|
|
|
|
|
Ok(result) => {
|
|
|
|
|
return self
|
2025-11-29 14:19:16 +01:00
|
|
|
.browse_result_to_didl(
|
|
|
|
|
object_id,
|
|
|
|
|
result,
|
|
|
|
|
source,
|
|
|
|
|
starting_index,
|
|
|
|
|
requested_count,
|
|
|
|
|
)
|
2025-10-19 19:29:10 +02:00
|
|
|
.await;
|
|
|
|
|
}
|
|
|
|
|
Err(MusicSourceError::ObjectNotFound(_)) => continue,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
non_not_found_error = Some(e.to_string());
|
|
|
|
|
break;
|
|
|
|
|
}
|
2025-10-17 09:01:24 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-19 19:29:10 +02:00
|
|
|
if let Some(err) = non_not_found_error {
|
|
|
|
|
Err(format!("Browse failed: {}", err))
|
|
|
|
|
} else {
|
|
|
|
|
Err(format!("Container not found: {}", object_id))
|
|
|
|
|
}
|
2025-10-17 09:01:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Browse la racine (liste toutes les sources)
|
|
|
|
|
async fn browse_root(
|
|
|
|
|
&self,
|
|
|
|
|
starting_index: u32,
|
|
|
|
|
requested_count: u32,
|
|
|
|
|
) -> Result<(String, u32, u32, u32), String> {
|
2025-10-17 12:45:18 +02:00
|
|
|
let sources = list_all_sources().await;
|
2025-10-17 09:01:24 +02:00
|
|
|
|
|
|
|
|
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))?;
|
2025-10-26 23:49:20 +01:00
|
|
|
let mut container = container;
|
|
|
|
|
container.searchable = container.searchable.or_else(|| Some("1".to_string()));
|
2025-10-17 09:01:24 +02:00
|
|
|
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
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-19 13:42:29 +02:00
|
|
|
let paginated: Vec<Container> = containers.into_iter().skip(start).take(count).collect();
|
2025-10-17 09:01:24 +02:00
|
|
|
|
|
|
|
|
let returned = paginated.len();
|
|
|
|
|
let didl = to_didl_lite(&paginated, &[])?;
|
|
|
|
|
|
2025-10-26 23:49:20 +01:00
|
|
|
// 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))
|
2025-10-17 09:01:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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> {
|
2025-11-26 21:40:24 +01:00
|
|
|
let source_id = source.id().to_string();
|
2025-10-17 09:01:24 +02:00
|
|
|
let result = source
|
2025-11-26 21:40:24 +01:00
|
|
|
.browse(&source_id)
|
2025-10-17 09:01:24 +02:00
|
|
|
.await
|
|
|
|
|
.map_err(|e| format!("Browse failed: {}", e))?;
|
|
|
|
|
|
2025-11-26 21:40:24 +01:00
|
|
|
self.browse_result_to_didl(&source_id, result, source, starting_index, requested_count)
|
2025-10-17 09:01:24 +02:00
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Convertit un BrowseResult en DIDL-Lite XML avec pagination
|
|
|
|
|
async fn browse_result_to_didl(
|
|
|
|
|
&self,
|
2025-11-26 21:40:24 +01:00
|
|
|
object_id: &str,
|
2025-10-17 09:01:24 +02:00
|
|
|
result: BrowseResult,
|
|
|
|
|
source: Arc<dyn MusicSource>,
|
|
|
|
|
starting_index: u32,
|
|
|
|
|
requested_count: u32,
|
|
|
|
|
) -> Result<(String, u32, u32, u32), String> {
|
2025-11-26 21:40:24 +01:00
|
|
|
let (containers, items) = match result {
|
2025-10-17 09:01:24 +02:00
|
|
|
BrowseResult::Containers(c) => (c, vec![]),
|
|
|
|
|
BrowseResult::Items(i) => (vec![], i),
|
|
|
|
|
BrowseResult::Mixed { containers, items } => (containers, items),
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-26 21:40:24 +01:00
|
|
|
// 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;
|
|
|
|
|
|
2025-11-28 22:15:44 +01:00
|
|
|
// 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()
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-06 18:16:39 +02:00
|
|
|
// 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()));
|
|
|
|
|
|
2025-10-17 09:01:24 +02:00
|
|
|
// 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;
|
2025-10-19 13:42:29 +02:00
|
|
|
items = items.into_iter().skip(item_start).take(count).collect();
|
2025-10-17 09:01:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let returned = (containers.len() + items.len()) as u32;
|
|
|
|
|
let didl = to_didl_lite(&containers, &items)?;
|
2025-11-28 20:45:31 +01:00
|
|
|
let update_id = source.update_id().await.max(1);
|
2025-10-17 09:01:24 +02:00
|
|
|
|
|
|
|
|
Ok((didl, returned, total, update_id))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Construit le container racine du MediaServer
|
|
|
|
|
async fn build_root_container(&self) -> Container {
|
2025-10-17 12:45:18 +02:00
|
|
|
let sources = list_all_sources().await;
|
Refactorisation complète de pmoradiofrance : cache centralisé avec système d'événements
Refactorisation complète de la crate `pmoradiofrance` pour simplifier l'architecture autour d'un cache de métadonnées centralisé avec système d'événements.
## Objectifs
1. Simplifier les structures de stations (supprimer StationType)
2. Créer un cache de métadonnées in-memory avec TTL basé sur `end_time`
3. Maintenir le cache de stations persistant (pmoconfig, TTL 1 semaine)
4. Implémenter un système d'événements pour la synchronisation GENA
5. Unifier les méthodes `to_didl()` pour retourner des Containers DIDL
6. Gérer automatiquement le cache des covers via pmocovers
## Changements architecturaux majeurs
### 1. Nouveau fichier: metadata_cache.rs
**Créé**: `pmoradiofrance/src/metadata_cache.rs`
Contient deux structures principales:
- **CachedMetadata**: Stocke uniquement les données nécessaires au DIDL (titre, artiste, album, cover, stream URL, etc.)
- **MetadataCache**: Gère le cache in-memory avec TTL + cache persistant des stations + système d'événements
**Fonctionnalités**:
- TTL basé sur `end_time` de l'API Radio France
- Gestion automatique du cache de covers via pmocovers
- Système subscribe/notify pour les événements
- Graceful degradation si API Radio France down
- Méthode `to_didl()` retournant une playlist à un item avec métadonnées identiques
### 2. Suppression: stateful_client.rs
**Supprimé**: `pmoradiofrance/src/stateful_client.rs`
Raison: Complètement redondant avec `MetadataCache`. Toute la logique a été déplacée dans le nouveau module.
### 3. Simplification: models.rs
**Modifications**:
- Supprimé `StationType` enum
- Simplifié `Station` struct (juste `slug` + `name`)
- Supprimé méthodes `is_main()`, `is_webradio()`, `is_local_radio()`, `base_station()`
- Conservé structures d'API (`LiveResponse`, `ShowMetadata`, etc.)
### 4. Simplification: playlist.rs
**Modifications**:
- Supprimé `StationPlaylist` complètement
- Simplifié `StationGroup` et `StationGroups`
- **Important**: `to_didl()` retourne `Container` (pas `Vec<Container>`)
- Logique unifiée: ICI fonctionne comme FIP (plus de traitement spécial)
- Préservé les règles de mapping RF → UPnP existantes
### 5. Refactoring: source.rs
**Modifications**:
- Utilise uniquement `MetadataCache` (plus de `stateful_client`)
- Simplifié `browse()` en 3 cas simples
- Abonnement aux événements du cache pour GENA
- Retourne des `Container` (cohérence avec to_didl)
### 6. Adaptation: config_ext.rs
**Modifications**:
- Format simplifié: `Vec<Station>` au lieu de `CachedStationList`
- TTL reste à 7 jours (1 semaine)
### 7. Mise à jour: lib.rs
**Modifications**:
- Ajouté `pub mod metadata_cache;`
- Supprimé export de `stateful_client`
- Ajouté exports: `MetadataCache`, `CachedMetadata`
## Hiérarchie de browse
**Niveau 0**: `radiofrance`
- Retourne UN Container contenant les containers de groupes
- Exemple: Container "FIP", Container "France Culture", Container "ICI"
**Niveau 1**: `radiofrance:group:fip` ou `radiofrance:ici`
- Si 1 station: retourne directement la playlist (Container playlistContainer)
- Si plusieurs stations: retourne un container contenant les playlists
**Niveau 2**: `radiofrance:fip`
- Retourne Container playlistContainer avec 1 item
- Métadonnées identiques entre playlist et item
## Règles de mapping préservées
Les règles existantes de transformation RF → UPnP ont été préservées:
- Radio musicale avec song → métadonnées du morceau
- Radio parlée → agrégation émission/producteur
- Éviter duplications du nom de station
- Calcul de duration depuis end_time
## Système d'événements
**Flux**:
1. `MetadataCache` rafraîchit les métadonnées d'un slug
2. Notifie tous les abonnés via `notify(slug)`
3. `RadioFranceSource` reçoit l'événement
4. Émet un événement GENA UPnP pour la playlist `radiofrance:{slug}`
5. Le Control Point reçoit la notification et peut se mettre à jour
## Fichiers modifiés
### Créés
- `pmoradiofrance/src/metadata_cache.rs`
### Supprimés
- `pmoradiofrance/src/stateful_client.rs`
### Modifiés
- `pmoradiofrance/src/models.rs`
- `pmoradiofrance/src/playlist.rs`
- `pmoradiofrance/src/source.rs`
- `pmoradiofrance/src/config_ext.rs`
- `pmoradiofrance/src/lib.rs`
### Inchangés
- `pmoradiofrance/src/client.rs`
- `pmoradiofrance/src/error.rs`
## Points de vigilance
1. **Migration**: Le cache pmoconfig existant sera invalidé (nouveau format)
2. **Covers**: Nécessite que pmocovers soit initialisé via cache_registry
3. **Thread safety**: Utilisation d'Arc<RwLock> pour la sécurité thread
4. **Graceful degradation**: Retourne cache expiré si API Radio France down
## Prochaines étapes
1. Tester le cache de métadonnées (TTL, refresh, graceful degradation)
2. Tester le système d'événements
3. Tester le browse sur les 3 niveaux
4. Vérifier les événements GENA
5. Vérifier que les covers sont correctement cachées
2026-01-24 14:15:26 +01:00
|
|
|
let _child_count = sources.len();
|
2025-10-17 09:01:24 +02:00
|
|
|
|
|
|
|
|
Container {
|
|
|
|
|
id: "0".to_string(),
|
|
|
|
|
parent_id: "-1".to_string(),
|
|
|
|
|
restricted: Some("1".to_string()),
|
2025-11-28 22:15:44 +01:00
|
|
|
// Laisser childCount absent sur la racine pour maximiser la compatibilité (BubbleUPnP)
|
2025-11-28 20:45:31 +01:00
|
|
|
child_count: None,
|
2025-10-26 23:49:20 +01:00
|
|
|
searchable: Some("1".to_string()),
|
2025-10-17 09:01:24 +02:00
|
|
|
title: "PMOMusic".to_string(),
|
|
|
|
|
class: "object.container".to_string(),
|
2026-01-04 18:54:31 +01:00
|
|
|
artist: None,
|
|
|
|
|
album_art: None,
|
2025-10-17 09:01:24 +02:00
|
|
|
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"
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-11 20:02:00 +02:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-17 09:01:24 +02:00
|
|
|
let mut all_containers = Vec::new();
|
|
|
|
|
let mut all_items = Vec::new();
|
|
|
|
|
|
2026-06-14 13:55:38 +02:00
|
|
|
// 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://");
|
|
|
|
|
|
2025-10-17 12:45:18 +02:00
|
|
|
for source in list_all_sources().await {
|
2026-06-14 13:55:38 +02:00
|
|
|
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);
|
2025-10-17 09:01:24 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let total = (all_containers.len() + all_items.len()) as u32;
|
|
|
|
|
let didl = to_didl_lite(&all_containers, &all_items)?;
|
|
|
|
|
|
2025-10-26 23:49:20 +01:00
|
|
|
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))
|
2025-10-17 09:01:24 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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 {
|
2025-10-17 12:45:18 +02:00
|
|
|
let sources = list_all_sources().await;
|
2025-10-17 09:01:24 +02:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-26 23:49:20 +01:00
|
|
|
combined_id.max(1)
|
2025-10-17 09:01:24 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
|
2025-10-26 23:49:20 +01:00
|
|
|
let (didl, returned, total, update_id) = result.unwrap();
|
2025-10-17 09:01:24 +02:00
|
|
|
assert_eq!(returned, 0);
|
|
|
|
|
assert_eq!(total, 0);
|
2025-10-26 23:49:20 +01:00
|
|
|
assert_eq!(update_id, 1);
|
2025-10-17 09:01:24 +02:00
|
|
|
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;
|
2025-10-26 23:49:20 +01:00
|
|
|
assert_eq!(update_id, 1); // No sources registered -> minimum 1
|
2025-10-17 09:01:24 +02:00
|
|
|
}
|
|
|
|
|
}
|