From 0f20a11666a7c4ea0e28a7c4cae8092094c34ba4 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Thu, 26 Mar 2026 00:46:18 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20am=C3=A9liorer=20la=20gestion=20des=20m?= =?UTF-8?q?=C3=A9tadonn=C3=A9es=20de=20flux=20en=20continu=20avec=20dc:dat?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajout d'une méthode de travail structurée pour éviter le 'vibe programming' - Version bump à 0.3.30 - Correction de chrono dans pmocontrol Cargo.toml (suppression d'optional) - Dans MusicRenderer : utilisation de dc:date pour calibrer track_start_time sur la diffusion réelle des flux continus - Ajout d'une fonction parse_rfc3339_to_system_time - Dans pmoradiofrance : remplacement de la durée dynamique (temps restant) par une durée fixe calculée à partir des bornes start_time/end_time, et ajout de start_time dans les métadonnées DIDL via dc:date - Correction du calcul de l'élément 'now playing' dans PullResponse pour utiliser les bornes temporelles exactes - Mise à jour de version.txt --- .claude/CLAUDE.md | 8 ++ Cargo.lock | 2 +- PMOMusic/Cargo.toml | 2 +- pmocontrol/Cargo.toml | 4 +- .../src/music_renderer/musicrenderer.rs | 27 ++++++- pmoradiofrance/src/metadata_cache.rs | 74 ++++++++----------- pmoradiofrance/src/models.rs | 17 ++++- version.txt | 2 +- 8 files changed, 82 insertions(+), 54 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index e3df146e..14a5ee2f 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -25,6 +25,14 @@ host: friendly_name_prefix: "PMOMusic-Dev1" ``` +## Méthode de travail +Avant de modifier quoi que ce soit sur une fonctionnalité non triviale : +1. Lire et comprendre le flux complet des données concernées, de bout en bout +2. Identifier précisément où ça casse et pourquoi +3. Faire une seule modification ciblée + +Ne pas avancer par tâtonnements ("vibe programming") — cela produit des allers-retours, des bugs introduits puis annulés, et du code inutilement compliqué. + ## Architecture - Projet Rust multi-crates avec workspaces - Crates principales : pmoupnp, pmomediaserver, pmomediarenderer, pmoconfig diff --git a/Cargo.lock b/Cargo.lock index 5b716822..d53b5e26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "PMOMusic" -version = "0.3.29" +version = "0.3.30" dependencies = [ "axum 0.8.7", "console-subscriber", diff --git a/PMOMusic/Cargo.toml b/PMOMusic/Cargo.toml index e254a3fe..01369797 100644 --- a/PMOMusic/Cargo.toml +++ b/PMOMusic/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "PMOMusic" -version = "0.3.29" +version = "0.3.30" edition = "2024" [dependencies] diff --git a/pmocontrol/Cargo.toml b/pmocontrol/Cargo.toml index 7b062861..fac1cec4 100644 --- a/pmocontrol/Cargo.toml +++ b/pmocontrol/Cargo.toml @@ -37,7 +37,7 @@ tokio-util = { version = "0.7", optional = true } async-trait = { version = "0.1", optional = true } tokio-stream = { version = "0.1", features = ["sync"], optional = true } async-stream = { version = "0.3", optional = true } -chrono = { version = "0.4", features = ["serde"], optional = true } +chrono = { version = "0.4", features = ["serde"] } [dev-dependencies] percent-encoding = "2.3" @@ -45,4 +45,4 @@ percent-encoding = "2.3" [features] default = [] # Active l'API REST pmoserver -pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"] +pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream"] diff --git a/pmocontrol/src/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs index 8495c7a1..a428b2bb 100644 --- a/pmocontrol/src/music_renderer/musicrenderer.rs +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -1360,12 +1360,21 @@ impl MusicRenderer { pub fn set_last_metadata(&self, metadata: Option) { let mut state = self.state.lock().unwrap(); let metadata_changed = state.last_metadata != metadata; - state.last_metadata = metadata; if metadata_changed { - state.track_start_time = Some(SystemTime::now()); + // Pour les flux continus: utiliser dc:date comme track_start_time réel de diffusion. + // Toute source stream peut encoder son start_time en RFC 3339 dans dc:date. + // Fallback sur now() si absent ou non parseable. + let start_time = metadata + .as_ref() + .filter(|m| m.is_continuous_stream) + .and_then(|m| m.date.as_deref()) + .and_then(parse_rfc3339_to_system_time) + .unwrap_or_else(SystemTime::now); + state.track_start_time = Some(start_time); // Reset duration cache when track changes (for streams) state.current_track_duration = None; } + state.last_metadata = metadata; } /// Gets the timestamp when the current track started playing. @@ -1785,6 +1794,20 @@ fn parse_didl_duration(didl_xml: &str) -> Option { duration } +/// Parse un datetime RFC 3339 en SystemTime. +/// +/// Utilisé pour calibrer track_start_time sur le début réel de diffusion +/// d'un segment, encodé dans dc:date du DIDL par la source stream. +fn parse_rfc3339_to_system_time(s: &str) -> Option { + let dt = chrono::DateTime::parse_from_rfc3339(s).ok()?; + let secs = dt.timestamp(); + if secs < 0 { + return None; + } + Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs as u64)) +} + + /// 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 { diff --git a/pmoradiofrance/src/metadata_cache.rs b/pmoradiofrance/src/metadata_cache.rs index 3c1a9ffd..7da5b86b 100644 --- a/pmoradiofrance/src/metadata_cache.rs +++ b/pmoradiofrance/src/metadata_cache.rs @@ -54,10 +54,11 @@ pub struct CachedMetadata { pub protocol_info: String, pub sample_frequency: Option, pub nr_audio_channels: Option, - pub duration: Option, // Calculé depuis end_time + pub duration: Option, // Calculé depuis end_time - start_time - // TTL = end_time de l'API Radio France - pub end_time: Option, // Unix timestamp + // Bornes temporelles de l'émission/morceau courant + pub start_time: Option, // Unix timestamp + pub end_time: Option, // Unix timestamp (sert aussi de TTL) /// Timestamp de la dernière récupération (pour TTL minimum) pub fetched_at: u64, @@ -89,7 +90,8 @@ impl CachedMetadata { let (stream_url, protocol_info, sample_frequency, nr_audio_channels, duration) = Self::build_stream_resource(live, &station.slug, server_base_url); - // 4. TTL = end_time + // 4. Bornes temporelles + TTL + let start_time = live.now.start_time; let end_time = live.now.end_time; let fetched_at = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -111,6 +113,7 @@ impl CachedMetadata { sample_frequency, nr_audio_channels, duration, + start_time, end_time, fetched_at, }) @@ -294,24 +297,16 @@ impl CachedMetadata { Option, Option, ) { - // Calculer la durée restante (maintenant -> end_time) - let duration = if let Some(end) = metadata.now.end_time { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - if end > now { - let duration_secs = end - now; + // Durée totale = end_time - start_time (valeur fixe, indépendante de now) + let duration = match (metadata.now.start_time, metadata.now.end_time) { + (Some(start), Some(end)) if end > start => { + let duration_secs = end - start; let hours = duration_secs / 3600; let minutes = (duration_secs % 3600) / 60; let seconds = duration_secs % 60; Some(format!("{}:{:02}:{:02}", hours, minutes, seconds)) - } else { - None } - } else { - None + _ => None, }; // URL du proxy PMOMusic @@ -368,45 +363,29 @@ impl CachedMetadata { /// La playlist et l'item ont EXACTEMENT les mêmes métadonnées #[cfg(feature = "playlist")] 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; + // Durée totale = end_time - start_time (valeur fixe) + let duration = match (self.start_time, self.end_time) { + (Some(start), Some(end)) if end > start => { + let duration_secs = end - start; 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 + "Duration for {}: {} (start: {}, end: {}, total: {}s)", + self.slug, dur, start, end, duration_secs ); - Some(dur) - } else { + } + _ => { #[cfg(feature = "logging")] tracing::warn!( - "Duration expired for {}: end_time {} < now {}", - self.slug, - end, - now + "No duration for {}: start_time={:?}, end_time={:?}", + self.slug, self.start_time, self.end_time ); None } - } else { - #[cfg(feature = "logging")] - tracing::warn!("No end_time for {}, duration will be None", self.slug); - None }; let item = Item { @@ -421,7 +400,13 @@ impl CachedMetadata { genre: self.genre.clone(), album_art: self.album_art.clone(), album_art_pk: self.album_art_pk.clone(), - date: None, + // dc:date: début réel de diffusion du segment en RFC 3339 UTC. + // Les timestamps Radio France sont des Unix timestamps UTC. + // Pmocontrol utilise cette valeur pour calibrer track_start_time. + date: self.start_time.and_then(|start| { + chrono::DateTime::from_timestamp(start as i64, 0) + .map(|dt| dt.to_rfc3339()) + }), original_track_number: None, resources: vec![Resource { protocol_info: self.protocol_info.clone(), @@ -797,6 +782,7 @@ mod tests { sample_frequency: None, nr_audio_channels: None, duration: None, + start_time: None, end_time: Some(0), // Dans le passé fetched_at: now, }; diff --git a/pmoradiofrance/src/models.rs b/pmoradiofrance/src/models.rs index 9777f590..f84c635b 100644 --- a/pmoradiofrance/src/models.rs +++ b/pmoradiofrance/src/models.rs @@ -49,10 +49,21 @@ pub struct PullResponse { impl PullResponse { /// Get the current step at depth 1 (the "now playing" item) pub fn current_step(&self) -> Option<&PullStep> { - // levels[0].items[0] is the current item at depth 1 (most relevant) + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + // Le niveau liste tous les steps de la session (passés et futurs) dans l'ordre. + // On cherche le step dont start <= now < end. let level = self.levels.first()?; - let step_id = level.items.first()?; - self.steps.get(step_id) + level.items.iter().find_map(|step_id| { + let step = self.steps.get(step_id)?; + match (step.start, step.end) { + (Some(start), Some(end)) if start <= now && now < end => Some(step), + _ => None, + } + }) } } diff --git a/version.txt b/version.txt index a6c8cf78..cfa5015f 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.3.29 +0.3.30