Ajout de la gestion de la durée dans les métadonnées UPnP

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.
This commit is contained in:
2026-01-23 22:02:03 +01:00
parent 5cee6539c6
commit a13467bc2c
14 changed files with 296 additions and 39 deletions

1
Cargo.lock generated
View File

@@ -3907,6 +3907,7 @@ dependencies = [
"pmodidl",
"pmoserver",
"pmoupnp",
"pmoutils",
"quick-xml",
"rand 0.9.2",
"ratatui",

View File

@@ -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"

View File

@@ -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,
})
}

View File

@@ -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!(

View File

@@ -93,6 +93,7 @@ pub struct TrackMetadata {
pub date: Option<String>,
pub track_number: Option<String>,
pub creator: Option<String>,
pub duration: Option<String>,
}
#[derive(Clone, Debug, Copy)]

View File

@@ -1245,33 +1245,46 @@ pub(crate) fn build_didl_lite_metadata(
uri: &str,
protocol_info: &str,
) -> String {
format!(
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
<item id="0" parentID="-1" restricted="1">
<dc:title>{}</dc:title>
<dc:creator>{}</dc:creator>
<upnp:artist>{}</upnp:artist>
<upnp:album>{}</upnp:album>
{}
<res protocolInfo="{}">{}</res>
</item>
</DIDL-Lite>"#,
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!("<upnp:albumArtURI>{}</upnp:albumArtURI>", 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 {

View File

@@ -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<String> {
// Search for duration attribute in <res> element
let res_start = didl.find("<res ")?;
let after_res = &didl[res_start..];
let 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!(
"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,

View File

@@ -23,6 +23,8 @@ pub struct UpnpRenderer {
connection_manager: Option<ConnectionManagerClient>,
has_avtransport_set_next: bool,
queue: Arc<Mutex<MusicQueue>>,
/// Durée extraite du DIDL-Lite (fallback si l'ampli ne la retourne pas)
cached_duration: Arc<Mutex<Option<String>>>,
}
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 <res>
fn parse_didl_duration(didl: &str) -> Option<String> {
// Recherche de l'élément <res> (avec ou sans espace après)
let res_start = didl
.find("<res ")
.or_else(|| didl.find("<res>"))
.or_else(|| didl.find("<res\n"))
.or_else(|| didl.find("<res\t"))?;
let after_res = &didl[res_start..];
// Recherche de l'attribut duration dans cet élément <res>
// 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 <res> 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,
})

View File

@@ -217,6 +217,7 @@ pub fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option<TrackMe
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()),
})
}

View File

@@ -436,10 +436,16 @@ fn build_metadata_xml(item: &PlaybackItem) -> String {
}
let escaped_protocol_info = escape(item.protocol_info.as_str());
xml.push_str(&format!(
r#"<res protocolInfo="{}">{}</res>"#,
escaped_protocol_info, escaped_uri
));
// Build <res> element with optional duration attribute
xml.push_str(&format!(r#"<res protocolInfo="{}""#, escaped_protocol_info));
if let Some(meta) = &item.metadata {
if let Some(duration) = meta.duration.as_deref() {
let escaped_duration = escape(duration);
xml.push_str(&format!(r#" duration="{}""#, escaped_duration));
}
}
xml.push_str(&format!(r#">{}</res>"#, escaped_uri));
xml.push_str(r#"<upnp:class>object.item.audioItem.musicTrack</upnp:class></item></DIDL-Lite>"#);
xml
}

View File

@@ -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),

View File

@@ -895,6 +895,7 @@ pub fn parse_track_metadata_from_didl(xml: &str) -> Option<TrackMetadata> {
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()),
})
}

View File

@@ -32,6 +32,16 @@ fn to_didl_lite(containers: &[Container], items: &[pmodidl::Item]) -> Result<Str
// 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)
}

View File

@@ -349,7 +349,7 @@ impl StationPlaylist {
// Construction de la ressource (stream)
let resource = Self::build_stream_resource(metadata);
Ok(Item {
let item = Item {
id: format!("radiofrance:{}:stream", station.slug),
parent_id: format!("radiofrance:{}", station.slug),
restricted: Some("1".to_string()),
@@ -365,7 +365,19 @@ impl StationPlaylist {
original_track_number: None,
resources: vec![resource],
descriptions: vec![],
})
};
#[cfg(feature = "logging")]
if let Some(res) = item.resources.first() {
tracing::info!(
"Item built for {}: title='{}', duration={:?}",
station.slug,
item.title,
res.duration
);
}
Ok(item)
}
/// Construit un Item UPnP depuis les métadonnées live (sans cache async)
@@ -606,6 +618,45 @@ impl StationPlaylist {
/// Construit la ressource stream
fn build_stream_resource(metadata: &LiveResponse) -> 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() {
"<empty>"
} else {
&resource.url[..resource.url.len().min(50)]
}
);
resource
}
/// Retourne l'URL du stream