push-pruvwtuysnnn #66

Merged
eric merged 13 commits from push-pruvwtuysnnn into main 2026-01-23 23:15:29 +01:00
14 changed files with 296 additions and 39 deletions
Showing only changes of commit a13467bc2c - Show all commits

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