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