diff --git a/.claude/hooks/preToolUse.sh b/.claude/hooks/preToolUse.sh new file mode 100644 index 00000000..6220f6d8 --- /dev/null +++ b/.claude/hooks/preToolUse.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Bloquer toutes les éditions sans confirmation explicite +if [[ "$TOOL_NAME" == "Edit" ]] || [[ "$TOOL_NAME" == "MultiEdit" ]]; then + echo "Édition bloquée - confirmation requise" + exit 1 +fi diff --git a/Blackboard/Report/metadata_RF_cache.md b/Blackboard/Report/metadata_RF_cache.md new file mode 100644 index 00000000..9c3eb0f0 --- /dev/null +++ b/Blackboard/Report/metadata_RF_cache.md @@ -0,0 +1,436 @@ +# Rapport : Refonte du cache Radio France avec système d'événements + +## Résumé + +Ce rapport documente le plan d'implémentation validé pour la refonte complète du système de cache de métadonnées Radio France dans la crate **pmoradiofrance**. + +**Objectif** : Éliminer la duplication des métadonnées et créer une architecture événementielle avec un seul cache de métadonnées (source unique de vérité), permettant des notifications GENA cohérentes pour les Control Points UPnP. + +**Crate concernée** : `pmoradiofrance` + +## Architecture cible + +### Principe fondamental + +**Une seule source de vérité** : Le cache HTTP avec TTL dans `RadioFranceStatefulClient`. + +```mermaid +flowchart TB + API[Radio France API] + Cache[MetadataCache
TTL automatique
Événements] + Source[RadioFranceSource
MusicSource trait] + Browse[Browse/DIDL] + Refresh[Refresh thread] + GENA[Notifications GENA UPnP] + CP[Control Point] + + API -->|Fetch quand TTL expiré| Cache + Cache -->|Événement: slug modifié| Source + Cache -->|get_metadata| Browse + Browse -->|Reconstruit containers + playlists| DIDL[DIDL] + + Refresh -->|get_metadata chaque seconde| Cache + Source -->|S'abonne aux événements| Cache + Source -->|Notifie changements| GENA + CP -->|Subscribe à playlist| GENA + + style Cache fill:#90EE90 + style Source fill:#FFB6C1 + style Refresh fill:#87CEEB +``` + +### Hiérarchie UPnP : Concept de playlist à 1 item + +**Innovation** : Chaque slug Radio France est exposé comme une **playlist contenant 1 item** (et non comme un simple item). Cela permet au Control Point de s'abonner aux changements de la playlist via GENA. + +``` +radiofrance/ (container root) +├─ Stations standalone (containers de playlists) +│ ├─ radiofrance:franceculture/ (container → playlist) +│ │ └─ radiofrance:franceculture:stream (item unique dans la playlist) +│ └─ radiofrance:franceinter/ (container → playlist) +│ └─ radiofrance:franceinter:stream (item unique) +├─ Groupes avec webradios (containers de containers) +│ ├─ radiofrance:group:fip/ (container de groupe) +│ │ ├─ radiofrance:fip/ (container → playlist) +│ │ │ └─ radiofrance:fip:stream (item) +│ │ ├─ radiofrance:fip_rock/ (container → playlist) +│ │ │ └─ radiofrance:fip_rock:stream (item) +│ │ └─ ... +└─ radiofrance:ici/ (container de groupe) + ├─ radiofrance:francebleu_alsace/ (container → playlist) + │ └─ radiofrance:francebleu_alsace:stream (item) + └─ ... +``` + +## Plan d'implémentation + +### Phase 1 : Système d'événements dans le cache + +**Fichier** : `pmoradiofrance/src/stateful_client.rs` + +#### 1.1 Ajouter le système de callback + +```rust +// Type de callback pour notifications +pub type MetadataUpdateCallback = Arc; + +pub struct RadioFranceStatefulClient { + client: RadioFranceClient, + config: Arc, + metadata_cache: Arc>>, + // NOUVEAU : Liste des callbacks abonnés + update_callbacks: Arc>>, +} +``` + +#### 1.2 Méthodes d'abonnement et notification + +```rust +impl RadioFranceStatefulClient { + /// S'abonner aux mises à jour de métadonnées + pub fn subscribe_to_updates(&self, callback: MetadataUpdateCallback) { + let mut callbacks = self.update_callbacks.write().unwrap(); + callbacks.push(callback); + } + + /// Notifier tous les abonnés d'une mise à jour + fn notify_update(&self, slug: &str) { + let callbacks = self.update_callbacks.read().unwrap(); + for callback in callbacks.iter() { + callback(slug); + } + } +} +``` + +#### 1.3 Refactorisation de get_live_metadata() + +**Principe** : Une seule méthode qui gère tout automatiquement : +- Si cache valide → retour immédiat +- Si cache expiré → fetch API + mise à jour cache + notification événements + +```rust +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() { + return Ok(entry.metadata.clone()); + } + } + } + + // Cache miss or expired - fetch from API + let metadata = tokio::time::timeout( + Duration::from_secs(5), + 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())); + } + + // Notify subscribers + self.notify_update(station); + + Ok(metadata) +} +``` + +**SUPPRIMER** : `refresh_live_metadata()` - Redondant, le TTL gère tout. + +### Phase 2 : Hiérarchie UPnP avec playlists + +**Fichier** : `pmoradiofrance/src/source.rs` + +#### 2.1 Supprimer le cache d'items + +```rust +pub struct RadioFranceSource { + pub(crate) client: RadioFranceStatefulClient, + // SUPPRIMER : playlists: Arc>>, + refresh_handles: Arc>>>, + // ... reste inchangé +} +``` + +#### 2.2 Construction du container de playlist avec item + +**Principe clé** : Un seul appel au cache, métadonnées cohérentes container/item. + +```rust +/// Construit le container de playlist avec son unique item (métadonnées cohérentes) +async fn build_station_playlist(&self, station: &Station) -> Result { + // UN SEUL appel cache - garantit cohérence + let metadata = self.client.get_live_metadata(&station.slug).await?; + + // Build l'item avec pmoDidl + let mut item = StationPlaylist::build_item_from_metadata( + station, + &metadata, + self.cover_cache.as_ref(), + self.server_base_url.as_deref(), + ).await?; + + // Parent_id de l'item = le container de playlist + let playlist_id = format!("radiofrance:{}", station.slug); + item.parent_id = playlist_id.clone(); + + // Container avec MÊMES métadonnées que l'item + let container = Container { + id: playlist_id, + parent_id: self.get_parent_id_for_station(station), + restricted: Some("1".to_string()), + child_count: Some(1), + searchable: Some("0".to_string()), + // Métadonnées identiques à l'item + title: item.title.clone(), + artist: item.artist.clone(), + album_art: item.album_art.clone(), + class: "object.container.playlistContainer".to_string(), + containers: vec![], + items: vec![item], // Item inclus dans le container + }; + + Ok(container) +} +``` + +#### 2.3 Refactorisation du browse + +Le browse est simplifié car les containers contiennent déjà leurs items. + +```rust +async fn browse(&self, object_id: &str) -> pmosource::Result { + match object_id { + "radiofrance" => { + let container = self.build_container_tree().await?; + Ok(BrowseResult::Containers(container.containers)) + } + id if id.starts_with("radiofrance:group:") => { + // Retourne des containers de playlists + let slug = id.strip_prefix("radiofrance:group:")?; + let stations = self.get_group_stations(slug).await?; + + let mut containers = Vec::new(); + for station in stations { + containers.push(self.build_station_playlist(&station).await?); + } + + Ok(BrowseResult::Containers(containers)) + } + id if id.starts_with("radiofrance:") && !id.contains(":stream") => { + // Browse de playlist - retourne le container qui contient l'item + let slug = id.strip_prefix("radiofrance:")?; + let station = self.get_station_by_slug(slug).await?; + let container = self.build_station_playlist(&station).await?; + + Ok(BrowseResult::Containers(vec![container])) + } + // ... autres cas + } +} +``` + +### Phase 3 : Notifications GENA événementielles + +**Fichier** : `pmoradiofrance/src/source.rs` + +#### 3.1 Abonnement aux événements du cache + +Dans le constructeur `new()`, s'abonner aux événements : + +```rust +pub async fn new(config: Arc) -> Result { + let client = RadioFranceStatefulClient::new(config).await?; + + let source = Self { + client, + refresh_handles: Arc::new(RwLock::new(HashMap::new())), + // ... + }; + + // S'abonner aux événements du cache + let container_notifier = source.container_notifier.clone(); + let update_id = source.update_id.clone(); + let last_change = source.last_change.clone(); + + source.client.subscribe_to_updates(Arc::new(move |slug: &str| { + let slug = slug.to_string(); + let update_id = update_id.clone(); + let last_change = last_change.clone(); + let container_notifier = container_notifier.clone(); + + // Spawn async car callback n'est pas async + tokio::spawn(async move { + *update_id.write().await += 1; + *last_change.write().await = Some(SystemTime::now()); + + if let Some(ref notifier) = container_notifier { + // Notifier le container de PLAYLIST (pas l'item) + notifier(&[format!("radiofrance:{}", slug)]); + } + }); + })); + + Ok(source) +} +``` + +#### 3.2 Simplification du refresh thread + +**Ultra-simple** : Appeler `get_live_metadata()` toutes les secondes. + +```rust +pub async fn start_metadata_refresh(&self, station_slug: &str) -> Result<()> { + let mut handles = self.refresh_handles.write().await; + + if handles.contains_key(station_slug) { + return Ok(()); // Already running + } + + let client = self.client.clone(); + let slug = station_slug.to_string(); + + let handle = tokio::spawn(async move { + loop { + // Appel simple - le cache + TTL + événements gèrent tout + let _ = client.get_live_metadata(&slug).await; + tokio::time::sleep(Duration::from_secs(1)).await; + } + }); + + handles.insert(station_slug.to_string(), handle); + Ok(()) +} +``` + +### Phase 4 : Pas de modification + +**Fichier** : `pmoradiofrance/src/config_ext.rs` + +Le cache des stations avec TTL de 7 jours est déjà correct, pas de changement. + +### Phase 5 : Synchronisation async + +**Fichier** : `pmoradiofrance/src/source.rs` + +Remplacer `std::sync::RwLock` par `tokio::sync::RwLock` pour les champs utilisés dans le callback : + +```rust +pub struct RadioFranceSource { + // ... + update_id: Arc>, + last_change: Arc>>, + // ... +} +``` + +### Phase 6 : Nettoyage du code obsolète + +**Fichier** : `pmoradiofrance/src/playlist.rs` + +Supprimer les méthodes obsolètes : +- `StationPlaylist::update_metadata()` +- `StationPlaylist::update_metadata_no_cache()` +- `StationPlaylist::from_live_metadata()` → Garder uniquement `build_item_from_metadata()` +- `StationPlaylist::from_live_metadata_no_cache()` → Garder uniquement `build_item_from_metadata_sync()` + +La structure devient un ensemble de méthodes statiques pour construire des items DIDL. + +## Fichiers à modifier + +### 1. pmoradiofrance/src/stateful_client.rs +- Ajouter `update_callbacks: Arc>>` +- Ajouter `subscribe_to_updates()` et `notify_update()` +- Refactoriser `get_live_metadata()` pour gérer cache + TTL + notifications +- **SUPPRIMER** `refresh_live_metadata()` (redondant) + +### 2. pmoradiofrance/src/source.rs +- Supprimer le champ `playlists` +- Ajouter `build_station_playlist()` (container + item, 1 seul appel cache) +- Ajouter `get_parent_id_for_station()` +- Modifier `browse()` pour utiliser `build_station_playlist()` partout +- S'abonner aux événements du cache dans `new()` +- Simplifier `start_metadata_refresh()` (appel `get_live_metadata()` toutes les secondes) +- Changer `update_id` et `last_change` vers `tokio::sync::RwLock` + +### 3. pmoradiofrance/src/playlist.rs +- Supprimer `update_metadata()` et `update_metadata_no_cache()` +- Conserver uniquement `build_item_from_metadata()` et `build_item_from_metadata_sync()` +- Simplifier la structure (méthodes statiques uniquement) + +## Tests de vérification + +### 1. Browse de la racine +- Ouvrir `radiofrance/` dans l'interface +- Vérifier des **containers** (groupes + stations standalone) +- **PAS** d'items directs à la racine + +### 2. Browse d'un groupe (ex: FIP) +- Ouvrir `radiofrance:group:fip/` +- Vérifier des **containers** (FIP, FIP Rock, FIP Jazz, etc.) +- Classe UPnP : `object.container.playlistContainer` +- Child count : `1` pour chaque + +### 3. Browse d'une playlist (ex: FIP) +- Ouvrir `radiofrance:fip/` (le container de playlist) +- Vérifier **1 seul item** : `radiofrance:fip:stream` +- Métadonnées à jour (titre, artiste, album du morceau en cours) +- Re-browse immédiat : devrait utiliser le cache HTTP (rapide) + +### 4. Lecture d'un stream +- Lancer la lecture via la playlist `radiofrance:fip/` +- Vérifier que le stream démarre +- Attendre 2-5 minutes (délai de refresh) +- Vérifier dans les logs que le refresh a lieu +- Re-browser la playlist : les métadonnées doivent avoir changé + +### 5. Vérification des événements GENA +- Avec un Control Point UPnP supportant l'abonnement aux playlists +- S'abonner à la playlist `radiofrance:fip` +- Lancer le stream +- Vérifier que les notifications GENA arrivent à chaque refresh +- Le Control Point doit re-browse automatiquement et voir les nouvelles métadonnées + +### Logs à surveiller + +``` +DEBUG RadioFranceStatefulClient: Using cached metadata for fip (TTL: XXms) +DEBUG RadioFranceStatefulClient: Fetching live metadata for fip +DEBUG RadioFranceSource: Notifying UPnP container update: radiofrance:fip +``` + +## Avantages de cette architecture + +1. **Source unique de vérité** : Le cache HTTP du `RadioFranceStatefulClient` +2. **Métadonnées toujours à jour** : TTL automatique + événements +3. **Pas de duplication** : Les items ne sont jamais stockés, reconstruits à chaque browse +4. **GENA cohérent** : Notifications envoyées uniquement lors de vraies mises à jour +5. **Code simplifié** : Moins de gestion de cache, moins de bugs possibles +6. **Performance** : Cache HTTP rapide (< 50ms pour browse complet avec cache chaud) + +## Ordre d'implémentation + +1. Phase 1 : Système d'événements dans le cache +2. Phase 5 : Corriger les RwLock (prérequis pour Phase 3) +3. Phase 3 : Refondre le refresh avec abonnement +4. Phase 2 : Modifier la hiérarchie UPnP et supprimer le cache d'items +5. Phase 6 : Nettoyer le code obsolète +6. Tests et validation + +## Notes techniques + +### Thread safety +Le callback d'événement n'est pas async. Solution : spawn une tâche async depuis le callback (déjà implémenté dans Phase 3.1). + +### Performance +Reconstruction DIDL à chaque browse mais : +- Cache HTTP évite les appels réseau +- Construction DIDL légère (structures en mémoire) +- Métadonnées toujours fraîches +- Performance attendue : < 50ms avec cache chaud diff --git a/Blackboard/Todo/metadata_RF_cache.md b/Blackboard/ToDiscuss/metadata_RF_cache.md similarity index 100% rename from Blackboard/Todo/metadata_RF_cache.md rename to Blackboard/ToDiscuss/metadata_RF_cache.md diff --git a/pmoradiofrance/src/playlist.rs b/pmoradiofrance/src/playlist.rs index 89f31ade..66a30360 100644 --- a/pmoradiofrance/src/playlist.rs +++ b/pmoradiofrance/src/playlist.rs @@ -218,111 +218,9 @@ impl StationPlaylist { /// - `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, - server_base_url: Option<&str>, - ) -> Result { - let id = format!("radiofrance:{}", station.slug); - let stream_item = Self::build_item_from_metadata_sync(&station, metadata, server_base_url)?; - - 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, - server_base_url: Option<&str>, - ) -> 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, server_base_url)?; - - 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( + pub async fn build_item_from_metadata( station: &Station, metadata: &LiveResponse, cover_cache: Option<&Arc>, @@ -381,7 +279,7 @@ impl StationPlaylist { } /// Construit un Item UPnP depuis les métadonnées live (sans cache async) - fn build_item_from_metadata_sync( + pub fn build_item_from_metadata_sync( station: &Station, metadata: &LiveResponse, server_base_url: Option<&str>, diff --git a/pmoradiofrance/src/source.rs b/pmoradiofrance/src/source.rs index 8a758a78..7824db35 100644 --- a/pmoradiofrance/src/source.rs +++ b/pmoradiofrance/src/source.rs @@ -4,7 +4,7 @@ //! providing UPnP/DLNA integration with dynamic container generation. use crate::error::Result; -use crate::models::Station; +use crate::models::{Station, StationType}; use crate::playlist::{StationGroup, StationGroups, StationPlaylist}; use crate::stateful_client::RadioFranceStatefulClient; use pmoconfig::Config; @@ -78,7 +78,7 @@ impl RadioFranceSource { pub async fn new(config: Arc) -> Result { let client = RadioFranceStatefulClient::new(config).await?; - Ok(Self { + let source = Self { client, refresh_handles: Arc::new(RwLock::new(HashMap::new())), #[cfg(feature = "cache")] @@ -87,7 +87,36 @@ impl RadioFranceSource { update_id: Arc::new(RwLock::new(0)), last_change: Arc::new(RwLock::new(None)), container_notifier: None, - }) + }; + + // S'abonner aux événements du cache pour les notifications GENA + let container_notifier = source.container_notifier.clone(); + let update_id = source.update_id.clone(); + let last_change = source.last_change.clone(); + + source + .client + .subscribe_to_updates(Arc::new(move |slug: &str| { + let slug = slug.to_string(); + let update_id = update_id.clone(); + let last_change = last_change.clone(); + let container_notifier = container_notifier.clone(); + + // Spawn async task car le callback n'est pas async + tokio::spawn(async move { + *update_id.write().await += 1; + *last_change.write().await = Some(SystemTime::now()); + + if let Some(ref notifier) = container_notifier { + // IMPORTANT : Notifier le container de playlist (pas l'item) + // Le Control Point est abonné à "radiofrance:fip" (la playlist) + // et non à "radiofrance:fip:stream" (l'item) + notifier(&[format!("radiofrance:{}", slug)]); + } + }); + })); + + Ok(source) } /// Set the container notifier for UPnP GENA events @@ -133,9 +162,8 @@ impl RadioFranceSource { #[cfg(feature = "cache")] let cover_cache = pmoupnp::cache_registry::get_cover_cache(); - Ok(Self { + let source = Self { client, - playlists: Arc::new(RwLock::new(HashMap::new())), refresh_handles: Arc::new(RwLock::new(HashMap::new())), #[cfg(feature = "cache")] cover_cache, @@ -143,10 +171,40 @@ impl RadioFranceSource { update_id: Arc::new(RwLock::new(0)), last_change: Arc::new(RwLock::new(None)), container_notifier: None, - }) + }; + + // S'abonner aux événements du cache pour les notifications GENA + let container_notifier = source.container_notifier.clone(); + let update_id = source.update_id.clone(); + let last_change = source.last_change.clone(); + + source + .client + .subscribe_to_updates(Arc::new(move |slug: &str| { + let slug = slug.to_string(); + let update_id = update_id.clone(); + let last_change = last_change.clone(); + let container_notifier = container_notifier.clone(); + + tokio::spawn(async move { + *update_id.write().await += 1; + *last_change.write().await = Some(SystemTime::now()); + + if let Some(ref notifier) = container_notifier { + notifier(&[format!("radiofrance:{}", slug)]); + } + }); + })); + + Ok(source) } /// Start metadata refresh task for a station + /// + /// Appelée par le proxy du stream audio. Cette méthode lance une tâche + /// qui appelle `get_live_metadata()` périodiquement (toutes les secondes). + /// Le cache avec TTL gère le refresh réel, et les événements GENA sont + /// déclenchés automatiquement par le système d'abonnement. pub async fn start_metadata_refresh(&self, station_slug: &str) -> Result<()> { let mut handles = self.refresh_handles.write().await; @@ -157,71 +215,26 @@ impl RadioFranceSource { let client = self.client.clone(); let slug = station_slug.to_string(); - let update_id = self.update_id.clone(); - let last_change = self.last_change.clone(); - let container_notifier = self.container_notifier.clone(); let handle = tokio::spawn(async move { loop { - // Force refresh metadata (bypass cache to get fresh data) - match client.refresh_live_metadata(&slug).await { - Ok(metadata) => { - let delay = std::time::Duration::from_millis(metadata.delay_to_refresh); + // Appeler simplement get_live_metadata + // Si le cache est valide, retour immédiat + // Si expiré, fetch API + mise à jour cache + notification GENA + let _ = client.get_live_metadata(&slug).await; - #[cfg(feature = "logging")] - { - let artist = metadata - .now - .song - .as_ref() - .and_then(|s| { - if s.interpreters.is_empty() { - None - } else { - Some(s.artists_display()) - } - }) - .unwrap_or_else(|| "".to_string()); - tracing::debug!( - "Refreshed metadata for {}: title='{}' artist='{}' delay={}ms", - slug, - metadata.now.first_line.title.as_deref().unwrap_or(""), - artist, - metadata.delay_to_refresh - ); - } - - // Update change tracking - *update_id.write().await = update_id.read().await.wrapping_add(1); - *last_change.write().await = Some(SystemTime::now()); - - // Notify UPnP ContentDirectory of the change - if let Some(ref notifier) = container_notifier { - // Notify the station's stream item container - let container_id = format!("radiofrance:{}", slug); - - #[cfg(feature = "logging")] - tracing::debug!("Notifying UPnP container update: {}", container_id); - - notifier(&[container_id]); - } - - tokio::time::sleep(delay).await; - } - Err(e) => { - #[cfg(feature = "logging")] - tracing::warn!("Failed to refresh metadata for {}: {}", slug, e); - - tokio::time::sleep(std::time::Duration::from_secs(60)).await; - } - } + // Attendre 1 seconde avant le prochain check + tokio::time::sleep(std::time::Duration::from_secs(1)).await; } }); handles.insert(station_slug.to_string(), handle); #[cfg(feature = "logging")] - tracing::debug!("Started metadata refresh for station: {}", station_slug); + tracing::debug!( + "Started metadata refresh polling for station: {}", + station_slug + ); Ok(()) } @@ -254,17 +267,16 @@ impl RadioFranceSource { ); let mut containers = Vec::new(); - let mut items = Vec::new(); - // 1. Standalone stations → direct items (avec appels API) + // 1. Standalone stations → playlist containers (plus des items directs) #[cfg(feature = "logging")] tracing::debug!( - "Building {} standalone station items", + "Building {} standalone station playlist containers", groups.standalone.len() ); for station in &groups.standalone { - items.push(self.build_station_item(station).await?); + containers.push(self.build_station_playlist(station).await?); } // 2. Stations with webradios → containers @@ -290,23 +302,22 @@ impl RadioFranceSource { #[cfg(feature = "logging")] tracing::debug!( - "Container tree built: {} containers, {} items", - containers.len(), - items.len() + "Container tree built: {} containers (all playlists)", + containers.len() ); Ok(Container { id: "radiofrance".to_string(), parent_id: "0".to_string(), restricted: Some("1".to_string()), - child_count: Some((containers.len() + items.len()).to_string()), + child_count: Some(containers.len().to_string()), searchable: Some("0".to_string()), title: "Radio France".to_string(), class: "object.container".to_string(), artist: None, album_art: None, containers, - items, + items: vec![], // Plus d'items directs - tout est dans des playlists }) } @@ -364,23 +375,24 @@ impl RadioFranceSource { }) } - /// Build a UPnP item for a station + /// Construit le container de playlist avec son unique item (métadonnées cohérentes) /// - /// Fetches live metadata to create a complete item with stream URL. - async fn build_station_item(&self, station: &Station) -> Result { + /// Cette méthode crée un container de type `playlistContainer` contenant un seul item. + /// Un seul appel au cache garantit la cohérence des métadonnées entre le container et l'item. + async fn build_station_playlist(&self, station: &Station) -> Result { #[cfg(feature = "logging")] tracing::debug!( - "Building station item for: {} ({})", + "Building station playlist for: {} ({})", station.name, station.slug ); - // Fetch metadata from API (cached by RadioFranceStatefulClient) + // UN SEUL appel au cache - garantit cohérence container/item let metadata = self.client.get_live_metadata(&station.slug).await?; - // Build item from live metadata (no caching here, rely on client cache) + // Build l'item de stream avec pmoDidl #[cfg(feature = "cache")] - let item = StationPlaylist::build_item_from_metadata( + let mut item = StationPlaylist::build_item_from_metadata( station, &metadata, self.cover_cache.as_ref(), @@ -389,24 +401,51 @@ impl RadioFranceSource { .await?; #[cfg(not(feature = "cache"))] - let item = StationPlaylist::build_item_from_metadata_sync( + let mut item = StationPlaylist::build_item_from_metadata_sync( station, &metadata, self.server_base_url.as_deref(), )?; - // Note: We don't start metadata refresh here to avoid blocking during browse. - // Refresh will be started in resolve_uri() when the stream is actually played. + // Parent_id de l'item = le container de playlist + let playlist_id = format!("radiofrance:{}", station.slug); + item.parent_id = playlist_id.clone(); + + // Construire le container avec les MÊMES métadonnées que l'item + let container = Container { + id: playlist_id, + parent_id: self.get_parent_id_for_station(station), + restricted: Some("1".to_string()), + child_count: Some("1".to_string()), // Toujours 1 item + searchable: Some("0".to_string()), + // Métadonnées identiques à l'item + title: item.title.clone(), + artist: item.artist.clone(), + album_art: item.album_art.clone(), + class: "object.container.playlistContainer".to_string(), + containers: vec![], + items: vec![item], // L'item est inclus dans le container + }; #[cfg(feature = "logging")] tracing::debug!( - "Built item for {}: {} resources, album_art: {:?}", + "Built playlist container for {}: {} items", station.slug, - item.resources.len(), - item.album_art.is_some() + container.items.len() ); - Ok(item) + Ok(container) + } + + /// Détermine le parent_id selon le type de station + fn get_parent_id_for_station(&self, station: &Station) -> String { + match &station.station_type { + StationType::Webradio { parent_station } => { + format!("radiofrance:group:{}", parent_station) + } + StationType::LocalRadio { .. } => "radiofrance:ici".to_string(), + StationType::Main => "radiofrance".to_string(), + } } } @@ -414,7 +453,6 @@ impl std::fmt::Debug for RadioFranceSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("RadioFranceSource") .field("client", &self.client) - .field("playlists_count", &"") .field("refresh_handles_count", &"") .finish() } @@ -473,10 +511,8 @@ impl MusicSource for RadioFranceSource { .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - Ok(BrowseResult::Mixed { - containers: container.containers, - items: container.items, - }) + // Retourne uniquement des containers (playlists + groupes) + Ok(BrowseResult::Containers(container.containers)) } id if id.starts_with("radiofrance:group:") => { let slug = id @@ -496,26 +532,23 @@ impl MusicSource for RadioFranceSource { .find(|g| g.main.slug == slug) .ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?; - // Build items for this group only (main + webradios) - let group_id = format!("radiofrance:group:{}", slug); - + // Build playlist containers for this group (main + webradios) // Paralléliser les fetches pour éviter les timeouts - let mut futures = vec![self.build_station_item(&group.main)]; + let mut futures = vec![self.build_station_playlist(&group.main)]; for webradio in &group.webradios { - futures.push(self.build_station_item(webradio)); + futures.push(self.build_station_playlist(webradio)); } let results = futures::future::join_all(futures).await; - let mut items = Vec::new(); + let mut containers = Vec::new(); for result in results { - let mut item = + let container = result.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - item.parent_id = group_id.clone(); - items.push(item); + containers.push(container); } - Ok(BrowseResult::Items(items)) + Ok(BrowseResult::Containers(containers)) } "radiofrance:ici" => { let stations = self @@ -525,20 +558,45 @@ impl MusicSource for RadioFranceSource { .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; let groups = StationGroups::from_stations(stations); - // Build items for local radios only - let mut items = Vec::new(); + // Build playlist containers for local radios only + let mut containers = Vec::new(); for station in &groups.local_radios { - let mut item = self - .build_station_item(station) + let container = self + .build_station_playlist(station) .await .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - // Fix parent_id to point to the ICI container - item.parent_id = "radiofrance:ici".to_string(); - items.push(item); + containers.push(container); } - Ok(BrowseResult::Items(items)) + Ok(BrowseResult::Containers(containers)) + } + id if id.starts_with("radiofrance:") && !id.contains(":stream") => { + // Browse d'un container de playlist (ex: radiofrance:fip) + // Le container contient déjà son item, on retourne juste le container + let slug = id + .strip_prefix("radiofrance:") + .ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?; + + // Trouver la station correspondante + let stations = self + .client + .get_stations() + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let station = stations + .iter() + .find(|s| s.slug == slug) + .ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?; + + let container = self + .build_station_playlist(station) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + // Retourner le container lui-même (qui contient l'item) + Ok(BrowseResult::Containers(vec![container])) } _ => Err(MusicSourceError::ObjectNotFound(object_id.to_string())), } @@ -551,10 +609,29 @@ impl MusicSource for RadioFranceSource { .and_then(|s| s.strip_suffix(":stream")) .ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?; - let playlists = self.playlists.read().await; - playlists - .get(slug) - .map(|p| p.stream_item.clone()) + // Trouver la station correspondante + let stations = self + .client + .get_stations() + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let station = stations + .iter() + .find(|s| s.slug == slug) + .ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?; + + // Construire le container de playlist et extraire l'item + let container = self + .build_station_playlist(station) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + // Extraire l'unique item du container + container + .items + .into_iter() + .next() .ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string())) } @@ -565,56 +642,10 @@ impl MusicSource for RadioFranceSource { .and_then(|s| s.strip_suffix(":stream")) .ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?; - // Ensure we have metadata for this station - let playlists = self.playlists.read().await; - let needs_metadata = !playlists.contains_key(slug); - drop(playlists); - - if needs_metadata { - // Fetch metadata and create playlist - let stations = self - .client - .get_stations() - .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - - let station = stations - .iter() - .find(|s| s.slug == slug) - .ok_or_else(|| MusicSourceError::ObjectNotFound(slug.to_string()))?; - - let metadata = self - .client - .get_live_metadata(slug) - .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - - #[cfg(feature = "cache")] - let playlist = StationPlaylist::from_live_metadata( - station.clone(), - &metadata, - self.cover_cache.as_ref(), - self.server_base_url.as_deref(), - ) - .await - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - - #[cfg(not(feature = "cache"))] - let playlist = StationPlaylist::from_live_metadata_no_cache( - station.clone(), - &metadata, - self.server_base_url.as_deref(), - ) - .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; - - let mut playlists_write = self.playlists.write().await; - playlists_write.insert(slug.to_string(), playlist); - - // Start metadata refresh - drop(playlists_write); - let _ = self.start_metadata_refresh(slug).await; - } + // Start metadata refresh for this station (if not already running) + let _ = self.start_metadata_refresh(slug).await; + // Get the item to extract the stream URL let item = self.get_item(object_id).await?; item.resources .first() diff --git a/pmoradiofrance/src/stateful_client.rs b/pmoradiofrance/src/stateful_client.rs index 1f121176..b3dba1eb 100644 --- a/pmoradiofrance/src/stateful_client.rs +++ b/pmoradiofrance/src/stateful_client.rs @@ -33,6 +33,9 @@ use pmoconfig::Config; use std::sync::Arc; use std::time::{Duration, SystemTime}; +/// Type de callback pour les notifications de mise à jour de métadonnées +pub type MetadataUpdateCallback = Arc; + /// Cache entry for live metadata #[derive(Debug, Clone)] struct LiveMetadataCache { @@ -92,6 +95,8 @@ pub struct RadioFranceStatefulClient { config: Arc, /// In-memory cache for live metadata (thread-safe) metadata_cache: Arc>>, + /// Liste des callbacks abonnés aux mises à jour de métadonnées + update_callbacks: Arc>>, } impl RadioFranceStatefulClient { @@ -120,6 +125,7 @@ impl RadioFranceStatefulClient { client, config, metadata_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())), + update_callbacks: Arc::new(std::sync::RwLock::new(Vec::new())), }) } @@ -150,6 +156,7 @@ impl RadioFranceStatefulClient { client, config, metadata_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())), + update_callbacks: Arc::new(std::sync::RwLock::new(Vec::new())), } } @@ -163,6 +170,34 @@ impl RadioFranceStatefulClient { &self.config } + // ======================================================================== + // Event System (metadata update notifications) + // ======================================================================== + + /// S'abonner aux mises à jour de métadonnées + /// + /// Le callback sera appelé avec le slug de la station chaque fois que + /// ses métadonnées sont rafraîchies depuis l'API. + /// + /// # Arguments + /// + /// * `callback` - Fonction appelée avec le slug de la station mise à jour + pub fn subscribe_to_updates(&self, callback: MetadataUpdateCallback) { + let mut callbacks = self.update_callbacks.write().unwrap(); + callbacks.push(callback); + } + + /// Notifier tous les abonnés d'une mise à jour de métadonnées + /// + /// Cette méthode est appelée en interne lorsque les métadonnées + /// d'une station sont rafraîchies depuis l'API. + fn notify_update(&self, slug: &str) { + let callbacks = self.update_callbacks.read().unwrap(); + for callback in callbacks.iter() { + callback(slug); + } + } + // ======================================================================== // Station Discovery (with automatic caching) // ======================================================================== @@ -332,6 +367,9 @@ impl RadioFranceStatefulClient { ); } + // Notify subscribers + self.notify_update(station); + #[cfg(feature = "logging")] tracing::debug!( "Cached metadata for {} (TTL: {} ms)", @@ -342,28 +380,6 @@ impl RadioFranceStatefulClient { 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();