Add pmoradiofrance module and improve track duration handling

This commit adds the pmoradiofrance module to the build, updates the version to 0.3.14, and enhances the music renderer to fetch track duration from DIDL metadata when the renderer doesn't provide it directly. It also improves metadata caching for Radio France, including dynamic duration calculation and better station name handling.
This commit is contained in:
2026-01-24 15:03:44 +01:00
parent 8ba627aee5
commit dfc0c7f8e7
6 changed files with 124 additions and 13 deletions

2
Cargo.lock generated
View File

@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "PMOMusic"
version = "0.3.13"
version = "0.3.14"
dependencies = [
"axum 0.8.7",
"console-subscriber",

View File

@@ -50,6 +50,7 @@ COPY pmoaudiocache/ ./pmoaudiocache/
COPY pmoaudio/ ./pmoaudio/
COPY pmoqobuz/ ./pmoqobuz/
COPY pmoparadise/ ./pmoparadise/
COPY pmoradiofrance/ ./pmoradiofrance/
COPY pmosource/ ./pmosource/
COPY pmoplaylist/ ./pmoplaylist/
COPY pmoflac/ ./pmoflac/

View File

@@ -1,6 +1,6 @@
[package]
name = "PMOMusic"
version = "0.3.13"
version = "0.3.14"
edition = "2024"
[dependencies]

View File

@@ -11,6 +11,7 @@ use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::SystemTime;
use pmodidl::{DIDLLite, MediaMetadataParser};
use tracing::{debug, error};
use crate::errors::ControlPointError;
@@ -755,8 +756,30 @@ impl MusicRenderer {
/// Get playback position
pub fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
self.lock_backend_for("playback_position")
.playback_position()
let mut position_info = self
.lock_backend_for("playback_position")
.playback_position()?;
// Si track_duration est absent ou invalide, essayer de le parser depuis le DIDL metadata
let needs_duration_fix = position_info
.track_duration
.as_ref()
.map(|d| d == "00:00:00" || d == "0:00:00")
.unwrap_or(true); // None = true
if needs_duration_fix {
if let Some(ref metadata_xml) = position_info.track_metadata {
if let Some(duration) = parse_didl_duration(metadata_xml) {
tracing::debug!(
"MusicRenderer: Corrected track_duration from DIDL metadata: {}",
duration
);
position_info.track_duration = Some(duration);
}
}
}
Ok(position_info)
}
/// Sets the playlist binding for this renderer.
@@ -1437,6 +1460,36 @@ impl RendererFromMediaRendererInfo for MusicRendererBackend {
}
}
/// Parse duration from DIDL-Lite metadata XML.
///
/// Extracts the duration attribute from the <res> element in DIDL metadata.
/// This is used as a fallback when the renderer doesn't provide track_duration
/// in GetPositionInfo or similar calls.
fn parse_didl_duration(didl_xml: &str) -> Option<String> {
// Parse DIDL-Lite XML properly using pmodidl
let didl = match DIDLLite::parse(didl_xml) {
Ok(d) => d,
Err(e) => {
tracing::trace!("Failed to parse DIDL metadata: {}", e);
return None;
}
};
// Extract duration from the first item's first resource
let duration = didl.items.first()?.resources.first()?.duration.clone();
if let Some(ref dur) = duration {
tracing::debug!(
"MusicRenderer: Extracted duration from DIDL metadata: {}",
dur
);
} else {
tracing::trace!("No duration attribute found in DIDL metadata");
}
duration
}
/// Transport control façade that dispatches to whichever backend can fulfill
/// the request, returning a standardized error if the backend lacks support.
impl TransportControl for MusicRendererBackend {

View File

@@ -238,17 +238,23 @@ impl CachedMetadata {
match cache.add_from_url(&cover_url, Some("radiofrance")).await {
Ok(pk) => {
// Construire l'URL publique
let public_url = format!(
"{}{}",
server_base_url.trim_end_matches('/'),
cache.route_for(&pk, None)
let route = cache.route_for(&pk, None);
let public_url = format!("{}{}", server_base_url.trim_end_matches('/'), route);
#[cfg(feature = "logging")]
tracing::debug!(
"Cached cover - UUID: {}, PK: {}, route: {}, public_url: {}",
uuid,
pk,
route,
public_url
);
(Some(public_url), Some(pk))
}
Err(e) => {
#[cfg(feature = "logging")]
tracing::warn!("Failed to cache Radio France cover: {}", e);
tracing::warn!("Failed to cache Radio France cover UUID {}: {}", uuid, e);
// Fallback sur le logo par défaut en cas d'erreur
let logo_url = format!(
"{}/api/radiofrance/default-logo",
@@ -344,6 +350,47 @@ impl CachedMetadata {
///
/// La playlist et l'item ont EXACTEMENT les mêmes métadonnées
pub fn to_didl(&self, playlist_id: &str, parent_id: &str) -> Container {
// Calculer la duration dynamiquement (temps restant jusqu'à end_time)
let duration = if let Some(end) = self.end_time {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
if end > now {
let duration_secs = end - now;
let hours = duration_secs / 3600;
let minutes = (duration_secs % 3600) / 60;
let seconds = duration_secs % 60;
let dur = format!("{}:{:02}:{:02}", hours, minutes, seconds);
#[cfg(feature = "logging")]
tracing::debug!(
"Duration calculated for {}: {} (end_time: {}, now: {}, remaining: {}s)",
self.slug,
dur,
end,
now,
duration_secs
);
Some(dur)
} else {
#[cfg(feature = "logging")]
tracing::warn!(
"Duration expired for {}: end_time {} < now {}",
self.slug,
end,
now
);
None
}
} else {
#[cfg(feature = "logging")]
tracing::warn!("No end_time for {}, duration will be None", self.slug);
None
};
let item = Item {
id: format!("{}:stream", playlist_id),
parent_id: playlist_id.to_string(),
@@ -363,7 +410,7 @@ impl CachedMetadata {
bits_per_sample: None,
sample_frequency: self.sample_frequency.clone(),
nr_audio_channels: self.nr_audio_channels.clone(),
duration: self.duration.clone(),
duration,
url: self.stream_url.clone(),
}],
descriptions: vec![],
@@ -493,11 +540,21 @@ impl MetadataCache {
}
};
// 3. Parse LiveResponse -> CachedMetadata
// 3. Récupérer le nom de la station depuis la liste des stations
let station_name = {
let stations = self.get_stations().await.unwrap_or_default();
stations
.iter()
.find(|s| s.slug == slug)
.map(|s| s.name.clone())
.unwrap_or_else(|| slug.to_string())
};
// 4. Parse LiveResponse -> CachedMetadata
let metadata = CachedMetadata::from_live_response(
&Station {
slug: slug.to_string(),
name: slug.to_string(),
name: station_name,
},
&live_response,
&self.cover_cache,

View File

@@ -1 +1 @@
0.3.13
0.3.14