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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3907,6 +3907,7 @@ dependencies = [
|
||||
"pmodidl",
|
||||
"pmoserver",
|
||||
"pmoupnp",
|
||||
"pmoutils",
|
||||
"quick-xml",
|
||||
"rand 0.9.2",
|
||||
"ratatui",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user