Refonte du cache Radio France avec système d'événements

Cette mise à jour implémente une refonte complète du système de cache de métadonnées Radio France avec un système d'événements événementiel. 

Principales modifications :
- Ajout d'un système de callback pour les mises à jour de métadonnées
- Suppression de la méthode refresh_live_metadata redondante
- Refactorisation du browse pour utiliser des playlists à 1 item au lieu d'items directs
- Simplification du refresh thread avec appel unique à get_live_metadata
- Mise à jour des notifications GENA pour être cohérentes avec les changements de métadonnées
- Nettoyage du code obsolète dans playlist.rs

L'architecture maintenant utilise une seule source de vérité avec TTL automatique et notifications événementielles pour des notifications GENA cohérentes.
This commit is contained in:
2026-01-24 12:44:41 +01:00
parent b5e9bb116f
commit 9bb4f00650
6 changed files with 673 additions and 286 deletions

View File

@@ -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<br/>TTL automatique<br/>Événements]
Source[RadioFranceSource<br/>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<dyn Fn(&str) + Send + Sync>;
pub struct RadioFranceStatefulClient {
client: RadioFranceClient,
config: Arc<Config>,
metadata_cache: Arc<RwLock<HashMap<String, LiveMetadataCache>>>,
// NOUVEAU : Liste des callbacks abonnés
update_callbacks: Arc<RwLock<Vec<MetadataUpdateCallback>>>,
}
```
#### 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<LiveResponse> {
// 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<RwLock<HashMap<String, StationPlaylist>>>,
refresh_handles: Arc<RwLock<HashMap<String, JoinHandle<()>>>>,
// ... 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<Container> {
// 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<BrowseResult> {
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<Config>) -> Result<Self> {
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<tokio::sync::RwLock<u32>>,
last_change: Arc<tokio::sync::RwLock<Option<SystemTime>>>,
// ...
}
```
### 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<RwLock<Vec<MetadataUpdateCallback>>>`
- 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