push-pruvwtuysnnn #66
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