diff --git a/.DS_Store b/.DS_Store index 119d664b..f82f7f4a 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/Blackboard/Report/Construire_pmoradiofrance.md b/Blackboard/Report/Construire_pmoradiofrance.md index f982b83c..63a921f6 100644 --- a/Blackboard/Report/Construire_pmoradiofrance.md +++ b/Blackboard/Report/Construire_pmoradiofrance.md @@ -203,4 +203,216 @@ cargo run -p pmoradiofrance --example live_metadata -- fip_rock --- -**Fin du rapport** +## Round 4bis : Client Stateful et Support Playlist (2026-01-22) + +### Objectif + +Compléter l'implémentation avec un client stateful qui gère automatiquement le cache et les structures pour la génération de playlists UPnP. + +### Fichiers créés + +| Fichier | Description | +|---------|-------------| +| `pmoradiofrance/src/stateful_client.rs` | `RadioFranceStatefulClient` avec cache automatique | +| `pmoradiofrance/src/playlist.rs` | Structures pour playlists UPnP (groupes, items) | + +### Fichiers modifiés + +| Fichier | Modification | +|---------|--------------| +| `pmoradiofrance/src/lib.rs` | Ajout modules `stateful_client` et `playlist` + re-exports | +| `pmoradiofrance/src/error.rs` | Ajout variant `Config(#[from] anyhow::Error)` pour conversion | +| `pmoradiofrance/Cargo.toml` | Ajout dépendance `pmodidl` pour feature `playlist` | + +--- + +### RadioFranceStatefulClient + +Client de haut niveau avec gestion automatique du cache : + +```rust +use pmoradiofrance::RadioFranceStatefulClient; +use pmoconfig::get_config; + +let config = get_config(); +let client = RadioFranceStatefulClient::new(config).await?; + +// Cache automatique des stations (7 jours par défaut) +let stations = client.get_stations().await?; + +// Cache intelligent des métadonnées (respecte delayToRefresh) +let metadata = client.get_live_metadata("franceculture").await?; +``` + +**Caractéristiques** : + +- **Cache à deux niveaux** : + - Liste des stations : persisté dans pmoconfig (7 jours) + - Métadonnées live : en mémoire (TTL dynamique de l'API) + +- **Thread-safe** : Clone + Send + Sync via `Arc>` + +- **Gestion intelligente du TTL** : + - Stations : configurable via `set_station_cache_ttl()` + - Métadonnées : utilise `delayToRefresh` de l'API + +### Structures de Playlist + +#### StationGroups + +Organisation hiérarchique des stations pour navigation UPnP : + +```rust +pub struct StationGroups { + pub standalone: Vec, // Sans webradios + pub with_webradios: Vec, // Avec webradios + pub local_radios: Vec, // France Bleu/ICI +} + +pub struct StationGroup { + pub main: Station, + pub webradios: Vec, +} +``` + +**Logique de groupement** : +- Stations standalone : France Inter, France Culture, France Info, Mouv' +- Groupes avec webradios : FIP (+ FIP Rock, Jazz...), France Musique (+ variantes) +- Radios locales : ~44 radios ICI (ex-France Bleu) + +#### StationPlaylist + +Playlist UPnP volatile pour une station : + +```rust +pub struct StationPlaylist { + pub id: String, + pub station: Station, + pub stream_item: Item, // Item UPnP avec métadonnées +} +``` + +**Mapping des métadonnées vers UPnP** : + +| Type | title | artist | album | class | +|------|-------|--------|-------|-------| +| **Radio parlée** | émission • titre | producteur | émission | audioBroadcast | +| **Radio musicale** | titre chanson | artiste(s) | album | musicTrack | + +**Gestion des covers** : +- Extraction UUID depuis `visual_background` +- Cache via `pmocovers` (optionnel) +- URLs Pikapi haute résolution (Large: 560x960) + +--- + +### Corrections et améliorations + +#### 1. Gestion des erreurs + +**Problème** : Les méthodes `pmoconfig` retournent `anyhow::Result` mais le client utilise son propre type `Result`. + +**Solution** : Ajout d'un variant dans `Error` pour conversion automatique : +```rust +pub enum Error { + // ... + #[error("Configuration error: {0}")] + Config(#[from] anyhow::Error), +} +``` + +#### 2. Feature playlist + +**Ajout** : Dépendance `pmodidl` pour les structures DIDL-Lite (Item, Resource) : +```toml +[features] +playlist = ["dep:pmoplaylist", "dep:pmodidl"] +``` + +#### 3. Doctests propres + +**Problème initial** : Exemples marqués `ignore` mais testés avec `--include-ignored`. + +**Solution** : Utilisation de `no_run` avec contexte async complet : +```rust +/// ```no_run +/// use pmoradiofrance::RadioFranceStatefulClient; +/// use pmoconfig::get_config; +/// +/// #[tokio::main] +/// async fn main() -> Result<(), Box> { +/// let config = get_config(); +/// let client = RadioFranceStatefulClient::new(config).await?; +/// // ... +/// Ok(()) +/// } +/// ``` +``` + +**Avantages** : +- Exemples compilés (vérification syntaxe) +- Pas exécutés (pas de dépendance réseau) +- Lignes de contexte cachées avec `#` dans la doc générée + +#### 4. Conditional compilation propre + +**Feature `logging`** pour le debug : +```rust +#[cfg(feature = "logging")] +fn remaining_ttl(&self) -> Duration { ... } + +#[cfg(feature = "logging")] +tracing::debug!("Using cached metadata for {} (TTL: {:?})", + station, entry.remaining_ttl()); +``` + +--- + +### Tests + +**Résultats** : +- ✅ Tests unitaires : **26/26 passés** +- ✅ Tests d'intégration (API réelle) : **26/26 passés** +- ✅ Doctests : **12/12 compilés** + +```bash +# Tests complets (unitaires + intégration + doctests) +cargo test -p pmoradiofrance -- --include-ignored + +# Tests unitaires uniquement +cargo test -p pmoradiofrance --lib +``` + +--- + +### Règles métier importantes + +1. **URLs de stream constantes** : L'URL du stream ne change JAMAIS, seules les métadonnées changent +2. **Polling intelligent** : Toujours respecter `delayToRefresh` de l'API +3. **Renommage France Bleu → ICI** : Les slugs restent `francebleu_*` mais l'affichage utilise "ICI" +4. **Validation du cache** : Triple vérification (existence + TTL + version d'algorithme) + +--- + +### Prochaines étapes + +1. **Implémenter `source.rs`** : Trait `MusicSource` pour intégration UPnP + - Génération automatique des playlists via `StationGroups` + - Rafraîchissement périodique des métadonnées (respecte `delayToRefresh`) + - Gestion des streams live continus (pas de FIFO - API ne fournit que des flux) + +2. **Intégration serveur** : Routes API REST via `pmoserver` + - `/radiofrance/stations` : Liste des stations groupées + - `/radiofrance/{slug}/metadata` : Métadonnées live avec cache + - `/radiofrance/{slug}/stream` : Redirection vers flux HiFi + - Cache registry pour les covers + +3. **Optimisations** : + - Pool de connexions HTTP partagé entre instances + - Préchargement intelligent des métadonnées (stations populaires) + - Métriques de cache (hit rate, age, refresh count) + - Compression des réponses API + +--- + +**Fin du rapport Round 4bis** diff --git a/Cargo.lock b/Cargo.lock index 5f2098ae..010ae188 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4151,6 +4151,7 @@ dependencies = [ "pmoaudiocache", "pmoconfig", "pmocovers", + "pmodidl", "pmoplaylist", "pmosource", "regex", diff --git a/pmoradiofrance/Cargo.toml b/pmoradiofrance/Cargo.toml index 1784c26c..768fa6a9 100644 --- a/pmoradiofrance/Cargo.toml +++ b/pmoradiofrance/Cargo.toml @@ -42,6 +42,9 @@ regex = "1.11" # Common music source traits pmosource = { path = "../pmosource" } +# DIDL-Lite structures (for playlist support) +pmodidl = { path = "../pmodidl", optional = true } + # Configuration support pmoconfig = { path = "../pmoconfig", optional = true } @@ -59,7 +62,7 @@ pmoconfig = ["dep:pmoconfig"] # Feature for cache support cache = ["dep:pmocovers", "dep:pmoaudiocache"] # Feature for playlist/FIFO support -playlist = ["dep:pmoplaylist"] +playlist = ["dep:pmoplaylist", "dep:pmodidl"] # Feature for logging (tracing) logging = [] # Feature for server support (cache registry) diff --git a/pmoradiofrance/src/config_ext.rs b/pmoradiofrance/src/config_ext.rs index e73a170f..867d613a 100644 --- a/pmoradiofrance/src/config_ext.rs +++ b/pmoradiofrance/src/config_ext.rs @@ -11,10 +11,11 @@ //! //! # Exemple //! -//! ```rust,ignore +//! ```no_run //! use pmoconfig::get_config; //! use pmoradiofrance::RadioFranceConfigExt; //! +//! # fn main() -> anyhow::Result<()> { //! let config = get_config(); //! //! // Check if enabled @@ -27,6 +28,8 @@ //! if let Some(cached) = config.get_radiofrance_cached_stations()? { //! println!("Found {} cached stations", cached.stations.len()); //! } +//! # Ok(()) +//! # } //! ``` use crate::models::{CachedStationList, Station}; @@ -121,7 +124,11 @@ pub trait RadioFranceConfigExt { /// /// # Example /// - /// ```rust,ignore + /// ```no_run + /// # use pmoconfig::get_config; + /// # use pmoradiofrance::{RadioFranceConfigExt, RadioFranceClient}; + /// # #[tokio::main] + /// # async fn main() -> anyhow::Result<()> { /// let config = get_config(); /// let stations = if let Some(cached) = config.get_radiofrance_stations_cached()? { /// cached @@ -131,6 +138,8 @@ pub trait RadioFranceConfigExt { /// config.set_radiofrance_cached_stations(&discovered)?; /// discovered /// }; + /// # Ok(()) + /// # } /// ``` fn get_radiofrance_stations_cached(&self) -> Result>>; } diff --git a/pmoradiofrance/src/error.rs b/pmoradiofrance/src/error.rs index 0fa36d2b..5e78ec85 100644 --- a/pmoradiofrance/src/error.rs +++ b/pmoradiofrance/src/error.rs @@ -50,6 +50,10 @@ pub enum Error { #[error("Request timeout")] Timeout, + /// Configuration error (from pmoconfig/anyhow) + #[error("Configuration error: {0}")] + Config(#[from] anyhow::Error), + /// Generic error #[error("{0}")] Other(String), diff --git a/pmoradiofrance/src/lib.rs b/pmoradiofrance/src/lib.rs index 64e17ea6..cf9e3fe9 100644 --- a/pmoradiofrance/src/lib.rs +++ b/pmoradiofrance/src/lib.rs @@ -52,10 +52,12 @@ //! When the `pmoconfig` feature is enabled, this crate provides a configuration //! extension trait for caching station lists: //! -//! ```rust,ignore +//! ```no_run //! use pmoconfig::get_config; -//! use pmoradiofrance::RadioFranceConfigExt; +//! use pmoradiofrance::{RadioFranceConfigExt, RadioFranceClient}; //! +//! # #[tokio::main] +//! # async fn main() -> anyhow::Result<()> { //! let config = get_config(); //! //! // Check cached stations (default TTL: 7 days) @@ -67,6 +69,8 @@ //! let stations = client.discover_all_stations().await?; //! config.set_radiofrance_cached_stations(&stations)?; //! } +//! # Ok(()) +//! # } //! ``` //! //! # API Rate Limiting @@ -91,6 +95,12 @@ pub mod models; #[cfg(feature = "pmoconfig")] pub mod config_ext; +#[cfg(feature = "pmoconfig")] +pub mod stateful_client; + +#[cfg(feature = "playlist")] +pub mod playlist; + // Re-exports pub use client::{ClientBuilder, RadioFranceClient}; pub use error::{Error, Result}; @@ -101,3 +111,9 @@ pub use models::{ #[cfg(feature = "pmoconfig")] pub use config_ext::RadioFranceConfigExt; + +#[cfg(feature = "pmoconfig")] +pub use stateful_client::RadioFranceStatefulClient; + +#[cfg(feature = "playlist")] +pub use playlist::{StationGroup, StationGroups, StationPlaylist}; diff --git a/pmoradiofrance/src/playlist.rs b/pmoradiofrance/src/playlist.rs new file mode 100644 index 00000000..aa409c46 --- /dev/null +++ b/pmoradiofrance/src/playlist.rs @@ -0,0 +1,608 @@ +//! Structures et helpers pour la construction de playlists UPnP Radio France +//! +//! Ce module fournit les structures nécessaires pour organiser les stations +//! Radio France en groupes hiérarchiques et construire des playlists UPnP +//! avec métadonnées volatiles. +//! +//! # Architecture +//! +//! - `StationGroups` : Organisation hiérarchique de toutes les stations +//! - `StationGroup` : Groupe station principale + webradios associées +//! - `StationPlaylist` : Playlist UPnP volatile pour une station +//! +//! # Exemple +//! +//! ```rust,ignore +//! use pmoradiofrance::playlist::{StationGroups, StationPlaylist}; +//! +//! // Organiser les stations en groupes +//! let groups = StationGroups::from_stations(stations); +//! +//! // Construire une playlist pour une station +//! let playlist = StationPlaylist::from_live_metadata( +//! station, +//! metadata, +//! &cover_cache, +//! server_base_url, +//! ).await?; +//! ``` + +use crate::error::Result; +use crate::models::{ImageSize, LiveResponse, Station, StationType, StreamFormat}; +use pmodidl::{Item, Resource}; + +#[cfg(feature = "cache")] +use pmocovers::Cache as CoverCache; +#[cfg(feature = "cache")] +use std::sync::Arc; + +// ============================================================================ +// Groupes de stations +// ============================================================================ + +/// Groupes de stations organisés hiérarchiquement +/// +/// Cette structure organise les stations Radio France en trois catégories : +/// - `standalone` : Stations sans webradios (France Culture, France Inter, France Info, Mouv') +/// - `with_webradios` : Groupes avec station principale + webradios (FIP, France Musique) +/// - `local_radios` : Toutes les radios ICI (ex-France Bleu) +#[derive(Debug, Clone)] +pub struct StationGroups { + /// Stations sans webradios associées + pub standalone: Vec, + /// Groupes station principale + webradios + pub with_webradios: Vec, + /// Radios locales ICI (ex-France Bleu) + pub local_radios: Vec, +} + +/// Groupe station principale + webradios associées +#[derive(Debug, Clone)] +pub struct StationGroup { + /// Station principale (ex: FIP) + pub main: Station, + /// Webradios associées (ex: FIP Rock, FIP Jazz, ...) + pub webradios: Vec, +} + +impl StationGroups { + /// Organise une liste de stations en groupes hiérarchiques + /// + /// # Logique de regroupement + /// + /// 1. Les stations locales (France Bleu/ICI) sont regroupées dans `local_radios` + /// 2. Les webradios sont associées à leur station parente + /// 3. Les stations principales sans webradios vont dans `standalone` + /// 4. Les stations avec au moins une webradio vont dans `with_webradios` + pub fn from_stations(stations: Vec) -> Self { + use std::collections::HashMap; + + let mut standalone = Vec::new(); + let mut local_radios = Vec::new(); + let mut main_stations: HashMap = HashMap::new(); + let mut webradios_by_parent: HashMap> = HashMap::new(); + + // Premier passage : trier par type + for station in stations { + match &station.station_type { + StationType::Main => { + main_stations.insert(station.slug.clone(), station); + } + StationType::Webradio { parent_station } => { + webradios_by_parent + .entry(parent_station.clone()) + .or_default() + .push(station); + } + StationType::LocalRadio { .. } => { + local_radios.push(station); + } + } + } + + // Deuxième passage : construire les groupes + let mut with_webradios = Vec::new(); + + for (slug, main) in main_stations { + if let Some(webradios) = webradios_by_parent.remove(&slug) { + // Cette station a des webradios + with_webradios.push(StationGroup { main, webradios }); + } else { + // Station standalone + standalone.push(main); + } + } + + // Trier pour un affichage cohérent + standalone.sort_by(|a, b| a.name.cmp(&b.name)); + local_radios.sort_by(|a, b| a.name.cmp(&b.name)); + with_webradios.sort_by(|a, b| a.main.name.cmp(&b.main.name)); + + for group in &mut with_webradios { + group.webradios.sort_by(|a, b| a.name.cmp(&b.name)); + } + + Self { + standalone, + with_webradios, + local_radios, + } + } + + /// Retourne toutes les stations dans un ordre de navigation logique + /// + /// Ordre : standalone, puis groupes (main + webradios), puis locales + pub fn all_stations(&self) -> impl Iterator { + self.standalone + .iter() + .chain( + self.with_webradios + .iter() + .flat_map(|g| std::iter::once(&g.main).chain(g.webradios.iter())), + ) + .chain(self.local_radios.iter()) + } + + /// Nombre total de stations + pub fn total_count(&self) -> usize { + self.standalone.len() + + self + .with_webradios + .iter() + .map(|g| 1 + g.webradios.len()) + .sum::() + + self.local_radios.len() + } +} + +impl StationGroup { + /// Retourne toutes les stations du groupe (main + webradios) + pub fn all_stations(&self) -> impl Iterator { + std::iter::once(&self.main).chain(self.webradios.iter()) + } + + /// Nombre de stations dans le groupe + pub fn count(&self) -> usize { + 1 + self.webradios.len() + } +} + +// ============================================================================ +// Playlist UPnP pour une station +// ============================================================================ + +/// Playlist UPnP volatile pour une station Radio France +/// +/// Contient UN SEUL item représentant le stream live. +/// Les métadonnées de l'item changent au fil du temps (émissions, morceaux) +/// mais l'URL du stream reste identique. +/// +/// # Volatilité +/// +/// - L'URL du stream ne change JAMAIS +/// - Le titre, artiste, album changent toutes les 2-5 minutes +/// - La cover change avec chaque nouvelle émission/morceau +#[derive(Debug, Clone)] +pub struct StationPlaylist { + /// ID de la playlist (ex: "radiofrance:franceculture") + pub id: String, + + /// Station source + pub station: Station, + + /// Item UPnP unique représentant le stream + pub stream_item: Item, +} + +impl StationPlaylist { + /// Construit une playlist depuis les métadonnées live + /// + /// # Arguments + /// + /// * `station` - Station Radio France + /// * `metadata` - Métadonnées live de l'API + /// * `cover_cache` - Cache des covers (optionnel) + /// * `server_base_url` - URL de base du serveur pour les covers cachées + /// + /// # Mapping des métadonnées + /// + /// Pour **radios parlées** (France Culture, France Inter, France Info) : + /// - `title` = émission + titre du jour + /// - `artist` = producteur + /// - `album` = nom de l'émission + /// + /// Pour **radios musicales** (FIP, France Musique) : + /// - Si morceau en cours : titre, artiste, album du morceau + /// - Sinon : fallback sur le mapping radio parlée + #[cfg(feature = "cache")] + pub async fn from_live_metadata( + station: Station, + metadata: &LiveResponse, + cover_cache: Option<&Arc>, + server_base_url: Option<&str>, + ) -> Result { + let id = format!("radiofrance:{}", station.slug); + let stream_item = + Self::build_item_from_metadata(&station, metadata, cover_cache, server_base_url) + .await?; + + Ok(Self { + id, + station, + stream_item, + }) + } + + /// Construit une playlist sans cache de covers + pub fn from_live_metadata_no_cache(station: Station, metadata: &LiveResponse) -> Result { + let id = format!("radiofrance:{}", station.slug); + let stream_item = Self::build_item_from_metadata_sync(&station, metadata, None)?; + + Ok(Self { + id, + station, + stream_item, + }) + } + + /// Met à jour les métadonnées volatiles de l'item + /// + /// Met à jour uniquement les champs volatiles : + /// - title, artist, album (depuis nouvelles métadonnées) + /// - album_art / album_art_pk (si nouvelle cover) + /// + /// L'URL du stream (resource.url) ne change JAMAIS. + #[cfg(feature = "cache")] + pub async fn update_metadata( + &mut self, + metadata: &LiveResponse, + cover_cache: Option<&Arc>, + server_base_url: Option<&str>, + ) -> Result<()> { + // Reconstruire l'item avec les nouvelles métadonnées + // mais conserver l'URL du stream + let old_url = self + .stream_item + .resources + .first() + .map(|r| r.url.clone()) + .unwrap_or_default(); + + let mut new_item = + Self::build_item_from_metadata(&self.station, metadata, cover_cache, server_base_url) + .await?; + + // S'assurer que l'URL du stream n'a pas changé + if let Some(res) = new_item.resources.first_mut() { + if !old_url.is_empty() { + res.url = old_url; + } + } + + self.stream_item = new_item; + Ok(()) + } + + /// Met à jour les métadonnées sans cache + pub fn update_metadata_no_cache(&mut self, metadata: &LiveResponse) -> Result<()> { + let old_url = self + .stream_item + .resources + .first() + .map(|r| r.url.clone()) + .unwrap_or_default(); + + let mut new_item = Self::build_item_from_metadata_sync(&self.station, metadata, None)?; + + if let Some(res) = new_item.resources.first_mut() { + if !old_url.is_empty() { + res.url = old_url; + } + } + + self.stream_item = new_item; + Ok(()) + } + + /// Construit un Item UPnP depuis les métadonnées live (avec cache) + #[cfg(feature = "cache")] + async fn build_item_from_metadata( + station: &Station, + metadata: &LiveResponse, + cover_cache: Option<&Arc>, + server_base_url: Option<&str>, + ) -> Result { + let (title, creator, artist, album, genre, class) = + Self::extract_metadata_fields(station, metadata); + + // Gestion de la cover + let (album_art, album_art_pk) = if let Some(cache) = cover_cache { + Self::cache_cover(metadata, cache, server_base_url).await + } else { + Self::extract_cover_url(metadata) + }; + + // Construction de la ressource (stream) + let resource = Self::build_stream_resource(metadata); + + Ok(Item { + id: format!("radiofrance:{}:stream", station.slug), + parent_id: format!("radiofrance:{}", station.slug), + restricted: Some("1".to_string()), + title, + creator, + class, + artist, + album, + genre, + album_art, + album_art_pk, + date: None, + original_track_number: None, + resources: vec![resource], + descriptions: vec![], + }) + } + + /// Construit un Item UPnP depuis les métadonnées live (sans cache async) + fn build_item_from_metadata_sync( + station: &Station, + metadata: &LiveResponse, + _cover_url_override: Option, + ) -> Result { + let (title, creator, artist, album, genre, class) = + Self::extract_metadata_fields(station, metadata); + + let (album_art, album_art_pk) = Self::extract_cover_url(metadata); + let resource = Self::build_stream_resource(metadata); + + Ok(Item { + id: format!("radiofrance:{}:stream", station.slug), + parent_id: format!("radiofrance:{}", station.slug), + restricted: Some("1".to_string()), + title, + creator, + class, + artist, + album, + genre, + album_art, + album_art_pk, + date: None, + original_track_number: None, + resources: vec![resource], + descriptions: vec![], + }) + } + + /// Extrait les champs de métadonnées selon le type de radio + fn extract_metadata_fields( + station: &Station, + metadata: &LiveResponse, + ) -> ( + String, + Option, + Option, + Option, + Option, + String, + ) { + let now = &metadata.now; + + // Détecter si c'est une radio musicale avec un morceau en cours + if let Some(ref song) = now.song { + // Radio musicale avec morceau + let title = now.first_line.title_or_default().to_string(); + let artist = if song.interpreters.is_empty() { + None + } else { + Some(song.artists_display()) + }; + let album = song.release.title.clone(); + let creator = artist.clone(); + let genre = Some("Music".to_string()); + let class = "object.item.audioItem.musicTrack".to_string(); + + (title, creator, artist, album, genre, class) + } else { + // Radio parlée ou segment talk sur radio musicale + let first = now.first_line.title_or_default(); + let second = now.second_line.title_or_default(); + + let title = if !first.is_empty() && !second.is_empty() { + format!("{} • {}", first, second) + } else if !first.is_empty() { + first.to_string() + } else { + station.display_name().to_string() + }; + + let creator = now.producer.clone(); + let artist = now.producer.clone(); + let album = if !first.is_empty() { + Some(first.to_string()) + } else { + None + }; + let genre = Some("Talk Radio".to_string()); + let class = "object.item.audioItem.audioBroadcast".to_string(); + + (title, creator, artist, album, genre, class) + } + } + + /// Extrait l'URL de cover depuis les métadonnées (sans cache) + fn extract_cover_url(metadata: &LiveResponse) -> (Option, Option) { + // Priorité : visual_background > song cover + if let Some(ref visual) = metadata.now.visual_background { + if let Some(uuid) = visual.extract_uuid() { + let url = ImageSize::Large.build_url(&uuid); + return (Some(url), None); + } + } + + // Pas de cover trouvée + (None, None) + } + + /// Cache la cover et retourne (url_publique, pk) + #[cfg(feature = "cache")] + async fn cache_cover( + metadata: &LiveResponse, + cache: &Arc, + server_base_url: Option<&str>, + ) -> (Option, Option) { + // Extraire l'UUID de la cover + let uuid = metadata + .now + .visual_background + .as_ref() + .and_then(|v| v.extract_uuid()); + + let uuid = match uuid { + Some(u) => u, + None => return (None, None), + }; + + // URL haute résolution + let cover_url = ImageSize::Large.build_url(&uuid); + + // Tenter de cacher la cover + match cache.add_from_url(&cover_url, Some("radiofrance")).await { + Ok(pk) => { + // Construire l'URL publique si server_base_url est fourni + let public_url = server_base_url + .map(|base| format!("{}/covers/{}", base.trim_end_matches('/'), pk)); + + (public_url.or(Some(cover_url)), Some(pk)) + } + Err(e) => { + tracing::warn!("Failed to cache Radio France cover: {}", e); + (Some(cover_url), None) + } + } + } + + /// Construit la ressource stream + fn build_stream_resource(metadata: &LiveResponse) -> Resource { + // Trouver le meilleur stream HiFi + let best_stream = metadata.now.media.best_hifi_stream(); + + let (url, protocol_info, sample_frequency, nr_audio_channels) = match best_stream { + Some(stream) => { + let protocol_info = match stream.format { + StreamFormat::Aac => "http-get:*:audio/aac:*".to_string(), + StreamFormat::Hls => "http-get:*:application/vnd.apple.mpegurl:*".to_string(), + StreamFormat::Mp3 => "http-get:*:audio/mpeg:*".to_string(), + }; + + let sample_freq = match stream.format { + StreamFormat::Aac => Some("48000".to_string()), + _ => None, + }; + + let channels = match stream.format { + StreamFormat::Aac | StreamFormat::Mp3 => Some("2".to_string()), + _ => None, + }; + + (stream.url.clone(), protocol_info, sample_freq, channels) + } + None => { + // Fallback : pas de stream trouvé + ( + String::new(), + "http-get:*:audio/aac:*".to_string(), + None, + None, + ) + } + }; + + Resource { + protocol_info, + bits_per_sample: None, + sample_frequency, + nr_audio_channels, + duration: None, // Stream live = pas de durée + url, + } + } + + /// Retourne l'URL du stream + pub fn stream_url(&self) -> Option<&str> { + self.stream_item.resources.first().map(|r| r.url.as_str()) + } + + /// Retourne le titre actuel + pub fn current_title(&self) -> &str { + &self.stream_item.title + } + + /// Retourne l'artiste actuel + pub fn current_artist(&self) -> Option<&str> { + self.stream_item.artist.as_deref() + } +} + +// ============================================================================ +// Helpers pour le renommage France Bleu → ICI +// ============================================================================ + +impl Station { + /// Retourne le nom d'affichage avec renommage France Bleu → ICI + /// + /// Les slugs sont conservés (francebleu_alsace) mais l'affichage + /// utilise "ICI" (ICI Alsace). + pub fn display_name(&self) -> &str { + // Le renommage est déjà fait lors de la découverte via l'API + // qui retourne directement "ICI Alsace" etc. + &self.name + } + + /// Vérifie si c'est une radio ICI (ex-France Bleu locale) + pub fn is_ici_radio(&self) -> bool { + self.name.starts_with("ICI ") || self.slug.starts_with("francebleu_") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_station_groups_organization() { + let stations = vec![ + Station::main("franceculture", "France Culture"), + Station::main("fip", "FIP"), + Station::webradio("fip_rock", "FIP Rock", "fip"), + Station::webradio("fip_jazz", "FIP Jazz", "fip"), + Station::local_radio("francebleu_alsace", "ICI Alsace", "Alsace", 1), + ]; + + let groups = StationGroups::from_stations(stations); + + assert_eq!(groups.standalone.len(), 1); + assert_eq!(groups.standalone[0].slug, "franceculture"); + + assert_eq!(groups.with_webradios.len(), 1); + assert_eq!(groups.with_webradios[0].main.slug, "fip"); + assert_eq!(groups.with_webradios[0].webradios.len(), 2); + + assert_eq!(groups.local_radios.len(), 1); + assert_eq!(groups.local_radios[0].slug, "francebleu_alsace"); + + assert_eq!(groups.total_count(), 5); + } + + #[test] + fn test_station_display_name() { + let station = Station::local_radio("francebleu_alsace", "ICI Alsace", "Alsace", 1); + assert_eq!(station.display_name(), "ICI Alsace"); + assert!(station.is_ici_radio()); + + let main = Station::main("franceculture", "France Culture"); + assert_eq!(main.display_name(), "France Culture"); + assert!(!main.is_ici_radio()); + } +} diff --git a/pmoradiofrance/src/stateful_client.rs b/pmoradiofrance/src/stateful_client.rs new file mode 100644 index 00000000..f7393082 --- /dev/null +++ b/pmoradiofrance/src/stateful_client.rs @@ -0,0 +1,448 @@ +//! Stateful client for Radio France with automatic caching +//! +//! This module provides a higher-level client that automatically manages +//! station discovery caching through pmoconfig, providing a simpler API +//! for integration into PMOMusic. +//! +//! # Example +//! +//! ```no_run +//! use pmoradiofrance::RadioFranceStatefulClient; +//! use pmoconfig::get_config; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let config = get_config(); +//! let client = RadioFranceStatefulClient::new(config).await?; +//! +//! // Get stations (automatically cached with 7-day TTL) +//! let stations = client.get_stations().await?; +//! +//! // Get live metadata (handles caching internally) +//! let metadata = client.get_live_metadata("franceculture").await?; +//! +//! Ok(()) +//! } +//! ``` + +use crate::client::RadioFranceClient; +use crate::config_ext::RadioFranceConfigExt; +use crate::error::{Error, Result}; +use crate::models::{LiveResponse, Station}; +use pmoconfig::Config; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +/// Cache entry for live metadata +#[derive(Debug, Clone)] +struct LiveMetadataCache { + /// Cached metadata + metadata: LiveResponse, + /// When the cache should be invalidated (based on delayToRefresh) + valid_until: SystemTime, +} + +impl LiveMetadataCache { + /// Create a new cache entry + fn new(metadata: LiveResponse) -> Self { + let delay = Duration::from_millis(metadata.delay_to_refresh); + let valid_until = SystemTime::now() + delay; + + Self { + metadata, + valid_until, + } + } + + /// Check if the cache is still valid + fn is_valid(&self) -> bool { + SystemTime::now() < self.valid_until + } + + /// Get the remaining time until the cache expires + #[cfg(feature = "logging")] + fn remaining_ttl(&self) -> Duration { + self.valid_until + .duration_since(SystemTime::now()) + .unwrap_or(Duration::ZERO) + } +} + +/// Stateful Radio France client with automatic caching +/// +/// This client wraps `RadioFranceClient` and adds: +/// - Automatic station list caching via pmoconfig +/// - Live metadata caching (in-memory, respecting delayToRefresh) +/// - Simple high-level API for PMOMusic integration +/// +/// # Caching Strategy +/// +/// - **Station List**: Cached in pmoconfig with 7-day TTL (configurable) +/// - **Live Metadata**: Cached in-memory per station, TTL from API's delayToRefresh +/// +/// # Thread Safety +/// +/// This client is thread-safe (Clone + Send + Sync) and can be shared +/// across async tasks. +#[derive(Clone)] +pub struct RadioFranceStatefulClient { + /// Underlying HTTP client + client: RadioFranceClient, + /// Configuration handle (Arc for sharing) + config: Arc, + /// In-memory cache for live metadata (thread-safe) + metadata_cache: Arc>>, +} + +impl RadioFranceStatefulClient { + /// Create a new stateful client + /// + /// # Arguments + /// + /// * `config` - Configuration handle for caching station lists + /// + /// # Example + /// + /// ```no_run + /// use pmoconfig::get_config; + /// use pmoradiofrance::RadioFranceStatefulClient; + /// + /// #[tokio::main] + /// async fn main() -> Result<(), Box> { + /// let config = get_config(); + /// let client = RadioFranceStatefulClient::new(config).await?; + /// Ok(()) + /// } + /// ``` + pub async fn new(config: Arc) -> Result { + let client = RadioFranceClient::new().await?; + Ok(Self { + client, + config, + metadata_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())), + }) + } + + /// Create a client with a custom RadioFranceClient + pub fn with_client(client: RadioFranceClient, config: Arc) -> Self { + Self { + client, + config, + metadata_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())), + } + } + + /// Get the underlying HTTP client + pub fn client(&self) -> &RadioFranceClient { + &self.client + } + + /// Get the configuration + pub fn config(&self) -> &Arc { + &self.config + } + + // ======================================================================== + // Station Discovery (with automatic caching) + // ======================================================================== + + /// Get all stations, using cache if valid + /// + /// This method automatically: + /// 1. Checks if Radio France is enabled in config + /// 2. Tries to use cached station list + /// 3. If cache miss/expired, discovers and caches stations + /// + /// # Errors + /// + /// Returns error if: + /// - Radio France is disabled in config + /// - Discovery fails and no valid cache exists + /// + /// # Example + /// + /// ```no_run + /// # use pmoradiofrance::RadioFranceStatefulClient; + /// # use pmoconfig::get_config; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # let config = get_config(); + /// # let client = RadioFranceStatefulClient::new(config).await?; + /// let stations = client.get_stations().await?; + /// for station in stations { + /// println!("{} - {}", station.name, station.slug); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn get_stations(&self) -> Result> { + // Check if Radio France is enabled + if !self.config.get_radiofrance_enabled()? { + return Err(Error::other("Radio France is disabled in configuration")); + } + + // Try to get from cache + if let Some(stations) = self.config.get_radiofrance_stations_cached()? { + #[cfg(feature = "logging")] + tracing::debug!("Using {} cached stations", stations.len()); + return Ok(stations); + } + + // Cache miss - discover and cache + #[cfg(feature = "logging")] + tracing::info!("Station cache miss - discovering stations"); + + let stations = self.client.discover_all_stations().await?; + + // Cache the results + self.config.set_radiofrance_cached_stations(&stations)?; + + #[cfg(feature = "logging")] + tracing::info!("Discovered and cached {} stations", stations.len()); + + Ok(stations) + } + + /// Force refresh of the station list (bypass cache) + /// + /// Use this to force re-discovery, for example after a manual + /// cache invalidation or to get the latest station list. + pub async fn refresh_stations(&self) -> Result> { + #[cfg(feature = "logging")] + tracing::info!("Force refreshing station list"); + + let stations = self.client.discover_all_stations().await?; + self.config.set_radiofrance_cached_stations(&stations)?; + + #[cfg(feature = "logging")] + tracing::info!("Refreshed {} stations", stations.len()); + + Ok(stations) + } + + /// Clear the station cache + /// + /// Forces next `get_stations()` call to re-discover stations. + pub fn clear_station_cache(&self) -> Result<()> { + Ok(self.config.clear_radiofrance_station_cache()?) + } + + // ======================================================================== + // Live Metadata (with intelligent caching) + // ======================================================================== + + /// Get live metadata for a station, using cache if valid + /// + /// This method automatically: + /// 1. Checks in-memory cache + /// 2. If cache valid (based on delayToRefresh), returns cached data + /// 3. If cache expired, fetches fresh data and updates cache + /// + /// # Arguments + /// + /// * `station` - Station slug (e.g., "franceculture", "fip_rock") + /// + /// # Caching Behavior + /// + /// The cache TTL is determined by the API's `delayToRefresh` field, + /// which respects Radio France's recommended polling interval. + /// + /// # Example + /// + /// ```no_run + /// # use pmoradiofrance::RadioFranceStatefulClient; + /// # use pmoconfig::get_config; + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # let config = get_config(); + /// # let client = RadioFranceStatefulClient::new(config).await?; + /// let metadata = client.get_live_metadata("franceculture").await?; + /// println!("Now: {} - {}", + /// metadata.now.first_line.title_or_default(), + /// metadata.now.second_line.title_or_default() + /// ); + /// # Ok(()) + /// # } + /// ``` + pub async fn get_live_metadata(&self, station: &str) -> Result { + // Check cache first + { + let cache = self.metadata_cache.read().unwrap(); + if let Some(entry) = cache.get(station) { + if entry.is_valid() { + #[cfg(feature = "logging")] + tracing::debug!( + "Using cached metadata for {} (TTL: {:?})", + station, + entry.remaining_ttl() + ); + return Ok(entry.metadata.clone()); + } + } + } + + // Cache miss or expired - fetch fresh data + #[cfg(feature = "logging")] + tracing::debug!("Fetching live metadata for {}", station); + + let metadata = self.client.live_metadata(station).await?; + + // Update cache + { + let mut cache = self.metadata_cache.write().unwrap(); + cache.insert( + station.to_string(), + LiveMetadataCache::new(metadata.clone()), + ); + } + + #[cfg(feature = "logging")] + tracing::debug!( + "Cached metadata for {} (TTL: {} ms)", + station, + metadata.delay_to_refresh + ); + + Ok(metadata) + } + + /// Force refresh of live metadata (bypass cache) + /// + /// Use this when you need the absolute latest metadata, + /// ignoring the cached version. + pub async fn refresh_live_metadata(&self, station: &str) -> Result { + #[cfg(feature = "logging")] + tracing::debug!("Force refreshing metadata for {}", station); + + let metadata = self.client.live_metadata(station).await?; + + // Update cache + { + let mut cache = self.metadata_cache.write().unwrap(); + cache.insert( + station.to_string(), + LiveMetadataCache::new(metadata.clone()), + ); + } + + Ok(metadata) + } + + /// Clear the metadata cache for a specific station + pub fn clear_metadata_cache(&self, station: &str) { + let mut cache = self.metadata_cache.write().unwrap(); + cache.remove(station); + + #[cfg(feature = "logging")] + tracing::debug!("Cleared metadata cache for {}", station); + } + + /// Clear all metadata caches + pub fn clear_all_metadata_caches(&self) { + let mut cache = self.metadata_cache.write().unwrap(); + cache.clear(); + + #[cfg(feature = "logging")] + tracing::debug!("Cleared all metadata caches"); + } + + // ======================================================================== + // Convenience Methods + // ======================================================================== + + /// Get the HiFi stream URL for a station + /// + /// Convenience wrapper around `get_live_metadata()` that extracts + /// the best HiFi stream URL. + pub async fn get_stream_url(&self, station: &str) -> Result { + self.client.get_hifi_stream_url(station).await + } + + /// Check if Radio France is enabled in configuration + pub fn is_enabled(&self) -> Result { + Ok(self.config.get_radiofrance_enabled()?) + } + + /// Enable Radio France in configuration + pub fn set_enabled(&self, enabled: bool) -> Result<()> { + Ok(self.config.set_radiofrance_enabled(enabled)?) + } + + /// Get the station cache TTL in seconds + pub fn get_station_cache_ttl(&self) -> Result { + Ok(self.config.get_radiofrance_station_cache_ttl()?) + } + + /// Set the station cache TTL in seconds + pub fn set_station_cache_ttl(&self, ttl_secs: u64) -> Result<()> { + Ok(self.config.set_radiofrance_station_cache_ttl(ttl_secs)?) + } + + /// Get cache statistics + /// + /// Returns (number of cached stations, number of cached metadata entries) + pub fn cache_stats(&self) -> (usize, usize) { + let station_count = self + .config + .get_radiofrance_stations_cached() + .ok() + .flatten() + .map(|s| s.len()) + .unwrap_or(0); + + let metadata_count = self.metadata_cache.read().unwrap().len(); + + (station_count, metadata_count) + } +} + +impl std::fmt::Debug for RadioFranceStatefulClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let (station_cache, metadata_cache) = self.cache_stats(); + f.debug_struct("RadioFranceStatefulClient") + .field("client", &self.client) + .field("cached_stations", &station_cache) + .field("cached_metadata_entries", &metadata_cache) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Note: Real integration tests would require pmoconfig setup + // These are just structural tests + + #[test] + fn test_live_metadata_cache_validity() { + let response = LiveResponse { + station_name: "test".to_string(), + delay_to_refresh: 5000, // 5 seconds + migrated: true, + now: crate::models::ShowMetadata { + print_prog_music: false, + start_time: None, + end_time: None, + producer: None, + first_line: Default::default(), + second_line: Default::default(), + third_line: None, + intro: None, + react_available: false, + visual_background: None, + song: None, + media: Default::default(), + visuals: None, + local_radios: None, + }, + next: None, + }; + + let cache = LiveMetadataCache::new(response); + assert!(cache.is_valid()); + + // Verify the cache expires in the future + assert!(cache.valid_until > SystemTime::now()); + } +}