From a13467bc2cc8b87a62c57c2a4101c8356f3cb1bf Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 23 Jan 2026 22:02:03 +0100 Subject: [PATCH] =?UTF-8?q?Ajout=20de=20la=20gestion=20de=20la=20dur=C3=A9?= =?UTF-8?q?e=20dans=20les=20m=C3=A9tadonn=C3=A9es=20UPnP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 1 + pmocontrol/Cargo.toml | 1 + pmocontrol/src/control_point.rs | 3 +- pmocontrol/src/media_server.rs | 1 + pmocontrol/src/model.rs | 1 + .../src/music_renderer/musicrenderer.rs | 67 ++++++----- .../src/music_renderer/openhome_renderer.rs | 42 ++++++- .../src/music_renderer/upnp_renderer.rs | 110 +++++++++++++++++- pmocontrol/src/music_renderer/watcher.rs | 1 + pmocontrol/src/queue/openhome.rs | 14 ++- .../src/upnp_clients/avtransport_client.rs | 9 ++ .../src/upnp_clients/openhome_client.rs | 1 + pmomediaserver/src/content_handler.rs | 10 ++ pmoradiofrance/src/playlist.rs | 74 +++++++++++- 14 files changed, 296 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 241e0e6b..197d6b5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3907,6 +3907,7 @@ dependencies = [ "pmodidl", "pmoserver", "pmoupnp", + "pmoutils", "quick-xml", "rand 0.9.2", "ratatui", diff --git a/pmocontrol/Cargo.toml b/pmocontrol/Cargo.toml index 00333d8b..e91fb791 100644 --- a/pmocontrol/Cargo.toml +++ b/pmocontrol/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] pmoupnp = { path = "../pmoupnp" } pmodidl = { path = "../pmodidl" } +pmoutils = { path = "../pmoutils" } quick-xml = { workspace = true } thiserror = { workspace = true } ureq = "3.1.4" diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 1c534779..17e746a1 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -1647,7 +1647,7 @@ fn didl_item_from_playback_item(item: &PlaybackItem) -> DidlItem { bits_per_sample: None, sample_frequency: None, nr_audio_channels: None, - duration: None, + duration: metadata.and_then(|m| m.duration.clone()), url: item.uri.clone(), }], descriptions: Vec::new(), @@ -1686,6 +1686,7 @@ fn playback_item_track_metadata(item: &PlaybackItem) -> TrackMetadata { date: None, track_number: None, creator: None, + duration: None, }) } diff --git a/pmocontrol/src/media_server.rs b/pmocontrol/src/media_server.rs index ef5eaa61..41bea3c5 100644 --- a/pmocontrol/src/media_server.rs +++ b/pmocontrol/src/media_server.rs @@ -565,6 +565,7 @@ pub fn playback_item_from_entry( date: entry.date.clone(), track_number: entry.track_number.clone(), creator: entry.creator.clone(), + duration: resource.duration.clone(), }; debug!( diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs index e89160f5..cfb1c1b3 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -93,6 +93,7 @@ pub struct TrackMetadata { pub date: Option, pub track_number: Option, pub creator: Option, + pub duration: Option, } #[derive(Clone, Debug, Copy)] diff --git a/pmocontrol/src/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs index a0df13c9..fe801cd8 100644 --- a/pmocontrol/src/music_renderer/musicrenderer.rs +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -1245,33 +1245,46 @@ pub(crate) fn build_didl_lite_metadata( uri: &str, protocol_info: &str, ) -> String { - format!( - r#" - -{} -{} -{} -{} -{} -{} - -"#, - metadata.title.as_deref().unwrap_or("Unknown Title"), - metadata - .creator - .as_deref() - .or(metadata.artist.as_deref()) - .unwrap_or("Unknown Artist"), - metadata.artist.as_deref().unwrap_or("Unknown Artist"), - metadata.album.as_deref().unwrap_or("Unknown Album"), - metadata - .album_art_uri - .as_ref() - .map(|art_uri| format!("{}", art_uri)) - .unwrap_or_default(), - protocol_info, - uri - ) + use pmodidl::{DIDLLite, Item, Resource}; + use pmoutils::ToXmlElement; + + // Construire l'Item DIDL avec toutes les métadonnées + let item = Item { + id: "0".to_string(), + parent_id: "-1".to_string(), + restricted: Some("1".to_string()), + title: metadata + .title + .clone() + .unwrap_or_else(|| "Unknown Title".to_string()), + creator: metadata.creator.clone().or_else(|| metadata.artist.clone()), + class: "object.item.audioItem.musicTrack".to_string(), + artist: metadata.artist.clone(), + album: metadata.album.clone(), + genre: metadata.genre.clone(), + album_art: metadata.album_art_uri.clone(), + album_art_pk: None, + date: metadata.date.clone(), + original_track_number: metadata.track_number.clone(), + resources: vec![Resource { + protocol_info: protocol_info.to_string(), + bits_per_sample: None, + sample_frequency: None, + nr_audio_channels: None, + duration: metadata.duration.clone(), + url: uri.to_string(), + }], + descriptions: vec![], + }; + + // Construire le DIDL-Lite complet + let didl = DIDLLite { + items: vec![item], + ..Default::default() + }; + + // Sérialiser en XML via pmodidl + didl.to_xml() } impl DeviceIdentity for MusicRenderer { diff --git a/pmocontrol/src/music_renderer/openhome_renderer.rs b/pmocontrol/src/music_renderer/openhome_renderer.rs index e4cba46a..e8319348 100644 --- a/pmocontrol/src/music_renderer/openhome_renderer.rs +++ b/pmocontrol/src/music_renderer/openhome_renderer.rs @@ -327,17 +327,57 @@ impl PlaybackPosition for OpenHomeRenderer { } } + // Get duration from Time service, but fall back to DIDL metadata if duration is 0 + let track_duration = if time_info.duration_secs == 0 { + // Try to extract duration from DIDL metadata + track_metadata_xml + .as_ref() + .and_then(|xml| parse_didl_duration_openhome(xml)) + } else { + Some(format_hhmmss_u32(time_info.duration_secs)) + }; + + tracing::trace!( + "OpenHome playback_position: duration_secs={}, track_duration={:?}", + time_info.duration_secs, + track_duration + ); + Ok(PlaybackPositionInfo { track: track_id, rel_time: Some(format_hhmmss_u32(time_info.elapsed_secs)), abs_time: None, - track_duration: Some(format_hhmmss_u32(time_info.duration_secs)), + track_duration, track_metadata: track_metadata_xml, track_uri, }) } } +/// Parse duration from DIDL-Lite metadata XML (OpenHome version) +fn parse_didl_duration_openhome(didl: &str) -> Option { + // Search for duration attribute in element + let res_start = didl.find("')?; + let tag_attrs = &after_res[..tag_close]; + + if let Some(duration_start) = tag_attrs.find("duration=\"") { + let duration_offset = duration_start + "duration=\"".len(); + if let Some(duration_end) = tag_attrs[duration_offset..].find('"') { + let duration = &tag_attrs[duration_offset..duration_offset + duration_end]; + tracing::info!( + "OpenHome: Extracted duration from DIDL metadata: {}", + duration + ); + return Some(duration.to_string()); + } + } + + tracing::debug!("OpenHome: No duration found in DIDL metadata"); + None +} + pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState { match raw.trim().to_ascii_uppercase().as_str() { "PLAYING" => PlaybackState::Playing, diff --git a/pmocontrol/src/music_renderer/upnp_renderer.rs b/pmocontrol/src/music_renderer/upnp_renderer.rs index 6036ef94..f74a9d49 100644 --- a/pmocontrol/src/music_renderer/upnp_renderer.rs +++ b/pmocontrol/src/music_renderer/upnp_renderer.rs @@ -23,6 +23,8 @@ pub struct UpnpRenderer { connection_manager: Option, has_avtransport_set_next: bool, queue: Arc>, + /// Durée extraite du DIDL-Lite (fallback si l'ampli ne la retourne pas) + cached_duration: Arc>>, } impl UpnpRenderer { @@ -103,6 +105,7 @@ impl UpnpRenderer { connection_manager, has_avtransport_set_next, queue, + cached_duration: Arc::new(Mutex::new(None)), } } } @@ -153,6 +156,7 @@ impl RendererFromMediaRendererInfo for UpnpRenderer { connection_manager, has_avtransport_set_next: info.capabilities().has_avtransport_set_next(), queue, + cached_duration: Arc::new(Mutex::new(None)), }) } @@ -202,6 +206,21 @@ impl QueueTransportControl for UpnpRenderer { ) }; + tracing::info!( + "play_from_queue DIDL metadata (first 800 chars):\n{}", + &metadata[..metadata.len().min(800)] + ); + + // Parse et cache la durée du DIDL + let duration = parse_didl_duration(&metadata); + if let Some(ref dur) = duration { + tracing::info!("Caching duration from queue DIDL: {}", dur); + *self.cached_duration.lock().unwrap() = Some(dur.clone()); + } else { + tracing::debug!("No duration to cache from queue DIDL"); + *self.cached_duration.lock().unwrap() = None; + } + // UPNP: SetAVTransportURI + Play let avt = self.avtransport()?; avt.set_av_transport_uri(&item.uri, &metadata)?; @@ -307,11 +326,61 @@ impl QueueBackend for UpnpRenderer { } } +/// Parse le DIDL-Lite pour extraire la durée du premier élément +fn parse_didl_duration(didl: &str) -> Option { + // Recherche de l'élément (avec ou sans espace après) + let res_start = didl + .find("")) + .or_else(|| didl.find(" + // Il doit être avant la fermeture du tag (avant '>') + if let Some(tag_close) = after_res.find('>') { + let tag_attrs = &after_res[..tag_close]; + + if let Some(duration_start) = tag_attrs.find("duration=\"") { + let duration_offset = duration_start + "duration=\"".len(); + if let Some(duration_end) = tag_attrs[duration_offset..].find('"') { + let duration = &tag_attrs[duration_offset..duration_offset + duration_end]; + tracing::info!("Extracted duration from DIDL: {}", duration); + return Some(duration.to_string()); + } + } + } + + tracing::warn!("No duration attribute found in DIDL element"); + None +} + /// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`]. /// /// Cette impl se base sur AVTransport (InstanceID = 0). impl TransportControl for UpnpRenderer { fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { + // Log du DIDL complet pour déboguer + if !meta.is_empty() { + tracing::debug!( + "play_uri DIDL-Lite metadata: {}", + &meta[..meta.len().min(500)] + ); + } + + // Parse le DIDL pour extraire la durée + let duration = parse_didl_duration(meta); + if let Some(ref dur) = duration { + tracing::info!("Caching duration from DIDL: {}", dur); + *self.cached_duration.lock().unwrap() = Some(dur.clone()); + } else { + tracing::warn!( + "No duration to cache from DIDL (this may be expected for streams without duration)" + ); + *self.cached_duration.lock().unwrap() = None; + } + let avt = self.avtransport()?; avt.set_av_transport_uri(uri, meta)?; avt.play(0, "1") @@ -381,11 +450,50 @@ impl PlaybackPosition for UpnpRenderer { let avt = self.avtransport()?; let raw: PositionInfo = avt.get_position_info(0)?; + tracing::trace!( + "GetPositionInfo returned: track_duration={:?}, rel_time={:?}", + raw.track_duration, + raw.rel_time + ); + + // Normalize "00:00:00" or "0:00:00" to None (some renderers return this for unknown duration) + let normalized_duration = raw.track_duration.as_ref().and_then(|d| { + if d == "00:00:00" || d == "0:00:00" { + None + } else { + Some(d.clone()) + } + }); + + // Si l'ampli ne retourne pas de durée, utilise la durée cachée du DIDL + let track_duration = if normalized_duration.is_none() { + let cached = self.cached_duration.lock().unwrap(); + if let Some(ref duration) = *cached { + tracing::debug!("Using cached duration from DIDL as fallback: {}", duration); + Some(duration.clone()) + } else { + tracing::warn!("No track_duration from renderer and no cached duration available!"); + None + } + } else { + tracing::debug!( + "Using track_duration from renderer: {:?}", + normalized_duration + ); + normalized_duration + }; + + tracing::trace!( + "Final PlaybackPositionInfo: track_duration={:?}, rel_time={:?}", + track_duration, + raw.rel_time + ); + Ok(PlaybackPositionInfo { track: Some(raw.track), rel_time: raw.rel_time, abs_time: raw.abs_time, - track_duration: raw.track_duration, + track_duration, track_metadata: raw.track_metadata, track_uri: raw.track_uri, }) diff --git a/pmocontrol/src/music_renderer/watcher.rs b/pmocontrol/src/music_renderer/watcher.rs index 9abaccb1..67a1d29e 100644 --- a/pmocontrol/src/music_renderer/watcher.rs +++ b/pmocontrol/src/music_renderer/watcher.rs @@ -217,6 +217,7 @@ pub fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option String { } let escaped_protocol_info = escape(item.protocol_info.as_str()); - xml.push_str(&format!( - r#"{}"#, - escaped_protocol_info, escaped_uri - )); + + // Build element with optional duration attribute + xml.push_str(&format!(r#"{}"#, escaped_uri)); xml.push_str(r#"object.item.audioItem.musicTrack"#); xml } diff --git a/pmocontrol/src/upnp_clients/avtransport_client.rs b/pmocontrol/src/upnp_clients/avtransport_client.rs index ce1dc839..7bd16166 100644 --- a/pmocontrol/src/upnp_clients/avtransport_client.rs +++ b/pmocontrol/src/upnp_clients/avtransport_client.rs @@ -71,6 +71,15 @@ impl AvTransportClient { /// - `uri` : CurrentURI /// - `meta` : CurrentURIMetaData (DIDL-Lite ou chaîne vide) pub fn set_av_transport_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> { + // Log le DIDL-Lite envoyé (limité pour éviter de polluer les logs) + if !meta.is_empty() { + tracing::debug!( + "SetAVTransportURI - URI: {}, MetaData: {}", + &uri[..uri.len().min(80)], + &meta[..meta.len().min(500)] + ); + } + let args = [ ("InstanceID", "0"), ("CurrentURI", uri), diff --git a/pmocontrol/src/upnp_clients/openhome_client.rs b/pmocontrol/src/upnp_clients/openhome_client.rs index 2b387dce..e3e42c3d 100644 --- a/pmocontrol/src/upnp_clients/openhome_client.rs +++ b/pmocontrol/src/upnp_clients/openhome_client.rs @@ -895,6 +895,7 @@ pub fn parse_track_metadata_from_didl(xml: &str) -> Option { date: item.date.clone(), track_number: item.original_track_number.clone(), creator: item.creator.clone(), + duration: item.resources.first().and_then(|r| r.duration.clone()), }) } diff --git a/pmomediaserver/src/content_handler.rs b/pmomediaserver/src/content_handler.rs index b631ae90..82ad80e2 100644 --- a/pmomediaserver/src/content_handler.rs +++ b/pmomediaserver/src/content_handler.rs @@ -32,6 +32,16 @@ fn to_didl_lite(containers: &[Container], items: &[pmodidl::Item]) -> Result Resource { + // Calculer la durée restante (maintenant -> end_time) + // Cela permet au curseur de progresser de 0 jusqu'à la fin de l'émission + let duration = if let Some(end) = metadata.now.end_time { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + #[cfg(feature = "logging")] + tracing::debug!( + "Calculating duration: end_time={}, now={}, diff={}", + end, + now, + end.saturating_sub(now) + ); + + if end > now { + let duration_secs = end - now; + // Format UPnP: H:MM:SS ou H:MM:SS.F + let hours = duration_secs / 3600; + let minutes = (duration_secs % 3600) / 60; + let seconds = duration_secs % 60; + let duration_str = format!("{}:{:02}:{:02}", hours, minutes, seconds); + + #[cfg(feature = "logging")] + tracing::info!("Track duration set to: {}", duration_str); + + Some(duration_str) + } else { + #[cfg(feature = "logging")] + tracing::warn!("end_time ({}) is in the past (now={})", end, now); + None + } + } else { + #[cfg(feature = "logging")] + tracing::debug!("No end_time available in metadata"); + None + }; + // Trouver le meilleur stream HiFi let best_stream = metadata.now.media.best_hifi_stream(); @@ -640,14 +691,27 @@ impl StationPlaylist { } }; - Resource { + let resource = Resource { protocol_info, bits_per_sample: None, sample_frequency, nr_audio_channels, - duration: None, // Stream live = pas de durée + duration: duration.clone(), // Durée calculée depuis start_time/end_time si disponible url, - } + }; + + #[cfg(feature = "logging")] + tracing::info!( + "Built resource with duration: {:?}, url: {}", + resource.duration, + if resource.url.is_empty() { + "" + } else { + &resource.url[..resource.url.len().min(50)] + } + ); + + resource } /// Retourne l'URL du stream