diff --git a/.DS_Store b/.DS_Store index 39adbca7..b20cdc77 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 61dcbf50..a257cd6c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,13 +16,14 @@ **/*.mp4 **/*.mp3 **/*.ogg + xxx /dcai/ **/.pmomusic.yml **/.pmomusic_covers/** **/.pmomusic_audio/** /.pmomusic -.DS_Store +**/.DS_Store /target **/target/ /.pmomusic_covers diff --git a/pmoradiofrance/examples/live_metadata.rs b/pmoradiofrance/examples/live_metadata.rs index 1a563e3a..ebc53a95 100644 --- a/pmoradiofrance/examples/live_metadata.rs +++ b/pmoradiofrance/examples/live_metadata.rs @@ -34,12 +34,9 @@ async fn main() -> Result<(), Box> { } if let Some(intro) = &metadata.now.intro { - let short_intro = if intro.len() > 100 { - format!("{}...", &intro[..100]) - } else { - intro.clone() - }; - println!(" Description: {}", short_intro); + let short_intro: String = intro.chars().take(100).collect(); + let suffix = if intro.chars().count() > 100 { "..." } else { "" }; + println!(" Description: {}{}", short_intro, suffix); } // Song info (for music stations) diff --git a/pmoradiofrance/src/client.rs b/pmoradiofrance/src/client.rs index d6653631..1d6de784 100644 --- a/pmoradiofrance/src/client.rs +++ b/pmoradiofrance/src/client.rs @@ -28,17 +28,27 @@ //! ``` use crate::error::{Error, Result}; -use crate::models::{ImageSize, LiveResponse, ShowMetadata, Station, StreamSource}; +use crate::models::{ + EmbedImage, ImageSize, Line, LiveResponse, Media, PullResponse, ShowMetadata, Song, Station, + StreamSource, +}; use regex::Regex; use reqwest::Client; use scraper::{Html, Selector}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, RwLock}; use std::time::Duration; use url::Url; +#[cfg(feature = "pmoconfig")] +use crate::config_ext::StationInfo; + /// Default Radio France base URL pub const DEFAULT_BASE_URL: &str = "https://www.radiofrance.fr"; +/// Livemeta API base URL (new API since 2026) +pub const LIVEMETA_API_URL: &str = "https://api.radiofrance.fr/livemeta/pull"; + /// Default timeout for HTTP requests (30 seconds) pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; @@ -56,6 +66,103 @@ pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[ ("francebleu", "France Bleu"), ]; +/// Mapping slug → numeric station ID for the livemeta/pull API +/// IDs extracted from www.radiofrance.fr SvelteKit page data (2026) +pub const STATION_IDS: &[(&str, u32)] = &[ + ("franceinter", 1), + ("franceinfo", 2), + ("francemusique", 4), + ("franceculture", 5), + ("mouv", 6), + ("fip", 7), + ("francebleu", 56), + // FIP webradios + ("fip_rock", 64), + ("fip_jazz", 65), + ("fip_groove", 66), + ("fip_reggae", 71), + ("fip_electro", 74), + ("fip_metal", 77), + ("fip_pop", 78), + ("fip_world", 69), + ("fip_nouveautes", 70), + ("fip_hiphop", 95), + ("fip_sacre_francais", 96), + ("fip_cultes", 709), +]; + +/// Icecast HiFi AAC stream URLs (stable, not from API) +/// Format: https://icecast.radiofrance.fr/{name}-hifi.aac +pub const STATION_STREAMS: &[(&str, &str)] = &[ + ( + "franceinter", + "https://icecast.radiofrance.fr/franceinter-hifi.aac", + ), + ( + "franceinfo", + "https://icecast.radiofrance.fr/franceinfo-hifi.aac", + ), + ( + "franceculture", + "https://icecast.radiofrance.fr/franceculture-hifi.aac", + ), + ( + "francemusique", + "https://icecast.radiofrance.fr/francemusique-hifi.aac", + ), + ("fip", "https://icecast.radiofrance.fr/fip-hifi.aac"), + ("mouv", "https://icecast.radiofrance.fr/mouv-hifi.aac"), + // FIP webradios + ( + "fip_rock", + "https://icecast.radiofrance.fr/fiprock-hifi.aac", + ), + ( + "fip_jazz", + "https://icecast.radiofrance.fr/fipjazz-hifi.aac", + ), + ( + "fip_groove", + "https://icecast.radiofrance.fr/fipgroove-hifi.aac", + ), + ( + "fip_reggae", + "https://icecast.radiofrance.fr/fipreggae-hifi.aac", + ), + ( + "fip_electro", + "https://icecast.radiofrance.fr/fipelectro-hifi.aac", + ), + ( + "fip_metal", + "https://icecast.radiofrance.fr/fipmetal-hifi.aac", + ), + ( + "fip_pop", + "https://icecast.radiofrance.fr/fippop-hifi.aac", + ), + ( + "fip_world", + "https://icecast.radiofrance.fr/fipworld-hifi.aac", + ), + ( + "fip_nouveautes", + "https://icecast.radiofrance.fr/fipnouveautes-hifi.aac", + ), + ( + "fip_hiphop", + "https://icecast.radiofrance.fr/fiphiphop-hifi.aac", + ), + ( + "fip_sacre_francais", + "https://icecast.radiofrance.fr/fipsacrefrancais-hifi.aac", + ), + ( + "fip_cultes", + "https://icecast.radiofrance.fr/fipcultes-hifi.aac", + ), +]; + /// Radio France HTTP client /// /// This client provides access to Radio France's public APIs for: @@ -63,13 +170,23 @@ pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[ /// - Live metadata (current show, next show, stream URLs) /// - Image URL construction (Pikapi) /// -/// The client is stateless and does not cache responses internally. -/// Caching should be handled by higher layers (e.g., config extension). +/// The client holds an in-memory station mapping (slug → numeric ID + stream URL) +/// seeded from hardcoded constants. Use `with_station_mapping()` to inject a +/// mapping loaded from persistent storage (pmoconfig). #[derive(Debug, Clone)] pub struct RadioFranceClient { pub(crate) client: Client, base_url: String, timeout: Duration, + /// Mapping slug → { station_id, stream_url } — thread-safe, updatable at runtime + station_mapping: Arc>>, +} + +/// Entry in the station mapping +#[derive(Debug, Clone)] +pub struct StationMappingEntry { + pub station_id: u32, + pub stream_url: String, } impl RadioFranceClient { @@ -91,6 +208,7 @@ impl RadioFranceClient { client, base_url: DEFAULT_BASE_URL.to_string(), timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), + station_mapping: Arc::new(RwLock::new(Self::default_station_mapping())), } } @@ -104,6 +222,89 @@ impl RadioFranceClient { &self.client } + /// Build the default station mapping from hardcoded constants + fn default_station_mapping() -> HashMap { + // Build a lookup for stream URLs + let stream_map: HashMap<&str, &str> = STATION_STREAMS.iter().copied().collect(); + + STATION_IDS + .iter() + .map(|(slug, id)| { + let stream_url = stream_map + .get(slug) + .copied() + .unwrap_or_default() + .to_string(); + ( + slug.to_string(), + StationMappingEntry { + station_id: *id, + stream_url, + }, + ) + }) + .collect() + } + + /// Remplace le mapping en mémoire par un mapping chargé depuis le cache persistant + /// + /// Appelé au démarrage par MetadataCache pour charger le mapping pmoconfig. + #[cfg(feature = "pmoconfig")] + pub fn set_station_mapping(&self, mapping: HashMap) { + let mut m = self.station_mapping.write().unwrap(); + m.clear(); + for (slug, info) in mapping { + m.insert( + slug, + StationMappingEntry { + station_id: info.station_id, + stream_url: info.stream_url, + }, + ); + } + } + + /// Récupère l'ensemble du mapping courant (pour persistance dans pmoconfig) + #[cfg(feature = "pmoconfig")] + pub fn get_station_mapping(&self) -> HashMap { + let m = self.station_mapping.read().unwrap(); + m.iter() + .map(|(slug, entry)| { + ( + slug.clone(), + StationInfo { + station_id: entry.station_id, + stream_url: entry.stream_url.clone(), + }, + ) + }) + .collect() + } + + /// Met à jour une entrée dans le mapping (utilisé après re-découverte unitaire) + pub fn update_station_entry(&self, slug: &str, station_id: u32, stream_url: String) { + let mut m = self.station_mapping.write().unwrap(); + m.insert( + slug.to_string(), + StationMappingEntry { + station_id, + stream_url, + }, + ); + } + + /// Retourne l'ID numérique pour un slug, ou None si inconnu + pub fn station_id_for_slug(&self, slug: &str) -> Option { + let m = self.station_mapping.read().unwrap(); + m.get(slug).map(|e| e.station_id) + } + + /// Retourne l'URL du stream HiFi pour un slug, ou None si inconnu + pub fn stream_url_for_slug(&self, slug: &str) -> Option { + let m = self.station_mapping.read().unwrap(); + m.get(slug).map(|e| e.stream_url.clone()) + } + // ======================================================================== // Station Discovery // ======================================================================== @@ -254,20 +455,71 @@ impl RadioFranceClient { .collect()) } - /// Discover local France Bleu radios via API + /// Discover local France Bleu radios via SvelteKit page data /// - /// Uses the France Bleu /api/live? endpoint which includes - /// a `localRadios` array in the `now` field. + /// Scrapes francebleu page for local station slugs and discovers their IDs. + /// Met à jour le station_mapping avec les IDs trouvés. pub async fn discover_local_radios(&self) -> Result> { - let response = self.live_metadata("francebleu").await?; + let url = format!("{}/francebleu/__data.json", self.base_url); + let resp = self + .client + .get(&url) + .timeout(self.timeout) + .send() + .await? + .text() + .await?; - Ok(response - .local_radios() - .cloned() - .unwrap_or_default() + // Extraire les slugs "francebleu_xxx" et les paires (slug, id) de la page + let re_slug_id = Regex::new(r#""(francebleu_[a-z_]+)","(\d+)""#)?; + let mut stations = Vec::new(); + let mut found_any = false; + + for cap in re_slug_id.captures_iter(&resp) { + let slug = cap[1].to_string(); + if let Ok(id) = cap[2].parse::() { + let stream_url = Self::derive_stream_url(&slug); + self.update_station_entry(&slug, id, stream_url); + let name = Self::slug_to_display_name(&slug); + stations.push(Station::new(slug, name)); + found_any = true; + } + } + + if found_any { + return Ok(stations); + } + + // Fallback : scraper la page HTML pour les noms "ICI ..." + let html_url = format!("{}/francebleu", self.base_url); + let html = self + .client + .get(&html_url) + .timeout(self.timeout) + .send() + .await? + .text() + .await?; + + let re_ici = Regex::new(r#"ICI ([A-Za-z\u00C0-\u017E][A-Za-z\u00C0-\u017E\s-]+)"#)?; + let re_slug = Regex::new(r#""(francebleu_[a-z_]+)""#)?; + + let mut slugs: HashSet = HashSet::new(); + for cap in re_slug.captures_iter(&html) { + slugs.insert(cap[1].to_string()); + } + + let _names: Vec = re_ici + .captures_iter(&html) + .map(|c| c[1].trim().to_string()) + .collect(); + + Ok(slugs .into_iter() - .filter(|local| local.is_on_air) - .map(|local| Station::new(local.name, local.title)) + .map(|slug| { + let name = Self::slug_to_display_name(&slug); + Station::new(slug, name) + }) .collect()) } @@ -370,28 +622,42 @@ impl RadioFranceClient { /// # } /// ``` pub async fn live_metadata(&self, station: &str) -> Result { - let (base_station, webradio) = Self::parse_station_slug(station); + let station_id = self + .station_id_for_slug(station) + .ok_or_else(|| Error::ApiError(format!("Unknown station slug: {}", station)))?; - let mut url = Url::parse(&format!("{}/{}/api/live", self.base_url, base_station))?; - - // Add webradio parameter if needed - if let Some(wr) = webradio { - url.query_pairs_mut().append_pair("webradio", wr); - } + let url = format!("{}/{}", LIVEMETA_API_URL, station_id); #[cfg(feature = "logging")] tracing::debug!("Fetching live metadata: {}", url); - let response = self.client.get(url).timeout(self.timeout).send().await?; + let response = self.client.get(&url).timeout(self.timeout).send().await?; if !response.status().is_success() { return Err(Error::ApiError(format!( - "API returned status: {}", - response.status() + "livemeta API returned status {} for station {} (id={})", + response.status(), + station, + station_id ))); } - let live: LiveResponse = response.json().await?; + let pull: PullResponse = response.json().await?; + + // Fix 1: Vérifier que l'ID retourné correspond bien à la station demandée. + // Si Radio France réassigne les IDs, on déclenche une redécouverte. + if pull.station_id != station_id { + #[cfg(feature = "logging")] + tracing::warn!( + "Station ID mismatch for {}: expected {}, API returned {} — triggering rediscovery", + station, station_id, pull.station_id + ); + // Mettre à jour le mapping avec le nouvel ID retourné par l'API + let stream_url = self.stream_url_for_slug(station).unwrap_or_default(); + self.update_station_entry(station, pull.station_id, stream_url); + } + + let live = self.pull_to_live_response(station, &pull); #[cfg(feature = "logging")] tracing::debug!( @@ -404,6 +670,268 @@ impl RadioFranceClient { Ok(live) } + /// Convertit une PullResponse (nouvelle API) en LiveResponse (format interne) + fn pull_to_live_response(&self, station_slug: &str, pull: &PullResponse) -> LiveResponse { + let now = if let Some(step) = pull.current_step() { + self.step_to_show_metadata(station_slug, step) + } else { + ShowMetadata::default() + }; + + // delayToRefresh : on utilise end_time - now comme indicateur (min 30s) + let delay_to_refresh = if let Some(end) = now.end_time { + let current = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + if end > current { + ((end - current) * 1000).min(60_000) // max 60s, en ms + } else { + 30_000 + } + } else { + 30_000 + }; + + LiveResponse { + station_name: station_slug.to_string(), + delay_to_refresh, + migrated: true, + now, + next: None, + } + } + + /// Convertit un PullStep en ShowMetadata + fn step_to_show_metadata(&self, station_slug: &str, step: &crate::models::PullStep) -> ShowMetadata { + // Stream : injecté depuis le mapping en mémoire + let stream_url = self.stream_url_for_slug(station_slug).unwrap_or_default(); + let sources = if !stream_url.is_empty() { + vec![StreamSource { + url: stream_url, + broadcast_type: crate::models::BroadcastType::Live, + format: crate::models::StreamFormat::Aac, + bitrate: 192, + }] + } else { + vec![] + }; + + // Visual : la nouvelle API donne directement l'UUID (pas une URL complète) + let visual_background = step.visual.as_deref().map(|uuid| EmbedImage { + model: "EmbedImage".to_string(), + src: ImageSize::Large.build_url(uuid), + width: None, + height: None, + dominant: None, + copyright: None, + }); + + if step.is_song() { + // === Radio musicale === + let artists = step.artists_display(); + let song = Some(Song { + id: step.song_id.clone().unwrap_or_default(), + year: step.annee_edition_musique, + interpreters: if artists.is_empty() { + vec![] + } else { + vec![artists.clone()] + }, + release: crate::models::Release { + label: step.label.clone(), + title: step.titre_album.clone(), + reference: None, + }, + }); + + ShowMetadata { + start_time: step.start, + end_time: step.end, + producer: step.disc_jockey.clone(), + first_line: Line { + title: Some(step.title.clone()), + id: None, + path: step.path.clone(), + }, + second_line: Line { + title: if artists.is_empty() { None } else { Some(artists) }, + id: None, + path: None, + }, + song, + media: Media { sources }, + visual_background, + ..ShowMetadata::default() + } + } else { + // === Radio parlée / émission === + let show_title = step.title_concept.as_deref() + .unwrap_or(step.title.as_str()) + .to_string(); + let episode_title = if step.title_concept.is_some() { + step.title.clone() + } else { + String::new() + }; + let producer = step.disc_jockey.clone() + .or_else(|| step.producers.first().map(|p| p.name.clone())); + + ShowMetadata { + start_time: step.start, + end_time: step.end, + producer, + first_line: Line { + title: Some(show_title), + id: None, + path: step.path.clone(), + }, + second_line: Line { + title: if episode_title.is_empty() { None } else { Some(episode_title) }, + id: None, + path: None, + }, + intro: step.expression_description.clone() + .or_else(|| step.description.clone()), + media: Media { sources }, + visual_background, + ..ShowMetadata::default() + } + } + } + + /// Tente de re-découvrir l'ID et l'URL stream d'une station depuis le web + /// + /// Utilisé quand un stream échoue ou qu'un slug est inconnu. + /// Met à jour le mapping en mémoire si la découverte réussit. + pub async fn rediscover_station(&self, slug: &str) -> Result<(u32, String)> { + #[cfg(feature = "logging")] + tracing::warn!("Rediscovering station mapping for: {}", slug); + + // Construire l'URL de la page de la station principale + let base_slug = slug.split('_').next().unwrap_or(slug); + let page_url = format!("{}/{}/__data.json", self.base_url, base_slug); + + let resp = self + .client + .get(&page_url) + .timeout(self.timeout) + .send() + .await?; + + if !resp.status().is_success() { + return Err(Error::ApiError(format!( + "Cannot rediscover station {}: page returned {}", + slug, + resp.status() + ))); + } + + let text = resp.text().await?; + + // Fix 2: chercher l'ID spécifique à la station via son brandEnum. + // Ex: "fip_rock" → "FIP_ROCK" → cherche `"FIP_ROCK","64"` dans le JSON SvelteKit. + // Pour les stations principales, on cherche aussi le pattern `"Brand",""`. + let brand_enum = slug.to_uppercase(); // fip_rock → FIP_ROCK + let re_enum = Regex::new(&format!(r#""{}","(\d+)""#, regex::escape(&brand_enum)))?; + let brand_id: Option = re_enum + .captures_iter(&text) + .filter_map(|c| c[1].parse::().ok()) + .next(); + + // Fallback : premier "Brand","" dans la page (pour stations principales) + let brand_id = if brand_id.is_some() { + brand_id + } else { + let re_brand = Regex::new(r#""Brand","(\d+)""#)?; + let ids: Vec = re_brand + .captures_iter(&text) + .filter_map(|c| c[1].parse::().ok()) + .collect(); + ids.into_iter().next() + }; + + let station_id = brand_id.ok_or_else(|| { + Error::ApiError(format!("Could not find station_id for {} in page data", slug)) + })?; + + // Dériver l'URL stream depuis le slug (format icecast connu) + let stream_url = Self::derive_stream_url(slug); + + self.update_station_entry(slug, station_id, stream_url.clone()); + + #[cfg(feature = "logging")] + tracing::info!( + "Rediscovered station {}: id={}, stream={}", + slug, + station_id, + stream_url + ); + + Ok((station_id, stream_url)) + } + + /// Valide qu'une station existe toujours et que son stream est accessible + /// + /// Vérifie deux choses : + /// 1. L'API livemeta répond avec un stationId valide (station connue de Radio France) + /// 2. L'URL icecast répond HTTP 200 (stream physiquement disponible) + /// + /// Retourne Ok(()) si tout est OK, Err si la station est invalide ou le stream mort. + pub async fn validate_station(&self, slug: &str) -> Result<()> { + // 1. Vérifier que l'API livemeta répond pour cette station + let station_id = self + .station_id_for_slug(slug) + .ok_or_else(|| Error::ApiError(format!("Unknown station slug: {}", slug)))?; + + let url = format!("{}/{}", LIVEMETA_API_URL, station_id); + let response = self.client.get(&url).timeout(self.timeout).send().await?; + + if !response.status().is_success() { + return Err(Error::ApiError(format!( + "Station {} (id={}) no longer exists: livemeta returned {}", + slug, station_id, response.status() + ))); + } + + let pull: PullResponse = response.json().await?; + + // Vérifier cohérence de l'ID — si mismatch, la station a peut-être été réassignée + if pull.station_id != station_id { + return Err(Error::ApiError(format!( + "Station {} ID mismatch: stored={}, API returned={} — mapping is stale", + slug, station_id, pull.station_id + ))); + } + + // 2. Vérifier que l'URL icecast répond + if let Some(stream_url) = self.stream_url_for_slug(slug) { + if !stream_url.is_empty() { + let stream_resp = self + .client + .head(&stream_url) + .timeout(Duration::from_secs(10)) + .send() + .await?; + if !stream_resp.status().is_success() { + return Err(Error::ApiError(format!( + "Stream for {} is not accessible: {} returned {}", + slug, stream_url, stream_resp.status() + ))); + } + } + } + + Ok(()) + } + + /// Dérive l'URL icecast depuis le slug selon le pattern Radio France + pub fn derive_stream_url(slug: &str) -> String { + // fip_rock → fiprock, fip_sacre_francais → fipsacrefrancais + let name = slug.replace('_', ""); + format!("https://icecast.radiofrance.fr/{}-hifi.aac", name) + } + /// Get only the current show metadata pub async fn now_playing(&self, station: &str) -> Result { let response = self.live_metadata(station).await?; @@ -464,13 +992,7 @@ impl RadioFranceClient { /// # } /// ``` pub async fn get_hifi_stream_url(&self, station: &str) -> Result { - let metadata = self.live_metadata(station).await?; - - metadata - .now - .media - .best_hifi_stream() - .map(|s| s.url.clone()) + self.stream_url_for_slug(station) .ok_or_else(|| Error::NoHifiStream(station.to_string())) } @@ -619,6 +1141,7 @@ impl ClientBuilder { client, base_url: self.base_url, timeout: self.timeout, + station_mapping: Arc::new(RwLock::new(RadioFranceClient::default_station_mapping())), }) } } @@ -644,7 +1167,7 @@ mod tests { ); assert_eq!( RadioFranceClient::parse_station_slug("francebleu_alsace"), - ("francebleu_alsace", None) + ("francebleu", Some("francebleu_alsace")) ); assert_eq!( RadioFranceClient::parse_station_slug("franceculture"), diff --git a/pmoradiofrance/src/config_ext.rs b/pmoradiofrance/src/config_ext.rs index a7242eec..0826284a 100644 --- a/pmoradiofrance/src/config_ext.rs +++ b/pmoradiofrance/src/config_ext.rs @@ -37,11 +37,24 @@ use anyhow::Result; use pmoconfig::Config; use serde::{Deserialize, Serialize}; use serde_yaml::Value; +use std::collections::HashMap; use std::time::{SystemTime, UNIX_EPOCH}; /// Default TTL for station list cache (7 days in seconds) pub const DEFAULT_STATION_CACHE_TTL_SECS: u64 = 7 * 24 * 3600; +/// Default TTL for station mapping cache (30 days — IDs and stream URLs are very stable) +pub const DEFAULT_STATION_MAPPING_TTL_SECS: u64 = 30 * 24 * 3600; + +/// Informations d'une station : ID numérique et URL du stream HiFi +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StationInfo { + /// ID numérique pour l'API livemeta/pull + pub station_id: u32, + /// URL du stream HiFi (icecast AAC ou MP3) + pub stream_url: String, +} + /// Cached station list (simplifié) #[derive(Debug, Clone, Serialize, Deserialize)] struct CachedStations { @@ -49,6 +62,14 @@ struct CachedStations { last_updated: u64, // Unix timestamp } +/// Mapping slug → StationInfo avec timestamp de mise à jour +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CachedStationMapping { + /// slug → { station_id, stream_url } + mapping: HashMap, + last_updated: u64, // Unix timestamp +} + /// Trait d'extension pour gérer la configuration Radio France dans pmoconfig /// /// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques @@ -113,6 +134,36 @@ pub trait RadioFranceConfigExt { /// Efface le cache des stations (force re-découverte) fn clear_radiofrance_station_cache(&self) -> Result<()>; + + // ======================================================================== + // Station Mapping Cache (slug → station_id + stream_url) + // ======================================================================== + + /// Récupère le mapping slug → StationInfo depuis le cache (si valide) + /// + /// # Returns + /// + /// - `Some(HashMap)` si le cache existe et n'est pas expiré + /// - `None` si absent ou TTL dépassé → il faut re-découvrir + fn get_radiofrance_station_mapping(&self) -> Result>>; + + /// Enregistre le mapping slug → StationInfo en cache + fn set_radiofrance_station_mapping(&self, mapping: &HashMap) + -> Result<()>; + + /// Récupère le TTL du mapping (en secondes, défaut 30 jours) + fn get_radiofrance_mapping_ttl(&self) -> Result; + + /// Définit le TTL du mapping (en secondes) + fn set_radiofrance_mapping_ttl(&self, ttl_secs: u64) -> Result<()>; + + /// Efface le cache du mapping (force re-découverte au prochain accès) + fn clear_radiofrance_station_mapping(&self) -> Result<()>; + + /// Met à jour une seule entrée dans le mapping (sans invalider tout le cache) + /// + /// Utilisé pour la mise à jour incrémentale lors d'une re-découverte unitaire. + fn upsert_radiofrance_station_info(&self, slug: &str, info: StationInfo) -> Result<()>; } impl RadioFranceConfigExt for Config { @@ -201,6 +252,90 @@ impl RadioFranceConfigExt for Config { // Set to null to clear self.set_value(&["sources", "radiofrance", "station_cache"], Value::Null) } + + fn get_radiofrance_station_mapping(&self) -> Result>> { + let ttl = self.get_radiofrance_mapping_ttl()?; + + match self.get_value(&["sources", "radiofrance", "station_mapping"]) { + Ok(value) => { + let cached: CachedStationMapping = serde_yaml::from_value(value)?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + if now - cached.last_updated < ttl { + Ok(Some(cached.mapping)) + } else { + Ok(None) // Expiré + } + } + Err(_) => Ok(None), // Absent + } + } + + fn set_radiofrance_station_mapping( + &self, + mapping: &HashMap, + ) -> Result<()> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let cached = CachedStationMapping { + mapping: mapping.clone(), + last_updated: now, + }; + + let value = serde_yaml::to_value(&cached)?; + self.set_value(&["sources", "radiofrance", "station_mapping"], value) + } + + fn get_radiofrance_mapping_ttl(&self) -> Result { + match self.get_value(&["sources", "radiofrance", "station_mapping_ttl_secs"]) { + Ok(Value::Number(n)) => { + if let Some(ttl) = n.as_u64() { + Ok(ttl) + } else { + self.set_radiofrance_mapping_ttl(DEFAULT_STATION_MAPPING_TTL_SECS)?; + Ok(DEFAULT_STATION_MAPPING_TTL_SECS) + } + } + _ => { + self.set_radiofrance_mapping_ttl(DEFAULT_STATION_MAPPING_TTL_SECS)?; + Ok(DEFAULT_STATION_MAPPING_TTL_SECS) + } + } + } + + fn set_radiofrance_mapping_ttl(&self, ttl_secs: u64) -> Result<()> { + self.set_value( + &["sources", "radiofrance", "station_mapping_ttl_secs"], + Value::Number(serde_yaml::Number::from(ttl_secs)), + ) + } + + fn clear_radiofrance_station_mapping(&self) -> Result<()> { + self.set_value( + &["sources", "radiofrance", "station_mapping"], + Value::Null, + ) + } + + fn upsert_radiofrance_station_info(&self, slug: &str, info: StationInfo) -> Result<()> { + // Charger le mapping existant (même expiré) pour mise à jour incrémentale + let mut mapping = match self.get_value(&["sources", "radiofrance", "station_mapping"]) { + Ok(value) => serde_yaml::from_value::(value) + .map(|c| c.mapping) + .unwrap_or_default(), + Err(_) => HashMap::new(), + }; + + mapping.insert(slug.to_string(), info); + + self.set_radiofrance_station_mapping(&mapping) + } } #[cfg(test)] diff --git a/pmoradiofrance/src/lib.rs b/pmoradiofrance/src/lib.rs index 5eb491f6..1fb0788d 100644 --- a/pmoradiofrance/src/lib.rs +++ b/pmoradiofrance/src/lib.rs @@ -122,7 +122,7 @@ pub use models::{ }; #[cfg(feature = "pmoconfig")] -pub use config_ext::RadioFranceConfigExt; +pub use config_ext::{RadioFranceConfigExt, StationInfo}; #[cfg(feature = "pmoconfig")] pub use metadata_cache::{CachedMetadata, MetadataCache, MetadataUpdateCallback}; diff --git a/pmoradiofrance/src/metadata_cache.rs b/pmoradiofrance/src/metadata_cache.rs index 14fa67c8..6e88593e 100644 --- a/pmoradiofrance/src/metadata_cache.rs +++ b/pmoradiofrance/src/metadata_cache.rs @@ -12,9 +12,11 @@ //! - `MetadataCache` : Gère le cache in-memory + cache persistant + événements use crate::client::RadioFranceClient; +use crate::config_ext::{RadioFranceConfigExt, StationInfo}; use crate::error::Result; use crate::models::{ImageSize, LiveResponse, Station}; use pmoconfig::Config; +#[cfg(feature = "playlist")] use pmodidl::{Container, Item, Resource}; use std::collections::HashMap; use std::sync::Arc; @@ -59,6 +61,9 @@ pub struct CachedMetadata { // TTL = end_time de l'API Radio France pub end_time: Option, // Unix timestamp + + /// Timestamp de la dernière récupération (pour TTL minimum) + pub fetched_at: u64, } impl CachedMetadata { @@ -89,6 +94,10 @@ impl CachedMetadata { // 4. TTL = end_time let end_time = live.now.end_time; + let fetched_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); Ok(Self { slug: station.slug.clone(), @@ -106,6 +115,7 @@ impl CachedMetadata { nr_audio_channels, duration, end_time, + fetched_at, }) } @@ -349,6 +359,7 @@ impl CachedMetadata { /// Construit un Container DIDL de playlist à un item /// /// 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 { @@ -432,17 +443,23 @@ impl CachedMetadata { } } + /// TTL minimum quand l'API ne fournit pas de end_time (3 minutes) + pub const FALLBACK_TTL_SECS: u64 = 3 * 60; + /// Vérifie si le TTL est dépassé + /// + /// - Si `end_time` est fourni par l'API : expire exactement quand l'émission se termine + /// - Sinon : expire après `FALLBACK_TTL_SECS` depuis le dernier fetch pub fn is_expired(&self) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + if let Some(end_time) = self.end_time { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); now >= end_time } else { - // Pas de end_time = toujours expiré (refresh systématique) - true + now >= self.fetched_at + Self::FALLBACK_TTL_SECS } } } @@ -478,6 +495,9 @@ pub struct MetadataCache { impl MetadataCache { /// Constructeur avec tous les paramètres obligatoires + /// + /// Charge automatiquement le mapping station depuis pmoconfig (si valide). + /// Si le cache est absent ou expiré, utilise les valeurs hardcodées du client. #[cfg(feature = "cache")] pub fn new( client: RadioFranceClient, @@ -485,6 +505,25 @@ impl MetadataCache { server_base_url: String, config: Arc, ) -> Self { + // Charger le mapping depuis pmoconfig et l'injecter dans le client + if let Ok(Some(mapping)) = config.get_radiofrance_station_mapping() { + #[cfg(feature = "logging")] + tracing::info!( + "Loaded station mapping from config ({} entries)", + mapping.len() + ); + client.set_station_mapping(mapping); + } else { + // Pas de cache persistant : persister le mapping par défaut + let mapping = client.get_station_mapping(); + let _ = config.set_radiofrance_station_mapping(&mapping); + #[cfg(feature = "logging")] + tracing::info!( + "Initialized station mapping from defaults ({} entries)", + mapping.len() + ); + } + Self { cache: Arc::new(RwLock::new(HashMap::new())), client, @@ -525,18 +564,46 @@ impl MetadataCache { let live_response = match self.client.live_metadata(slug).await { Ok(resp) => resp, Err(e) => { - // Graceful degradation: retourner les données expirées si API down - let cache = self.cache.read().await; - if let Some(metadata) = cache.get(slug) { + // Si station inconnue → tenter une re-découverte du mapping + let is_unknown = e.to_string().contains("Unknown station slug"); + if is_unknown { #[cfg(feature = "logging")] - tracing::warn!( - "API Radio France down for {}, using expired cache: {}", - slug, - e - ); - return Ok(metadata.clone()); + tracing::warn!("Unknown station '{}', attempting rediscovery", slug); + + match self.client.rediscover_station(slug).await { + Ok(_) => { + // Persister le mapping mis à jour + self.persist_station_mapping(); + // Réessayer la requête + match self.client.live_metadata(slug).await { + Ok(resp) => resp, + Err(e2) => return Err(e2), + } + } + Err(discover_err) => { + #[cfg(feature = "logging")] + tracing::error!( + "Rediscovery failed for '{}': {}", + slug, + discover_err + ); + return Err(e); + } + } + } else { + // Graceful degradation: retourner les données expirées si API down + let cache = self.cache.read().await; + if let Some(metadata) = cache.get(slug) { + #[cfg(feature = "logging")] + tracing::warn!( + "API Radio France down for {}, using expired cache: {}", + slug, + e + ); + return Ok(metadata.clone()); + } + return Err(e); } - return Err(e); } }; @@ -575,17 +642,34 @@ impl MetadataCache { Ok(metadata) } - /// Récupère la liste des stations (cache persistant via pmoconfig) + /// Récupère la liste des stations (cache persistant via pmoconfig, TTL 7 jours) /// /// # Logique /// - /// 1. Essaie de lire depuis pmoconfig - /// 2. Si cache valide (TTL 1 semaine), retourne - /// 3. Sinon, découvre via API et met à jour pmoconfig + /// 1. Essaie de lire depuis pmoconfig (cache TTL 7 jours) + /// 2. Si cache valide, retourne sans appel réseau + /// 3. Sinon, découvre via scraping et met à jour pmoconfig pub async fn get_stations(&self) -> Result> { - // TODO: Implémenter avec config_ext - // Pour l'instant, découvre directement - self.client.discover_all_stations().await + // 1. Essayer le cache persistant + if let Ok(Some(stations)) = self.config.get_radiofrance_stations_cached() { + #[cfg(feature = "logging")] + tracing::debug!("Using cached station list ({} stations)", stations.len()); + return Ok(stations); + } + + // 2. Cache miss : découverte via scraping + #[cfg(feature = "logging")] + tracing::info!("Station cache miss, discovering via web scraping..."); + + let stations = self.client.discover_all_stations().await?; + + // 3. Persister dans pmoconfig + if let Err(e) = self.config.set_radiofrance_cached_stations(&stations) { + #[cfg(feature = "logging")] + tracing::warn!("Failed to cache station list: {}", e); + } + + Ok(stations) } /// Récupère les métadonnées live brutes de l'API (sans cache) @@ -601,6 +685,60 @@ impl MetadataCache { self.client.get_hifi_stream_url(slug).await } + /// Force une re-découverte du mapping pour un slug donné + /// + /// Utilisé quand un stream retourne une erreur HTTP (404, connexion refusée, etc.) + /// Persiste le mapping mis à jour dans pmoconfig. + pub async fn handle_stream_failure(&self, slug: &str) { + #[cfg(feature = "logging")] + tracing::warn!("Stream failure for '{}', triggering rediscovery", slug); + + match self.client.rediscover_station(slug).await { + Ok((id, url)) => { + #[cfg(feature = "logging")] + tracing::info!( + "Re-discovered station '{}': id={}, url={}", + slug, + id, + url + ); + self.persist_station_mapping(); + // Invalider le cache mémoire pour forcer un refresh des métadonnées + let mut cache = self.cache.write().await; + cache.remove(slug); + } + Err(e) => { + #[cfg(feature = "logging")] + tracing::error!("Rediscovery failed for '{}': {}", slug, e); + } + } + } + + /// Persiste le mapping courant dans pmoconfig + fn persist_station_mapping(&self) { + let mapping = self.client.get_station_mapping(); + if let Err(e) = self.config.set_radiofrance_station_mapping(&mapping) { + #[cfg(feature = "logging")] + tracing::warn!("Failed to persist station mapping: {}", e); + } + } + + /// Met à jour manuellement le mapping d'une station et le persiste + pub fn update_station_mapping(&self, slug: &str, station_id: u32, stream_url: String) { + self.client + .update_station_entry(slug, station_id, stream_url.clone()); + + // Persister dans pmoconfig + let info = StationInfo { + station_id, + stream_url, + }; + if let Err(e) = self.config.upsert_radiofrance_station_info(slug, info) { + #[cfg(feature = "logging")] + tracing::warn!("Failed to upsert station info for '{}': {}", slug, e); + } + } + /// S'abonner aux changements de métadonnées /// /// Le callback sera appelé avec le slug chaque fois que @@ -634,7 +772,12 @@ mod tests { #[test] fn test_cached_metadata_is_expired() { - let metadata = CachedMetadata { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let base = CachedMetadata { slug: "test".to_string(), title: "Test".to_string(), creator: None, @@ -650,15 +793,26 @@ mod tests { nr_audio_channels: None, duration: None, end_time: Some(0), // Dans le passé + fetched_at: now, }; - assert!(metadata.is_expired()); + // end_time dans le passé → expiré + assert!(base.is_expired()); - let metadata_no_end = CachedMetadata { + // end_time dans le futur → non expiré + let not_expired = CachedMetadata { end_time: Some(now + 3600), ..base.clone() }; + assert!(!not_expired.is_expired()); + + // Pas de end_time, fetched_at récent → non expiré (fallback TTL) + let no_end_fresh = CachedMetadata { end_time: None, fetched_at: now, ..base.clone() }; + assert!(!no_end_fresh.is_expired()); + + // Pas de end_time, fetched_at ancien → expiré + let no_end_old = CachedMetadata { end_time: None, - ..metadata + fetched_at: now - CachedMetadata::FALLBACK_TTL_SECS - 1, + ..base }; - - assert!(metadata_no_end.is_expired()); + assert!(no_end_old.is_expired()); } } diff --git a/pmoradiofrance/src/models.rs b/pmoradiofrance/src/models.rs index 3e7a82e2..9777f590 100644 --- a/pmoradiofrance/src/models.rs +++ b/pmoradiofrance/src/models.rs @@ -31,14 +31,117 @@ impl Station { } // ============================================================================ -// Live API Response Models +// New livemeta/pull API Models (api.radiofrance.fr) // ============================================================================ -/// Response from the /api/live? endpoint +/// Response from https://api.radiofrance.fr/livemeta/pull/{stationId} +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PullResponse { + /// Numeric station ID + pub station_id: u32, + /// Map of stepId -> step metadata + pub steps: std::collections::HashMap, + /// Ordered levels (depth 1 = current show/song) + pub levels: Vec, +} + +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 level = self.levels.first()?; + let step_id = level.items.first()?; + self.steps.get(step_id) + } +} + +/// A level in the livemeta response (groups steps by depth) +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PullLevel { + /// Ordered list of step IDs at this level + pub items: Vec, + /// Depth (1 = show/song, 2 = sub-item, 3 = deeper) + pub position: u32, +} + +/// A step (show, episode, or song) from the livemeta pull API +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PullStep { + pub uuid: String, + pub step_id: String, + pub title: String, + pub start: Option, + pub end: Option, + pub station_id: u32, + pub embed_type: Option, // "song", "expression", "concept" + pub depth: u32, + pub disc_jockey: Option, + /// For songs: authors + #[serde(default)] + pub authors: serde_json::Value, // can be String or Vec + #[serde(default)] + pub performers: Option, + #[serde(default)] + pub highlighted_artists: Vec, + pub song_id: Option, + pub titre_album: Option, + pub label: Option, + pub annee_edition_musique: Option, + pub cover_uuid: Option, + /// Direct UUID for visual (new API uses UUID directly, not URL) + pub visual: Option, + /// For shows: concept title (e.g. "La Science, CQFD") + pub title_concept: Option, + /// For shows: producers list + #[serde(default)] + pub producers: Vec, + pub path: Option, + pub expression_description: Option, + pub description: Option, +} + +impl PullStep { + /// Returns true if this step is a music track + pub fn is_song(&self) -> bool { + self.embed_type.as_deref() == Some("song") + } + + /// Get artist display string + pub fn artists_display(&self) -> String { + if !self.highlighted_artists.is_empty() { + return self.highlighted_artists.join(", "); + } + match &self.authors { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Array(arr) => arr + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", "), + _ => String::new(), + } + } +} + +/// A producer in a PullStep +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PullProducer { + pub uuid: String, + pub name: String, +} + +// ============================================================================ +// Live API Response Models (internal representation) +// ============================================================================ + +/// Internal live metadata representation +/// Built from PullResponse (new API) or used directly in tests #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct LiveResponse { - /// Station name (slug) + /// Station slug pub station_name: String, /// Recommended delay before next refresh (milliseconds) pub delay_to_refresh: u64, @@ -59,7 +162,7 @@ impl LiveResponse { } /// Metadata for a show or track currently playing -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct ShowMetadata { /// Whether to display music program info diff --git a/pmoradiofrance/src/playlist.rs b/pmoradiofrance/src/playlist.rs index a221ea52..5c0a2fd4 100644 --- a/pmoradiofrance/src/playlist.rs +++ b/pmoradiofrance/src/playlist.rs @@ -98,18 +98,26 @@ impl StationGroups { /// Niveau 0: to_didl() retourne le container "radiofrance" avec tous les groupes /// - /// Appelle to_stub() sur chaque StationGroup + /// Appelle to_stub() sur chaque StationGroup en parallèle pub async fn to_didl( &self, metadata_cache: &MetadataCache, server_base_url: &str, ) -> Result { - let mut containers = Vec::new(); + use futures::stream::{self, StreamExt}; - for group in &self.groups { - let container = group.to_stub(metadata_cache, server_base_url).await?; - containers.push(container); - } + let futures: Vec<_> = self + .groups + .iter() + .map(|group| group.to_stub(metadata_cache, server_base_url)) + .collect(); + + let results: Vec> = stream::iter(futures) + .buffer_unordered(8) + .collect() + .await; + + let containers: Vec = results.into_iter().collect::>>()?; Ok(Container { id: "radiofrance".to_string(),