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:
6
.claude/hooks/preToolUse.sh
Normal file
6
.claude/hooks/preToolUse.sh
Normal file
@@ -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
|
||||||
436
Blackboard/Report/metadata_RF_cache.md
Normal file
436
Blackboard/Report/metadata_RF_cache.md
Normal 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
|
||||||
@@ -218,111 +218,9 @@ impl StationPlaylist {
|
|||||||
/// - `artist` = producteur
|
/// - `artist` = producteur
|
||||||
/// - `album` = nom de l'émission
|
/// - `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<CoverCache>>,
|
|
||||||
server_base_url: Option<&str>,
|
|
||||||
) -> Result<Self> {
|
|
||||||
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<Self> {
|
|
||||||
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<CoverCache>>,
|
|
||||||
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)
|
/// Construit un Item UPnP depuis les métadonnées live (avec cache)
|
||||||
#[cfg(feature = "cache")]
|
#[cfg(feature = "cache")]
|
||||||
async fn build_item_from_metadata(
|
pub async fn build_item_from_metadata(
|
||||||
station: &Station,
|
station: &Station,
|
||||||
metadata: &LiveResponse,
|
metadata: &LiveResponse,
|
||||||
cover_cache: Option<&Arc<CoverCache>>,
|
cover_cache: Option<&Arc<CoverCache>>,
|
||||||
@@ -381,7 +279,7 @@ impl StationPlaylist {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Construit un Item UPnP depuis les métadonnées live (sans cache async)
|
/// 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,
|
station: &Station,
|
||||||
metadata: &LiveResponse,
|
metadata: &LiveResponse,
|
||||||
server_base_url: Option<&str>,
|
server_base_url: Option<&str>,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! providing UPnP/DLNA integration with dynamic container generation.
|
//! providing UPnP/DLNA integration with dynamic container generation.
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::Station;
|
use crate::models::{Station, StationType};
|
||||||
use crate::playlist::{StationGroup, StationGroups, StationPlaylist};
|
use crate::playlist::{StationGroup, StationGroups, StationPlaylist};
|
||||||
use crate::stateful_client::RadioFranceStatefulClient;
|
use crate::stateful_client::RadioFranceStatefulClient;
|
||||||
use pmoconfig::Config;
|
use pmoconfig::Config;
|
||||||
@@ -78,7 +78,7 @@ impl RadioFranceSource {
|
|||||||
pub async fn new(config: Arc<Config>) -> Result<Self> {
|
pub async fn new(config: Arc<Config>) -> Result<Self> {
|
||||||
let client = RadioFranceStatefulClient::new(config).await?;
|
let client = RadioFranceStatefulClient::new(config).await?;
|
||||||
|
|
||||||
Ok(Self {
|
let source = Self {
|
||||||
client,
|
client,
|
||||||
refresh_handles: Arc::new(RwLock::new(HashMap::new())),
|
refresh_handles: Arc::new(RwLock::new(HashMap::new())),
|
||||||
#[cfg(feature = "cache")]
|
#[cfg(feature = "cache")]
|
||||||
@@ -87,7 +87,36 @@ impl RadioFranceSource {
|
|||||||
update_id: Arc::new(RwLock::new(0)),
|
update_id: Arc::new(RwLock::new(0)),
|
||||||
last_change: Arc::new(RwLock::new(None)),
|
last_change: Arc::new(RwLock::new(None)),
|
||||||
container_notifier: 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
|
/// Set the container notifier for UPnP GENA events
|
||||||
@@ -133,9 +162,8 @@ impl RadioFranceSource {
|
|||||||
#[cfg(feature = "cache")]
|
#[cfg(feature = "cache")]
|
||||||
let cover_cache = pmoupnp::cache_registry::get_cover_cache();
|
let cover_cache = pmoupnp::cache_registry::get_cover_cache();
|
||||||
|
|
||||||
Ok(Self {
|
let source = Self {
|
||||||
client,
|
client,
|
||||||
playlists: Arc::new(RwLock::new(HashMap::new())),
|
|
||||||
refresh_handles: Arc::new(RwLock::new(HashMap::new())),
|
refresh_handles: Arc::new(RwLock::new(HashMap::new())),
|
||||||
#[cfg(feature = "cache")]
|
#[cfg(feature = "cache")]
|
||||||
cover_cache,
|
cover_cache,
|
||||||
@@ -143,10 +171,40 @@ impl RadioFranceSource {
|
|||||||
update_id: Arc::new(RwLock::new(0)),
|
update_id: Arc::new(RwLock::new(0)),
|
||||||
last_change: Arc::new(RwLock::new(None)),
|
last_change: Arc::new(RwLock::new(None)),
|
||||||
container_notifier: 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
|
/// 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<()> {
|
pub async fn start_metadata_refresh(&self, station_slug: &str) -> Result<()> {
|
||||||
let mut handles = self.refresh_handles.write().await;
|
let mut handles = self.refresh_handles.write().await;
|
||||||
|
|
||||||
@@ -157,71 +215,26 @@ impl RadioFranceSource {
|
|||||||
|
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
let slug = station_slug.to_string();
|
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 {
|
let handle = tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
// Force refresh metadata (bypass cache to get fresh data)
|
// Appeler simplement get_live_metadata
|
||||||
match client.refresh_live_metadata(&slug).await {
|
// Si le cache est valide, retour immédiat
|
||||||
Ok(metadata) => {
|
// Si expiré, fetch API + mise à jour cache + notification GENA
|
||||||
let delay = std::time::Duration::from_millis(metadata.delay_to_refresh);
|
let _ = client.get_live_metadata(&slug).await;
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
// Attendre 1 seconde avant le prochain check
|
||||||
{
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
handles.insert(station_slug.to_string(), handle);
|
handles.insert(station_slug.to_string(), handle);
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
#[cfg(feature = "logging")]
|
||||||
tracing::debug!("Started metadata refresh for station: {}", station_slug);
|
tracing::debug!(
|
||||||
|
"Started metadata refresh polling for station: {}",
|
||||||
|
station_slug
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -254,17 +267,16 @@ impl RadioFranceSource {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut containers = Vec::new();
|
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")]
|
#[cfg(feature = "logging")]
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"Building {} standalone station items",
|
"Building {} standalone station playlist containers",
|
||||||
groups.standalone.len()
|
groups.standalone.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
for station in &groups.standalone {
|
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
|
// 2. Stations with webradios → containers
|
||||||
@@ -290,23 +302,22 @@ impl RadioFranceSource {
|
|||||||
|
|
||||||
#[cfg(feature = "logging")]
|
#[cfg(feature = "logging")]
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"Container tree built: {} containers, {} items",
|
"Container tree built: {} containers (all playlists)",
|
||||||
containers.len(),
|
containers.len()
|
||||||
items.len()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(Container {
|
Ok(Container {
|
||||||
id: "radiofrance".to_string(),
|
id: "radiofrance".to_string(),
|
||||||
parent_id: "0".to_string(),
|
parent_id: "0".to_string(),
|
||||||
restricted: Some("1".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()),
|
searchable: Some("0".to_string()),
|
||||||
title: "Radio France".to_string(),
|
title: "Radio France".to_string(),
|
||||||
class: "object.container".to_string(),
|
class: "object.container".to_string(),
|
||||||
artist: None,
|
artist: None,
|
||||||
album_art: None,
|
album_art: None,
|
||||||
containers,
|
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.
|
/// Cette méthode crée un container de type `playlistContainer` contenant un seul item.
|
||||||
async fn build_station_item(&self, station: &Station) -> Result<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<Container> {
|
||||||
#[cfg(feature = "logging")]
|
#[cfg(feature = "logging")]
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"Building station item for: {} ({})",
|
"Building station playlist for: {} ({})",
|
||||||
station.name,
|
station.name,
|
||||||
station.slug
|
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?;
|
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")]
|
#[cfg(feature = "cache")]
|
||||||
let item = StationPlaylist::build_item_from_metadata(
|
let mut item = StationPlaylist::build_item_from_metadata(
|
||||||
station,
|
station,
|
||||||
&metadata,
|
&metadata,
|
||||||
self.cover_cache.as_ref(),
|
self.cover_cache.as_ref(),
|
||||||
@@ -389,24 +401,51 @@ impl RadioFranceSource {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
#[cfg(not(feature = "cache"))]
|
#[cfg(not(feature = "cache"))]
|
||||||
let item = StationPlaylist::build_item_from_metadata_sync(
|
let mut item = StationPlaylist::build_item_from_metadata_sync(
|
||||||
station,
|
station,
|
||||||
&metadata,
|
&metadata,
|
||||||
self.server_base_url.as_deref(),
|
self.server_base_url.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Note: We don't start metadata refresh here to avoid blocking during browse.
|
// Parent_id de l'item = le container de playlist
|
||||||
// Refresh will be started in resolve_uri() when the stream is actually played.
|
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")]
|
#[cfg(feature = "logging")]
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"Built item for {}: {} resources, album_art: {:?}",
|
"Built playlist container for {}: {} items",
|
||||||
station.slug,
|
station.slug,
|
||||||
item.resources.len(),
|
container.items.len()
|
||||||
item.album_art.is_some()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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 {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.debug_struct("RadioFranceSource")
|
f.debug_struct("RadioFranceSource")
|
||||||
.field("client", &self.client)
|
.field("client", &self.client)
|
||||||
.field("playlists_count", &"<locked>")
|
|
||||||
.field("refresh_handles_count", &"<locked>")
|
.field("refresh_handles_count", &"<locked>")
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
@@ -473,10 +511,8 @@ impl MusicSource for RadioFranceSource {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
|
|
||||||
Ok(BrowseResult::Mixed {
|
// Retourne uniquement des containers (playlists + groupes)
|
||||||
containers: container.containers,
|
Ok(BrowseResult::Containers(container.containers))
|
||||||
items: container.items,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
id if id.starts_with("radiofrance:group:") => {
|
id if id.starts_with("radiofrance:group:") => {
|
||||||
let slug = id
|
let slug = id
|
||||||
@@ -496,26 +532,23 @@ impl MusicSource for RadioFranceSource {
|
|||||||
.find(|g| g.main.slug == slug)
|
.find(|g| g.main.slug == slug)
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?;
|
.ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?;
|
||||||
|
|
||||||
// Build items for this group only (main + webradios)
|
// Build playlist containers for this group (main + webradios)
|
||||||
let group_id = format!("radiofrance:group:{}", slug);
|
|
||||||
|
|
||||||
// Paralléliser les fetches pour éviter les timeouts
|
// 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 {
|
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 results = futures::future::join_all(futures).await;
|
||||||
|
|
||||||
let mut items = Vec::new();
|
let mut containers = Vec::new();
|
||||||
for result in results {
|
for result in results {
|
||||||
let mut item =
|
let container =
|
||||||
result.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
result.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
item.parent_id = group_id.clone();
|
containers.push(container);
|
||||||
items.push(item);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(BrowseResult::Items(items))
|
Ok(BrowseResult::Containers(containers))
|
||||||
}
|
}
|
||||||
"radiofrance:ici" => {
|
"radiofrance:ici" => {
|
||||||
let stations = self
|
let stations = self
|
||||||
@@ -525,20 +558,45 @@ impl MusicSource for RadioFranceSource {
|
|||||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
let groups = StationGroups::from_stations(stations);
|
let groups = StationGroups::from_stations(stations);
|
||||||
|
|
||||||
// Build items for local radios only
|
// Build playlist containers for local radios only
|
||||||
let mut items = Vec::new();
|
let mut containers = Vec::new();
|
||||||
for station in &groups.local_radios {
|
for station in &groups.local_radios {
|
||||||
let mut item = self
|
let container = self
|
||||||
.build_station_item(station)
|
.build_station_playlist(station)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
|
|
||||||
// Fix parent_id to point to the ICI container
|
containers.push(container);
|
||||||
item.parent_id = "radiofrance:ici".to_string();
|
|
||||||
items.push(item);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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())),
|
_ => Err(MusicSourceError::ObjectNotFound(object_id.to_string())),
|
||||||
}
|
}
|
||||||
@@ -551,10 +609,29 @@ impl MusicSource for RadioFranceSource {
|
|||||||
.and_then(|s| s.strip_suffix(":stream"))
|
.and_then(|s| s.strip_suffix(":stream"))
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
||||||
|
|
||||||
let playlists = self.playlists.read().await;
|
// Trouver la station correspondante
|
||||||
playlists
|
let stations = self
|
||||||
.get(slug)
|
.client
|
||||||
.map(|p| p.stream_item.clone())
|
.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()))
|
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,56 +642,10 @@ impl MusicSource for RadioFranceSource {
|
|||||||
.and_then(|s| s.strip_suffix(":stream"))
|
.and_then(|s| s.strip_suffix(":stream"))
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
||||||
|
|
||||||
// Ensure we have metadata for this station
|
// Start metadata refresh for this station (if not already running)
|
||||||
let playlists = self.playlists.read().await;
|
let _ = self.start_metadata_refresh(slug).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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Get the item to extract the stream URL
|
||||||
let item = self.get_item(object_id).await?;
|
let item = self.get_item(object_id).await?;
|
||||||
item.resources
|
item.resources
|
||||||
.first()
|
.first()
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ use pmoconfig::Config;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
/// Type de callback pour les notifications de mise à jour de métadonnées
|
||||||
|
pub type MetadataUpdateCallback = Arc<dyn Fn(&str) + Send + Sync>;
|
||||||
|
|
||||||
/// Cache entry for live metadata
|
/// Cache entry for live metadata
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct LiveMetadataCache {
|
struct LiveMetadataCache {
|
||||||
@@ -92,6 +95,8 @@ pub struct RadioFranceStatefulClient {
|
|||||||
config: Arc<Config>,
|
config: Arc<Config>,
|
||||||
/// In-memory cache for live metadata (thread-safe)
|
/// In-memory cache for live metadata (thread-safe)
|
||||||
metadata_cache: Arc<std::sync::RwLock<std::collections::HashMap<String, LiveMetadataCache>>>,
|
metadata_cache: Arc<std::sync::RwLock<std::collections::HashMap<String, LiveMetadataCache>>>,
|
||||||
|
/// Liste des callbacks abonnés aux mises à jour de métadonnées
|
||||||
|
update_callbacks: Arc<std::sync::RwLock<Vec<MetadataUpdateCallback>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RadioFranceStatefulClient {
|
impl RadioFranceStatefulClient {
|
||||||
@@ -120,6 +125,7 @@ impl RadioFranceStatefulClient {
|
|||||||
client,
|
client,
|
||||||
config,
|
config,
|
||||||
metadata_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
|
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,
|
client,
|
||||||
config,
|
config,
|
||||||
metadata_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
|
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
|
&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)
|
// Station Discovery (with automatic caching)
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -332,6 +367,9 @@ impl RadioFranceStatefulClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify subscribers
|
||||||
|
self.notify_update(station);
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
#[cfg(feature = "logging")]
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"Cached metadata for {} (TTL: {} ms)",
|
"Cached metadata for {} (TTL: {} ms)",
|
||||||
@@ -342,28 +380,6 @@ impl RadioFranceStatefulClient {
|
|||||||
Ok(metadata)
|
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<LiveResponse> {
|
|
||||||
#[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
|
/// Clear the metadata cache for a specific station
|
||||||
pub fn clear_metadata_cache(&self, station: &str) {
|
pub fn clear_metadata_cache(&self, station: &str) {
|
||||||
let mut cache = self.metadata_cache.write().unwrap();
|
let mut cache = self.metadata_cache.write().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user