Refactorisation complète de pmoradiofrance : cache centralisé avec système d'événements
Refactorisation complète de la crate `pmoradiofrance` pour simplifier l'architecture autour d'un cache de métadonnées centralisé avec système d'événements.
## Objectifs
1. Simplifier les structures de stations (supprimer StationType)
2. Créer un cache de métadonnées in-memory avec TTL basé sur `end_time`
3. Maintenir le cache de stations persistant (pmoconfig, TTL 1 semaine)
4. Implémenter un système d'événements pour la synchronisation GENA
5. Unifier les méthodes `to_didl()` pour retourner des Containers DIDL
6. Gérer automatiquement le cache des covers via pmocovers
## Changements architecturaux majeurs
### 1. Nouveau fichier: metadata_cache.rs
**Créé**: `pmoradiofrance/src/metadata_cache.rs`
Contient deux structures principales:
- **CachedMetadata**: Stocke uniquement les données nécessaires au DIDL (titre, artiste, album, cover, stream URL, etc.)
- **MetadataCache**: Gère le cache in-memory avec TTL + cache persistant des stations + système d'événements
**Fonctionnalités**:
- TTL basé sur `end_time` de l'API Radio France
- Gestion automatique du cache de covers via pmocovers
- Système subscribe/notify pour les événements
- Graceful degradation si API Radio France down
- Méthode `to_didl()` retournant une playlist à un item avec métadonnées identiques
### 2. Suppression: stateful_client.rs
**Supprimé**: `pmoradiofrance/src/stateful_client.rs`
Raison: Complètement redondant avec `MetadataCache`. Toute la logique a été déplacée dans le nouveau module.
### 3. Simplification: models.rs
**Modifications**:
- Supprimé `StationType` enum
- Simplifié `Station` struct (juste `slug` + `name`)
- Supprimé méthodes `is_main()`, `is_webradio()`, `is_local_radio()`, `base_station()`
- Conservé structures d'API (`LiveResponse`, `ShowMetadata`, etc.)
### 4. Simplification: playlist.rs
**Modifications**:
- Supprimé `StationPlaylist` complètement
- Simplifié `StationGroup` et `StationGroups`
- **Important**: `to_didl()` retourne `Container` (pas `Vec<Container>`)
- Logique unifiée: ICI fonctionne comme FIP (plus de traitement spécial)
- Préservé les règles de mapping RF → UPnP existantes
### 5. Refactoring: source.rs
**Modifications**:
- Utilise uniquement `MetadataCache` (plus de `stateful_client`)
- Simplifié `browse()` en 3 cas simples
- Abonnement aux événements du cache pour GENA
- Retourne des `Container` (cohérence avec to_didl)
### 6. Adaptation: config_ext.rs
**Modifications**:
- Format simplifié: `Vec<Station>` au lieu de `CachedStationList`
- TTL reste à 7 jours (1 semaine)
### 7. Mise à jour: lib.rs
**Modifications**:
- Ajouté `pub mod metadata_cache;`
- Supprimé export de `stateful_client`
- Ajouté exports: `MetadataCache`, `CachedMetadata`
## Hiérarchie de browse
**Niveau 0**: `radiofrance`
- Retourne UN Container contenant les containers de groupes
- Exemple: Container "FIP", Container "France Culture", Container "ICI"
**Niveau 1**: `radiofrance:group:fip` ou `radiofrance:ici`
- Si 1 station: retourne directement la playlist (Container playlistContainer)
- Si plusieurs stations: retourne un container contenant les playlists
**Niveau 2**: `radiofrance:fip`
- Retourne Container playlistContainer avec 1 item
- Métadonnées identiques entre playlist et item
## Règles de mapping préservées
Les règles existantes de transformation RF → UPnP ont été préservées:
- Radio musicale avec song → métadonnées du morceau
- Radio parlée → agrégation émission/producteur
- Éviter duplications du nom de station
- Calcul de duration depuis end_time
## Système d'événements
**Flux**:
1. `MetadataCache` rafraîchit les métadonnées d'un slug
2. Notifie tous les abonnés via `notify(slug)`
3. `RadioFranceSource` reçoit l'événement
4. Émet un événement GENA UPnP pour la playlist `radiofrance:{slug}`
5. Le Control Point reçoit la notification et peut se mettre à jour
## Fichiers modifiés
### Créés
- `pmoradiofrance/src/metadata_cache.rs`
### Supprimés
- `pmoradiofrance/src/stateful_client.rs`
### Modifiés
- `pmoradiofrance/src/models.rs`
- `pmoradiofrance/src/playlist.rs`
- `pmoradiofrance/src/source.rs`
- `pmoradiofrance/src/config_ext.rs`
- `pmoradiofrance/src/lib.rs`
### Inchangés
- `pmoradiofrance/src/client.rs`
- `pmoradiofrance/src/error.rs`
## Points de vigilance
1. **Migration**: Le cache pmoconfig existant sera invalidé (nouveau format)
2. **Covers**: Nécessite que pmocovers soit initialisé via cache_registry
3. **Thread safety**: Utilisation d'Arc<RwLock> pour la sécurité thread
4. **Graceful degradation**: Retourne cache expiré si API Radio France down
## Prochaines étapes
1. Tester le cache de métadonnées (TTL, refresh, graceful degradation)
2. Tester le système d'événements
3. Tester le browse sur les 3 niveaux
4. Vérifier les événements GENA
5. Vérifier que les covers sont correctement cachées
This commit is contained in:
27
.claude/hooks/preToolUse.sh
Normal file → Executable file
27
.claude/hooks/preToolUse.sh
Normal file → Executable file
@@ -1,6 +1,25 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Bloquer toutes les éditions sans confirmation explicite
|
|
||||||
if [[ "$TOOL_NAME" == "Edit" ]] || [[ "$TOOL_NAME" == "MultiEdit" ]]; then
|
# Lire les données JSON envoyées par Claude Code
|
||||||
echo "Édition bloquée - confirmation requise"
|
INPUT=$(cat)
|
||||||
exit 1
|
|
||||||
|
# Extraire le nom de l'outil
|
||||||
|
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
|
||||||
|
|
||||||
|
# Pour les éditions de fichiers, forcer la demande de confirmation
|
||||||
|
if [[ "$TOOL_NAME" == "Edit" ]] || [[ "$TOOL_NAME" == "MultiEdit" ]] || [[ "$TOOL_NAME" == "Write" ]]; then
|
||||||
|
# Retourner une décision "ask" qui force la confirmation
|
||||||
|
cat << EOF
|
||||||
|
{
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"permissionDecision": "ask",
|
||||||
|
"permissionDecisionReason": "Validation requise pour toute édition de fichier"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Pour les autres outils, laisser passer normalement
|
||||||
|
exit 0
|
||||||
|
|||||||
@@ -1,436 +1,146 @@
|
|||||||
# Rapport : Refonte du cache Radio France avec système d'événements
|
# Rapport: Simplification de pmoradiofrance
|
||||||
|
|
||||||
## Résumé
|
## 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**.
|
Refactoring complet de la crate `pmoradiofrance` pour simplifier l'architecture autour d'un cache de métadonnées centralisé avec système d'événements.
|
||||||
|
|
||||||
**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.
|
## Objectifs
|
||||||
|
|
||||||
**Crate concernée** : `pmoradiofrance`
|
1. Simplifier les structures de stations (supprimer StationType)
|
||||||
|
2. Créer un cache de métadonnées in-memory avec TTL basé sur `end_time`
|
||||||
## Architecture cible
|
3. Maintenir le cache de stations persistant (pmoconfig, TTL 1 semaine)
|
||||||
|
4. Implémenter un système d'événements pour la synchronisation GENA
|
||||||
### Principe fondamental
|
5. Unifier les méthodes `to_didl()` pour retourner des Containers DIDL
|
||||||
|
6. Gérer automatiquement le cache des covers via pmocovers
|
||||||
**Une seule source de vérité** : Le cache HTTP avec TTL dans `RadioFranceStatefulClient`.
|
|
||||||
|
## Changements architecturaux majeurs
|
||||||
```mermaid
|
|
||||||
flowchart TB
|
### 1. Nouveau fichier: metadata_cache.rs
|
||||||
API[Radio France API]
|
|
||||||
Cache[MetadataCache<br/>TTL automatique<br/>Événements]
|
**Créé**: `pmoradiofrance/src/metadata_cache.rs`
|
||||||
Source[RadioFranceSource<br/>MusicSource trait]
|
|
||||||
Browse[Browse/DIDL]
|
Contient deux structures principales:
|
||||||
Refresh[Refresh thread]
|
|
||||||
GENA[Notifications GENA UPnP]
|
- **CachedMetadata**: Stocke uniquement les données nécessaires au DIDL (titre, artiste, album, cover, stream URL, etc.)
|
||||||
CP[Control Point]
|
- **MetadataCache**: Gère le cache in-memory avec TTL + cache persistant des stations + système d'événements
|
||||||
|
|
||||||
API -->|Fetch quand TTL expiré| Cache
|
**Fonctionnalités**:
|
||||||
Cache -->|Événement: slug modifié| Source
|
- TTL basé sur `end_time` de l'API Radio France
|
||||||
Cache -->|get_metadata| Browse
|
- Gestion automatique du cache de covers via pmocovers
|
||||||
Browse -->|Reconstruit containers + playlists| DIDL[DIDL]
|
- Système subscribe/notify pour les événements
|
||||||
|
- Graceful degradation si API Radio France down
|
||||||
Refresh -->|get_metadata chaque seconde| Cache
|
- Méthode `to_didl()` retournant une playlist à un item avec métadonnées identiques
|
||||||
Source -->|S'abonne aux événements| Cache
|
|
||||||
Source -->|Notifie changements| GENA
|
### 2. Suppression: stateful_client.rs
|
||||||
CP -->|Subscribe à playlist| GENA
|
|
||||||
|
**Supprimé**: `pmoradiofrance/src/stateful_client.rs`
|
||||||
style Cache fill:#90EE90
|
|
||||||
style Source fill:#FFB6C1
|
Raison: Complètement redondant avec `MetadataCache`. Toute la logique a été déplacée dans le nouveau module.
|
||||||
style Refresh fill:#87CEEB
|
|
||||||
```
|
### 3. Simplification: models.rs
|
||||||
|
|
||||||
### Hiérarchie UPnP : Concept de playlist à 1 item
|
**Modifications**:
|
||||||
|
- Supprimé `StationType` enum
|
||||||
**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.
|
- Simplifié `Station` struct (juste `slug` + `name`)
|
||||||
|
- Supprimé méthodes `is_main()`, `is_webradio()`, `is_local_radio()`, `base_station()`
|
||||||
```
|
- Conservé structures d'API (`LiveResponse`, `ShowMetadata`, etc.)
|
||||||
radiofrance/ (container root)
|
|
||||||
├─ Stations standalone (containers de playlists)
|
### 4. Simplification: playlist.rs
|
||||||
│ ├─ radiofrance:franceculture/ (container → playlist)
|
|
||||||
│ │ └─ radiofrance:franceculture:stream (item unique dans la playlist)
|
**Modifications**:
|
||||||
│ └─ radiofrance:franceinter/ (container → playlist)
|
- Supprimé `StationPlaylist` complètement
|
||||||
│ └─ radiofrance:franceinter:stream (item unique)
|
- Simplifié `StationGroup` et `StationGroups`
|
||||||
├─ Groupes avec webradios (containers de containers)
|
- **Important**: `to_didl()` retourne `Container` (pas `Vec<Container>`)
|
||||||
│ ├─ radiofrance:group:fip/ (container de groupe)
|
- Logique unifiée: ICI fonctionne comme FIP (plus de traitement spécial)
|
||||||
│ │ ├─ radiofrance:fip/ (container → playlist)
|
- Préservé les règles de mapping RF → UPnP existantes
|
||||||
│ │ │ └─ radiofrance:fip:stream (item)
|
|
||||||
│ │ ├─ radiofrance:fip_rock/ (container → playlist)
|
### 5. Refactoring: source.rs
|
||||||
│ │ │ └─ radiofrance:fip_rock:stream (item)
|
|
||||||
│ │ └─ ...
|
**Modifications**:
|
||||||
└─ radiofrance:ici/ (container de groupe)
|
- Utilise uniquement `MetadataCache` (plus de `stateful_client`)
|
||||||
├─ radiofrance:francebleu_alsace/ (container → playlist)
|
- Simplifié `browse()` en 3 cas simples
|
||||||
│ └─ radiofrance:francebleu_alsace:stream (item)
|
- Abonnement aux événements du cache pour GENA
|
||||||
└─ ...
|
- Retourne des `Container` (cohérence avec to_didl)
|
||||||
```
|
|
||||||
|
### 6. Adaptation: config_ext.rs
|
||||||
## Plan d'implémentation
|
|
||||||
|
**Modifications**:
|
||||||
### Phase 1 : Système d'événements dans le cache
|
- Format simplifié: `Vec<Station>` au lieu de `CachedStationList`
|
||||||
|
- TTL reste à 7 jours (1 semaine)
|
||||||
**Fichier** : `pmoradiofrance/src/stateful_client.rs`
|
|
||||||
|
### 7. Mise à jour: lib.rs
|
||||||
#### 1.1 Ajouter le système de callback
|
|
||||||
|
**Modifications**:
|
||||||
```rust
|
- Ajouté `pub mod metadata_cache;`
|
||||||
// Type de callback pour notifications
|
- Supprimé export de `stateful_client`
|
||||||
pub type MetadataUpdateCallback = Arc<dyn Fn(&str) + Send + Sync>;
|
- Ajouté exports: `MetadataCache`, `CachedMetadata`
|
||||||
|
|
||||||
pub struct RadioFranceStatefulClient {
|
## Hiérarchie de browse
|
||||||
client: RadioFranceClient,
|
|
||||||
config: Arc<Config>,
|
**Niveau 0**: `radiofrance`
|
||||||
metadata_cache: Arc<RwLock<HashMap<String, LiveMetadataCache>>>,
|
- Retourne UN Container contenant les containers de groupes
|
||||||
// NOUVEAU : Liste des callbacks abonnés
|
- Exemple: Container "FIP", Container "France Culture", Container "ICI"
|
||||||
update_callbacks: Arc<RwLock<Vec<MetadataUpdateCallback>>>,
|
|
||||||
}
|
**Niveau 1**: `radiofrance:group:fip` ou `radiofrance:ici`
|
||||||
```
|
- Si 1 station: retourne directement la playlist (Container playlistContainer)
|
||||||
|
- Si plusieurs stations: retourne un container contenant les playlists
|
||||||
#### 1.2 Méthodes d'abonnement et notification
|
|
||||||
|
**Niveau 2**: `radiofrance:fip`
|
||||||
```rust
|
- Retourne Container playlistContainer avec 1 item
|
||||||
impl RadioFranceStatefulClient {
|
- Métadonnées identiques entre playlist et item
|
||||||
/// S'abonner aux mises à jour de métadonnées
|
|
||||||
pub fn subscribe_to_updates(&self, callback: MetadataUpdateCallback) {
|
## Règles de mapping préservées
|
||||||
let mut callbacks = self.update_callbacks.write().unwrap();
|
|
||||||
callbacks.push(callback);
|
Les règles existantes de transformation RF → UPnP ont été préservées:
|
||||||
}
|
- Radio musicale avec song → métadonnées du morceau
|
||||||
|
- Radio parlée → agrégation émission/producteur
|
||||||
/// Notifier tous les abonnés d'une mise à jour
|
- Éviter duplications du nom de station
|
||||||
fn notify_update(&self, slug: &str) {
|
- Calcul de duration depuis end_time
|
||||||
let callbacks = self.update_callbacks.read().unwrap();
|
|
||||||
for callback in callbacks.iter() {
|
## Système d'événements
|
||||||
callback(slug);
|
|
||||||
}
|
**Flux**:
|
||||||
}
|
1. `MetadataCache` rafraîchit les métadonnées d'un slug
|
||||||
}
|
2. Notifie tous les abonnés via `notify(slug)`
|
||||||
```
|
3. `RadioFranceSource` reçoit l'événement
|
||||||
|
4. Émet un événement GENA UPnP pour la playlist `radiofrance:{slug}`
|
||||||
#### 1.3 Refactorisation de get_live_metadata()
|
5. Le Control Point reçoit la notification et peut se mettre à jour
|
||||||
|
|
||||||
**Principe** : Une seule méthode qui gère tout automatiquement :
|
## Fichiers modifiés
|
||||||
- Si cache valide → retour immédiat
|
|
||||||
- Si cache expiré → fetch API + mise à jour cache + notification événements
|
### Créés
|
||||||
|
- `pmoradiofrance/src/metadata_cache.rs`
|
||||||
```rust
|
|
||||||
pub async fn get_live_metadata(&self, station: &str) -> Result<LiveResponse> {
|
### Supprimés
|
||||||
// Check cache first
|
- `pmoradiofrance/src/stateful_client.rs`
|
||||||
{
|
|
||||||
let cache = self.metadata_cache.read().unwrap();
|
### Modifiés
|
||||||
if let Some(entry) = cache.get(station) {
|
- `pmoradiofrance/src/models.rs`
|
||||||
if entry.is_valid() {
|
- `pmoradiofrance/src/playlist.rs`
|
||||||
return Ok(entry.metadata.clone());
|
- `pmoradiofrance/src/source.rs`
|
||||||
}
|
- `pmoradiofrance/src/config_ext.rs`
|
||||||
}
|
- `pmoradiofrance/src/lib.rs`
|
||||||
}
|
|
||||||
|
### Inchangés
|
||||||
// Cache miss or expired - fetch from API
|
- `pmoradiofrance/src/client.rs`
|
||||||
let metadata = tokio::time::timeout(
|
- `pmoradiofrance/src/error.rs`
|
||||||
Duration::from_secs(5),
|
|
||||||
self.client.live_metadata(station),
|
## Points de vigilance
|
||||||
).await??;
|
|
||||||
|
1. **Migration**: Le cache pmoconfig existant sera invalidé (nouveau format)
|
||||||
// Update cache
|
2. **Covers**: Nécessite que pmocovers soit initialisé via cache_registry
|
||||||
{
|
3. **Thread safety**: Utilisation d'Arc<RwLock> pour la sécurité thread
|
||||||
let mut cache = self.metadata_cache.write().unwrap();
|
4. **Graceful degradation**: Retourne cache expiré si API Radio France down
|
||||||
cache.insert(station.to_string(), LiveMetadataCache::new(metadata.clone()));
|
|
||||||
}
|
## Prochaines étapes
|
||||||
|
|
||||||
// Notify subscribers
|
1. Tester le cache de métadonnées (TTL, refresh, graceful degradation)
|
||||||
self.notify_update(station);
|
2. Tester le système d'événements
|
||||||
|
3. Tester le browse sur les 3 niveaux
|
||||||
Ok(metadata)
|
4. Vérifier les événements GENA
|
||||||
}
|
5. Vérifier que les covers sont correctement cachées
|
||||||
```
|
|
||||||
|
## Plan d'implémentation détaillé
|
||||||
**SUPPRIMER** : `refresh_live_metadata()` - Redondant, le TTL gère tout.
|
|
||||||
|
Le plan détaillé est disponible dans:
|
||||||
### Phase 2 : Hiérarchie UPnP avec playlists
|
`/Users/coissac/.claude/plans/glowing-scribbling-cook.md`
|
||||||
|
|
||||||
**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
|
|
||||||
|
|||||||
@@ -20,3 +20,154 @@ La source Radio France doit donc s'abonner aux événements du Cache. A chaque f
|
|||||||
Maintenant, il y a le cache des stations. Le cache des stations finalement il ne stock qu'un emboîtement de listes de slug. Ça, normalement, ça ne bouge quasiment pas. On peut dire que une fois par jour, on met à jour ce cache. Les listes de slug ont donc un TTL mais très long.
|
Maintenant, il y a le cache des stations. Le cache des stations finalement il ne stock qu'un emboîtement de listes de slug. Ça, normalement, ça ne bouge quasiment pas. On peut dire que une fois par jour, on met à jour ce cache. Les listes de slug ont donc un TTL mais très long.
|
||||||
|
|
||||||
A chaque browse, on reconstruit un document didl à partir des métadonnées à jour provenant du cache.
|
A chaque browse, on reconstruit un document didl à partir des métadonnées à jour provenant du cache.
|
||||||
|
|
||||||
|
## Round 2
|
||||||
|
|
||||||
|
Je repasse sur ton code. Tout est beaucoup beaucoup trop compliqué, trop de structures allambiquées, de trucs qui s'emboîtent dans des trucs. Il faut faire simple. Le mot d'ordre est simple. Nous ne construisons pas une usine à gaz, nous construisons simplement un truc capable de diffuser moins d'une centaine de radios.
|
||||||
|
|
||||||
|
### Simplification de la notion de station.
|
||||||
|
|
||||||
|
Alors, tu fais une distinction entre radio locale et web radio, c'est une distinction sémantique, mais d'un point de vue informatique y'a pas de différence.
|
||||||
|
|
||||||
|
L'unité de base, ça devrait être:
|
||||||
|
|
||||||
|
pub struct StationGroup {
|
||||||
|
pub stations: Vec<Station>,
|
||||||
|
}
|
||||||
|
|
||||||
|
La seule règle metier sémantique est: L'index 0 du vecteur est attribué à la station principale du groupe, par exemple FIP, pour le groupe FIP, si elle existe.
|
||||||
|
|
||||||
|
Et du coup, les StationGroups devrait juste être un vecteur de StationGroup
|
||||||
|
|
||||||
|
- StationGroups définie le niveau zéro du browse
|
||||||
|
- StationGroup définit les différents niveaux 1
|
||||||
|
|
||||||
|
Chaque station étant représentée maintenant par une playlist à un item item, Il y a un niveau 2 de browsing qui correspond à l'item de la station.
|
||||||
|
|
||||||
|
Donc, Station, StationGroup et StationGroups devrait chacun fournir une méthode retournant un objet PMODidl qui se construit en demandant les métadonnées au cache. Genre:
|
||||||
|
|
||||||
|
async pub fn to_didl(caches et server_base_url)
|
||||||
|
|
||||||
|
## Simplification du cache
|
||||||
|
|
||||||
|
Il faut réfléchir, Finalement, qu'est-ce que l'on a besoin de stocker dans le cache pour être efficace? De quoi remplir les Didl. Donc, à partir des données parsées depuis l'API Radio France, il faut reconstruire une structure simplifiée. contenant juste les données telles qu'on va les utiliser dans le diddle. Idéalement, le cache devrait être capable de fournir le bien d'idoles d'un item. Avec une méthode to_didl(slug) -> Un item de la Crate pmodidl. Tout le reste est superflu. Donc ne doit pas être stocké. Pour calculer la durée correctement, Il nous faut la fin de validité de l'item. Il est donc important de stocker end_time. Normalement, end time est aussi le TTL. Car à la fin de la diffusion de cet item, ça veut dire qu'il faut remettre à jour les métadata, Pour avoir l'item suivant.
|
||||||
|
|
||||||
|
## Round 3
|
||||||
|
|
||||||
|
### Problèmes identifiés
|
||||||
|
|
||||||
|
Point 3 : Le cache des slugs doit être persistant et stocké dans la config comme actuellement. Avec un délai d'une semaine. Le cache des métadonnées reste en mémoire. Les métadonnées changent à chaque émission, il n'y a pas de raison de les stocker de manière persistante.
|
||||||
|
|
||||||
|
Du coup, le cache des métadonnées, Pour simplifier la vie des autres structures. devrait s'occuper de cacher les covers dans pmocovers et stocker le PK de l'image dans le cache pour pouvoir construire le didl de l'item.
|
||||||
|
|
||||||
|
En fait, le didl de l'item, Dans notre nouvelle strategie est déjà un didl d'une playlist à un item.
|
||||||
|
|
||||||
|
### Architecture cible simplifiée
|
||||||
|
|
||||||
|
#### 1. Structures de station (models.rs et playlist.rs)
|
||||||
|
|
||||||
|
Je ne comprends pas bien la distinction entre les deux:
|
||||||
|
A-t-on vraiment besoin des deux fonctions?
|
||||||
|
À quoi sert cette fonction to_container?
|
||||||
|
|
||||||
|
```
|
||||||
|
// Browse niveau 1: retourne les playlists (containers) pour chaque station du groupe
|
||||||
|
pub async fn to_didl(&self, metadata_cache: &MetadataCache, server_base_url: &str) -> Vec<Container>;
|
||||||
|
|
||||||
|
// Helper pour construire le container de groupe (sans items, juste la structure)
|
||||||
|
pub fn to_container(&self, server_base_url: Option<&str>) -> Container;
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
impl CachedMetadata {
|
||||||
|
// Parse depuis LiveResponse + Station + optionnel cover cache
|
||||||
|
#[cfg(feature = "cache")]
|
||||||
|
pub async fn from_live_response(
|
||||||
|
station: &Station,
|
||||||
|
live: &LiveResponse,
|
||||||
|
cover_cache: Option<&Arc<CoverCache>>,
|
||||||
|
server_base_url: Option<&str>,
|
||||||
|
) -> Result<Self>;
|
||||||
|
|
||||||
|
pub fn from_live_response_sync(
|
||||||
|
station: &Station,
|
||||||
|
live: &LiveResponse,
|
||||||
|
server_base_url: Option<&str>,
|
||||||
|
) -> Result<Self>;
|
||||||
|
```
|
||||||
|
|
||||||
|
C'est quoi exactement la fonction from_live_response_sync ?
|
||||||
|
J'ai l'impression que tu surcompliques encore.
|
||||||
|
|
||||||
|
```
|
||||||
|
pub fn to_didl_item(&self, parent_id: &str) -> Item
|
||||||
|
```
|
||||||
|
|
||||||
|
Vu ma remarque précédente:
|
||||||
|
En fait, le didl de l'item, Dans notre nouvelle strategie est déjà un didl d'une playlist à un item.
|
||||||
|
|
||||||
|
Cette fonction devrait juste être un toDiddle et retourner le diddle d'une playlist à un item avec exactement les mêmes métadonnées pour la playlist conteneur et l'item à l'intérieur.
|
||||||
|
|
||||||
|
### 3. Cache de stations (intégré dans stateful_client.rs)
|
||||||
|
|
||||||
|
Comme je le disais plus haut, ce cache doit être permanent via l'usage de la configuration. Comme c'est le cas actuellement.
|
||||||
|
|
||||||
|
## Hiérarchie de browse
|
||||||
|
|
||||||
|
**Niveau 1**: Browse d'un groupe
|
||||||
|
En fait, si un station groupe ne contient qu'un seul item, C'est à dire qu'il n'y a pas plusieurs sous-radios sous ce groupe. on peut directement retourner la playlist simple qui contient simplement cet item.
|
||||||
|
|
||||||
|
Questions pour validation
|
||||||
|
|
||||||
|
1. **Organisation des stations sans webradios**: Faut-il créer un groupe pour chaque station standalone (France Culture, France Inter, etc.) ou les mettre toutes dans un seul groupe "Stations principales"?
|
||||||
|
- En fait, si un station groupe ne contient qu'un seul item, C'est à dire qu'il n'y a pas plusieurs sous-radios sous ce groupe. on peut directement retourner la playlist simple qui contient simplement cet item. Sinon, on retourne un container qui contient les playlists de chacun des items. Cela peut directement être implémenté dans le code de la fonction to Didl du groupe de station.
|
||||||
|
|
||||||
|
2. **Cache de métadonnées**: In-memory uniquement (données volatiles avec TTL court)?
|
||||||
|
- Oui, in-memory seulement, TTL basé sur la fin de diffusion de cet item.
|
||||||
|
|
||||||
|
3. **Cache de stations**: Rester dans pmoconfig avec TTL 1 jour?
|
||||||
|
- Oui, garder le système actuel, Il me semble que le TTL est d'une semaine actuellement, mais le garder tel qu'il est.
|
||||||
|
|
||||||
|
4. **Gestion d'erreur API Radio France down**: Retourner les données expirées avec warning?
|
||||||
|
- **Proposition**: Oui, graceful degradation
|
||||||
|
C'est parfait.
|
||||||
|
|
||||||
|
5. **Migration du code existant**: Faut-il maintenir une compatibilité temporaire ou refactoring complet immédiat?
|
||||||
|
- **Proposition**: Refactoring complet, c'est une simplification profonde
|
||||||
|
C'est parfait.
|
||||||
|
|
||||||
|
|
||||||
|
## Round 4
|
||||||
|
|
||||||
|
### 2. Groupes de stations (playlist.rs)
|
||||||
|
|
||||||
|
```
|
||||||
|
impl StationGroups {
|
||||||
|
// Browse niveau 0: retourne les containers de groupes
|
||||||
|
pub async fn to_didl(&self, metadata_cache: &MetadataCache, server_base_url: &str) -> Vec<Container>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pourquoi retourner un vecteur de conteneurs et pas un conteneur qui contient des conteneurs? Ça doit retourner une structure didl La fonction s'appelle to_didl.
|
||||||
|
|
||||||
|
Il faut être cohérent. Et **SIMPLE**.
|
||||||
|
|
||||||
|
### 3. Cache de métadonnées (NOUVEAU: metadata_cache.rs)
|
||||||
|
|
||||||
|
Il y a actuellement dans le code des règles pour passer des métadonnées Radio France vers des métadonnées UPNP, qui agrège les métadonnées selon certaines règles depuis Radio France pour en faire des métadonnées plus simples mais avec une sémantique correcte pour l'interface utilisateur du côté UPNP. Il ne faut pas abandonner ces règles.
|
||||||
|
|
||||||
|
### Hiérarchie de browse
|
||||||
|
|
||||||
|
**Niveau 0**: `radiofrance` → containers de groupes
|
||||||
|
- "France Culture" (id: `radiofrance:franceculture`) - playlist directe si groupe à 1 station
|
||||||
|
- "FIP" (id: `radiofrance:group:fip`) - container de groupe si plusieurs stations
|
||||||
|
- "Radios ICI" (id: `radiofrance:ici`) - container de groupe pour les radios locales --> Je te rappelle qu'il n'y a plus de distinction entre radio locale et autres radios. Ça c'était avant. Donc ICI fonctionne exactement comme FIP.
|
||||||
|
|
||||||
|
### Étapes d'implémentation
|
||||||
|
|
||||||
|
#### Étape 1: Créer metadata_cache.rs
|
||||||
|
1. Définir `CachedMetadata` struct avec tous les champs DIDL
|
||||||
|
|
||||||
|
On est d'accord que si tu définis ce type là, ça veut dire que tu supprimes le client Stateful. Sans ça, c'est complètement redondant.
|
||||||
|
|
||||||
|
A la fin de cette tâche, tu généreras le nouveau plan dans le fichier de rapport tel que c'est demandé par le fichier de règles [@Rules.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules.md) que tu devras suivre scrupuleusement.
|
||||||
|
|||||||
@@ -473,7 +473,7 @@ impl ContentHandler {
|
|||||||
/// Construit le container racine du MediaServer
|
/// Construit le container racine du MediaServer
|
||||||
async fn build_root_container(&self) -> Container {
|
async fn build_root_container(&self) -> Container {
|
||||||
let sources = list_all_sources().await;
|
let sources = list_all_sources().await;
|
||||||
let child_count = sources.len();
|
let _child_count = sources.len();
|
||||||
|
|
||||||
Container {
|
Container {
|
||||||
id: "0".to_string(),
|
id: "0".to_string(),
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ use axum::{
|
|||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
use pmoaudiocache::{AudioCacheExt, Cache as AudioCache, get_audio_cache, register_audio_cache};
|
use pmoaudiocache::{AudioCacheExt, get_audio_cache, register_audio_cache};
|
||||||
use pmocovers::{Cache as CoverCache, CoverCacheExt, get_cover_cache, register_cover_cache};
|
use pmocovers::{CoverCacheExt, get_cover_cache, register_cover_cache};
|
||||||
use pmoparadise::{
|
use pmoparadise::{
|
||||||
ParadiseChannelManager, ParadiseHistoryBuilder,
|
ParadiseChannelManager, ParadiseHistoryBuilder,
|
||||||
channels::{ALL_CHANNELS, ChannelDescriptor},
|
channels::{ALL_CHANNELS, ChannelDescriptor},
|
||||||
@@ -91,7 +91,7 @@ impl ParadiseStreamingExt for pmoserver::Server {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let audio_cache = match get_audio_cache() {
|
let _audio_cache = match get_audio_cache() {
|
||||||
Some(cache) => {
|
Some(cache) => {
|
||||||
info!(" ✅ Using existing audio cache singleton");
|
info!(" ✅ Using existing audio cache singleton");
|
||||||
// S'assurer qu'il est aussi enregistré dans le playlist manager
|
// S'assurer qu'il est aussi enregistré dans le playlist manager
|
||||||
@@ -332,7 +332,7 @@ fn spawn_playlist_event_handler(manager: Arc<ParadiseChannelManager>) {
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut rx = pmoplaylist::subscribe_events();
|
let mut rx = pmoplaylist::subscribe_events();
|
||||||
while let Ok(envelope) = rx.recv().await {
|
while let Ok(envelope) = rx.recv().await {
|
||||||
if let PlaylistEventKind::TrackPlayed { cache_pk, .. } = envelope.event.kind {
|
if let PlaylistEventKind::TrackPlayed { cache_pk: _, .. } = envelope.event.kind {
|
||||||
if let Some(descriptor) = channel_from_live_playlist(&envelope.event.playlist_id) {
|
if let Some(descriptor) = channel_from_live_playlist(&envelope.event.playlist_id) {
|
||||||
if let Err(e) = manager.prefetch_until_horizon(descriptor.id).await {
|
if let Err(e) = manager.prefetch_until_horizon(descriptor.id).await {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
|
|||||||
@@ -244,25 +244,21 @@ impl SourcesExt for Server {
|
|||||||
|
|
||||||
#[cfg(feature = "radiofrance")]
|
#[cfg(feature = "radiofrance")]
|
||||||
async fn register_radiofrance(&mut self) -> Result<()> {
|
async fn register_radiofrance(&mut self) -> Result<()> {
|
||||||
use pmoradiofrance::{RadioFranceExt, RadioFranceSource, RadioFranceStatefulClient};
|
use pmoradiofrance::{RadioFranceExt, RadioFranceSource};
|
||||||
|
|
||||||
tracing::info!("Initializing Radio France source...");
|
tracing::info!("Initializing Radio France source...");
|
||||||
|
|
||||||
// Obtenir l'URL de base du serveur
|
// Obtenir l'URL de base du serveur
|
||||||
let base_url = self.base_url();
|
let base_url = self.base_url();
|
||||||
|
|
||||||
// Créer le client stateful depuis la config
|
// Créer la source depuis le registry (avec cache)
|
||||||
let client = RadioFranceStatefulClient::from_config()
|
let config = pmoconfig::get_config();
|
||||||
|
let source = RadioFranceSource::from_registry(config, base_url)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
SourceInitError::RadioFranceError(format!("Failed to create client: {}", e))
|
SourceInitError::RadioFranceError(format!("Failed to create source: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Créer la source depuis le registry (avec cache)
|
|
||||||
let source = RadioFranceSource::from_registry(client, base_url).map_err(|e| {
|
|
||||||
SourceInitError::RadioFranceError(format!("Failed to create source: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Configurer le notifier pour les événements UPnP GENA
|
// Configurer le notifier pour les événements UPnP GENA
|
||||||
let notifier = Arc::new(|containers: &[String]| {
|
let notifier = Arc::new(|containers: &[String]| {
|
||||||
let refs: Vec<&str> = containers.iter().map(|s| s.as_str()).collect();
|
let refs: Vec<&str> = containers.iter().map(|s| s.as_str()).collect();
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ async fn register_qobuz(Json(creds): Json<QobuzCredentials>) -> impl IntoRespons
|
|||||||
tag = "sources"
|
tag = "sources"
|
||||||
)]
|
)]
|
||||||
async fn register_paradise(Json(params): Json<ParadiseParams>) -> impl IntoResponse {
|
async fn register_paradise(Json(params): Json<ParadiseParams>) -> impl IntoResponse {
|
||||||
use pmoparadise::{RadioParadiseClient, RadioParadiseSource};
|
use pmoparadise::RadioParadiseSource;
|
||||||
use pmosource::api::register_source;
|
use pmosource::api::register_source;
|
||||||
|
|
||||||
// Utiliser l'URL de base depuis les params ou une valeur par défaut
|
// Utiliser l'URL de base depuis les params ou une valeur par défaut
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use serde_json;
|
use serde_json;
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
// ============ Gestion des erreurs ============
|
// ============ Gestion des erreurs ============
|
||||||
|
|
||||||
@@ -61,35 +60,26 @@ pub fn create_router(state: RadioFranceState) -> Router {
|
|||||||
|
|
||||||
/// GET /api/radiofrance/stations
|
/// GET /api/radiofrance/stations
|
||||||
/// Returns the grouped list of stations
|
/// Returns the grouped list of stations
|
||||||
#[axum::debug_handler]
|
async fn get_stations(State(state): State<RadioFranceState>) -> impl IntoResponse {
|
||||||
async fn get_stations(
|
match state.source.get_stations().await {
|
||||||
State(state): State<RadioFranceState>,
|
Ok(stations) => {
|
||||||
) -> Result<Json<StationGroups>, AppError> {
|
let groups = StationGroups::from_stations(stations);
|
||||||
let stations = state
|
Json(groups).into_response()
|
||||||
.source
|
}
|
||||||
.client
|
Err(e) => AppError(e.to_string()).into_response(),
|
||||||
.get_stations()
|
}
|
||||||
.await
|
|
||||||
.map_err(|e| AppError(e.to_string()))?;
|
|
||||||
|
|
||||||
let groups = StationGroups::from_stations(stations);
|
|
||||||
Ok(Json(groups))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/radiofrance/{slug}/metadata
|
/// GET /api/radiofrance/{slug}/metadata
|
||||||
/// Returns live metadata for a station (with caching)
|
/// Returns live metadata for a station
|
||||||
async fn get_metadata(
|
async fn get_metadata(
|
||||||
State(state): State<RadioFranceState>,
|
State(state): State<RadioFranceState>,
|
||||||
Path(slug): Path<String>,
|
Path(slug): Path<String>,
|
||||||
) -> Result<Json<LiveResponse>, AppError> {
|
) -> impl IntoResponse {
|
||||||
let metadata = state
|
match state.source.get_live_metadata(&slug).await {
|
||||||
.source
|
Ok(metadata) => Json(metadata).into_response(),
|
||||||
.client
|
Err(e) => AppError(e.to_string()).into_response(),
|
||||||
.get_live_metadata(&slug)
|
}
|
||||||
.await
|
|
||||||
.map_err(|e| AppError(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(Json(metadata))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/radiofrance/{slug}/stream
|
/// GET /api/radiofrance/{slug}/stream
|
||||||
@@ -97,36 +87,24 @@ async fn get_metadata(
|
|||||||
async fn proxy_stream(
|
async fn proxy_stream(
|
||||||
State(state): State<RadioFranceState>,
|
State(state): State<RadioFranceState>,
|
||||||
Path(slug): Path<String>,
|
Path(slug): Path<String>,
|
||||||
) -> Result<Response, AppError> {
|
) -> impl IntoResponse {
|
||||||
// Start metadata refresh when stream is accessed
|
|
||||||
#[cfg(feature = "logging")]
|
#[cfg(feature = "logging")]
|
||||||
tracing::info!("Stream proxy accessed for station: {}", slug);
|
tracing::info!("Stream proxy accessed for station: {}", slug);
|
||||||
|
|
||||||
// Spawn refresh task (non-blocking)
|
|
||||||
let source_clone = Arc::clone(&state.source);
|
|
||||||
let slug_clone = slug.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = source_clone.start_metadata_refresh(&slug_clone).await {
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::error!("Failed to start metadata refresh for {}: {}", slug_clone, e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get the stream URL
|
// Get the stream URL
|
||||||
let stream_url = state
|
let stream_url = match state.source.get_stream_url(&slug).await {
|
||||||
.source
|
Ok(url) => url,
|
||||||
.client
|
Err(e) => return AppError(format!("Stream not found: {}", e)).into_response(),
|
||||||
.get_stream_url(&slug)
|
};
|
||||||
.await
|
|
||||||
.map_err(|e| AppError(format!("Stream not found: {}", e)))?;
|
|
||||||
|
|
||||||
// Connect to the Radio France stream
|
// Connect to the Radio France stream
|
||||||
let response = reqwest::get(&stream_url)
|
let response = match reqwest::get(&stream_url).await {
|
||||||
.await
|
Ok(r) => r,
|
||||||
.map_err(|e| AppError(format!("Failed to connect: {}", e)))?;
|
Err(e) => return AppError(format!("Failed to connect: {}", e)).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(AppError(format!("Upstream returned {}", response.status())));
|
return AppError(format!("Upstream returned {}", response.status())).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build response headers
|
// Build response headers
|
||||||
@@ -134,46 +112,14 @@ async fn proxy_stream(
|
|||||||
headers.insert("content-type", "audio/aac".parse().unwrap());
|
headers.insert("content-type", "audio/aac".parse().unwrap());
|
||||||
headers.insert("cache-control", "no-cache".parse().unwrap());
|
headers.insert("cache-control", "no-cache".parse().unwrap());
|
||||||
|
|
||||||
// Create streaming body with cleanup on disconnect
|
// Create streaming body
|
||||||
let stream = response
|
let stream = response
|
||||||
.bytes_stream()
|
.bytes_stream()
|
||||||
.map(|chunk| chunk.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));
|
.map(|chunk| chunk.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));
|
||||||
|
|
||||||
// Wrap the stream to detect when client disconnects
|
let body = Body::from_stream(stream);
|
||||||
let source_for_cleanup = Arc::clone(&state.source);
|
|
||||||
let slug_for_cleanup = slug.clone();
|
|
||||||
let monitored_stream =
|
|
||||||
futures::stream::unfold((stream, false), move |(mut stream, mut done)| {
|
|
||||||
let source = source_for_cleanup.clone();
|
|
||||||
let slug = slug_for_cleanup.clone();
|
|
||||||
async move {
|
|
||||||
if done {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
match stream.next().await {
|
(headers, body).into_response()
|
||||||
Some(Ok(chunk)) => Some((Ok(chunk), (stream, false))),
|
|
||||||
Some(Err(e)) => {
|
|
||||||
// Error occurred, stop refresh
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::info!("Stream error for {}, stopping refresh", slug);
|
|
||||||
source.stop_metadata_refresh(&slug).await;
|
|
||||||
Some((Err(e), (stream, true)))
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
// Stream ended normally, stop refresh
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::info!("Stream ended for {}, stopping refresh", slug);
|
|
||||||
source.stop_metadata_refresh(&slug).await;
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let body = Body::from_stream(monitored_stream);
|
|
||||||
|
|
||||||
Ok((headers, body).into_response())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/radiofrance/default-logo
|
/// GET /api/radiofrance/default-logo
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ impl RadioFranceClient {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|slug| {
|
.map(|slug| {
|
||||||
let name = Self::slug_to_display_name(&slug);
|
let name = Self::slug_to_display_name(&slug);
|
||||||
Station::main(slug, name)
|
Station::new(slug, name)
|
||||||
})
|
})
|
||||||
.collect());
|
.collect());
|
||||||
}
|
}
|
||||||
@@ -211,7 +211,7 @@ impl RadioFranceClient {
|
|||||||
|
|
||||||
Ok(KNOWN_MAIN_STATIONS
|
Ok(KNOWN_MAIN_STATIONS
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(slug, name)| Station::main(*slug, *name))
|
.map(|(slug, name)| Station::new(*slug, *name))
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +249,7 @@ impl RadioFranceClient {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|slug| {
|
.map(|slug| {
|
||||||
let name = Self::slug_to_display_name(&slug);
|
let name = Self::slug_to_display_name(&slug);
|
||||||
Station::webradio(slug, name, station)
|
Station::new(slug, name)
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -267,10 +267,7 @@ impl RadioFranceClient {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|local| local.is_on_air)
|
.filter(|local| local.is_on_air)
|
||||||
.map(|local| {
|
.map(|local| Station::new(local.name, local.title))
|
||||||
let region = local.title.replace("ICI ", "");
|
|
||||||
Station::local_radio(local.name, local.title, region, local.id)
|
|
||||||
})
|
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1145,21 +1142,9 @@ mod tests {
|
|||||||
let stations = stations.unwrap();
|
let stations = stations.unwrap();
|
||||||
assert!(!stations.is_empty(), "Expected stations");
|
assert!(!stations.is_empty(), "Expected stations");
|
||||||
|
|
||||||
// Count by type
|
println!("Discovered {} total stations", stations.len());
|
||||||
let main_count = stations.iter().filter(|s| s.is_main()).count();
|
|
||||||
let webradio_count = stations.iter().filter(|s| s.is_webradio()).count();
|
|
||||||
let local_count = stations.iter().filter(|s| s.is_local_radio()).count();
|
|
||||||
|
|
||||||
println!("Discovered {} total stations:", stations.len());
|
// Should have a significant number of stations
|
||||||
println!(" - {} main stations", main_count);
|
|
||||||
println!(" - {} webradios", webradio_count);
|
|
||||||
println!(" - {} local radios", local_count);
|
|
||||||
|
|
||||||
// Should have a good mix
|
|
||||||
assert!(main_count >= 5, "Expected at least 5 main stations");
|
|
||||||
assert!(local_count >= 30, "Expected at least 30 local radios");
|
|
||||||
|
|
||||||
// Total should be significant
|
|
||||||
assert!(
|
assert!(
|
||||||
stations.len() >= 40,
|
stations.len() >= 40,
|
||||||
"Expected at least 40 total stations, got {}",
|
"Expected at least 40 total stations, got {}",
|
||||||
|
|||||||
@@ -32,14 +32,23 @@
|
|||||||
//! # }
|
//! # }
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use crate::models::{CachedStationList, Station};
|
use crate::models::Station;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use pmoconfig::Config;
|
use pmoconfig::Config;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_yaml::Value;
|
use serde_yaml::Value;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
/// Default TTL for station list cache (7 days in seconds)
|
/// Default TTL for station list cache (7 days in seconds)
|
||||||
pub const DEFAULT_STATION_CACHE_TTL_SECS: u64 = 7 * 24 * 3600;
|
pub const DEFAULT_STATION_CACHE_TTL_SECS: u64 = 7 * 24 * 3600;
|
||||||
|
|
||||||
|
/// Cached station list (simplifié)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct CachedStations {
|
||||||
|
stations: Vec<Station>,
|
||||||
|
last_updated: u64, // Unix timestamp
|
||||||
|
}
|
||||||
|
|
||||||
/// Trait d'extension pour gérer la configuration Radio France dans pmoconfig
|
/// Trait d'extension pour gérer la configuration Radio France dans pmoconfig
|
||||||
///
|
///
|
||||||
/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques
|
/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques
|
||||||
@@ -75,7 +84,7 @@ pub trait RadioFranceConfigExt {
|
|||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// - `Some(CachedStationList)` si le cache existe et est valide
|
/// - `Some(Vec<Station>)` si le cache existe et est valide
|
||||||
/// - `None` si le cache n'existe pas ou est expiré
|
/// - `None` si le cache n'existe pas ou est expiré
|
||||||
///
|
///
|
||||||
/// # Cache Validation
|
/// # Cache Validation
|
||||||
@@ -83,8 +92,7 @@ pub trait RadioFranceConfigExt {
|
|||||||
/// Le cache est considéré invalide si :
|
/// Le cache est considéré invalide si :
|
||||||
/// - Il n'existe pas
|
/// - Il n'existe pas
|
||||||
/// - Son TTL est dépassé (configurable, défaut 7 jours)
|
/// - Son TTL est dépassé (configurable, défaut 7 jours)
|
||||||
/// - Sa version ne correspond pas à la version actuelle de l'algorithme
|
fn get_radiofrance_stations_cached(&self) -> Result<Option<Vec<Station>>>;
|
||||||
fn get_radiofrance_cached_stations(&self) -> Result<Option<CachedStationList>>;
|
|
||||||
|
|
||||||
/// Enregistre la liste des stations en cache
|
/// Enregistre la liste des stations en cache
|
||||||
///
|
///
|
||||||
@@ -103,45 +111,8 @@ pub trait RadioFranceConfigExt {
|
|||||||
/// Définit le TTL du cache des stations (en secondes)
|
/// Définit le TTL du cache des stations (en secondes)
|
||||||
fn set_radiofrance_station_cache_ttl(&self, ttl_secs: u64) -> Result<()>;
|
fn set_radiofrance_station_cache_ttl(&self, ttl_secs: u64) -> Result<()>;
|
||||||
|
|
||||||
/// Vérifie si le cache des stations est valide
|
|
||||||
///
|
|
||||||
/// Raccourci pour `get_radiofrance_cached_stations()?.is_some()`
|
|
||||||
fn is_radiofrance_station_cache_valid(&self) -> bool;
|
|
||||||
|
|
||||||
/// Efface le cache des stations (force re-découverte)
|
/// Efface le cache des stations (force re-découverte)
|
||||||
fn clear_radiofrance_station_cache(&self) -> Result<()>;
|
fn clear_radiofrance_station_cache(&self) -> Result<()>;
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// High-level helpers
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
/// Récupère les stations, en utilisant le cache si valide
|
|
||||||
///
|
|
||||||
/// Cette méthode est un helper qui :
|
|
||||||
/// 1. Vérifie le cache
|
|
||||||
/// 2. Si valide, retourne les stations du cache
|
|
||||||
/// 3. Si invalide, retourne None (l'appelant doit découvrir et mettre en cache)
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// # use pmoconfig::get_config;
|
|
||||||
/// # use pmoradiofrance::{RadioFranceConfigExt, RadioFranceClient};
|
|
||||||
/// # #[tokio::main]
|
|
||||||
/// # async fn main() -> anyhow::Result<()> {
|
|
||||||
/// let config = get_config();
|
|
||||||
/// let stations = if let Some(cached) = config.get_radiofrance_stations_cached()? {
|
|
||||||
/// cached
|
|
||||||
/// } else {
|
|
||||||
/// let client = RadioFranceClient::new().await?;
|
|
||||||
/// let discovered = client.discover_all_stations().await?;
|
|
||||||
/// config.set_radiofrance_cached_stations(&discovered)?;
|
|
||||||
/// discovered
|
|
||||||
/// };
|
|
||||||
/// # Ok(())
|
|
||||||
/// # }
|
|
||||||
/// ```
|
|
||||||
fn get_radiofrance_stations_cached(&self) -> Result<Option<Vec<Station>>>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RadioFranceConfigExt for Config {
|
impl RadioFranceConfigExt for Config {
|
||||||
@@ -160,19 +131,24 @@ impl RadioFranceConfigExt for Config {
|
|||||||
self.set_value(&["sources", "radiofrance", "enabled"], Value::Bool(enabled))
|
self.set_value(&["sources", "radiofrance", "enabled"], Value::Bool(enabled))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_radiofrance_cached_stations(&self) -> Result<Option<CachedStationList>> {
|
fn get_radiofrance_stations_cached(&self) -> Result<Option<Vec<Station>>> {
|
||||||
let ttl = self.get_radiofrance_station_cache_ttl()?;
|
let ttl = self.get_radiofrance_station_cache_ttl()?;
|
||||||
|
|
||||||
match self.get_value(&["sources", "radiofrance", "station_cache"]) {
|
match self.get_value(&["sources", "radiofrance", "station_cache"]) {
|
||||||
Ok(value) => {
|
Ok(value) => {
|
||||||
// Try to deserialize the cached data
|
// Try to deserialize the cached data
|
||||||
let cached: CachedStationList = serde_yaml::from_value(value)?;
|
let cached: CachedStations = serde_yaml::from_value(value)?;
|
||||||
|
|
||||||
// Check validity
|
// Check validity (TTL)
|
||||||
if cached.is_valid(ttl) {
|
let now = SystemTime::now()
|
||||||
Ok(Some(cached))
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
if now - cached.last_updated < ttl {
|
||||||
|
Ok(Some(cached.stations))
|
||||||
} else {
|
} else {
|
||||||
// Cache expired or version mismatch
|
// Cache expired
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,7 +157,16 @@ impl RadioFranceConfigExt for Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn set_radiofrance_cached_stations(&self, stations: &[Station]) -> Result<()> {
|
fn set_radiofrance_cached_stations(&self, stations: &[Station]) -> Result<()> {
|
||||||
let cached = CachedStationList::new(stations.to_vec());
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let cached = CachedStations {
|
||||||
|
stations: stations.to_vec(),
|
||||||
|
last_updated: now,
|
||||||
|
};
|
||||||
|
|
||||||
let value = serde_yaml::to_value(&cached)?;
|
let value = serde_yaml::to_value(&cached)?;
|
||||||
self.set_value(&["sources", "radiofrance", "station_cache"], value)
|
self.set_value(&["sources", "radiofrance", "station_cache"], value)
|
||||||
}
|
}
|
||||||
@@ -212,23 +197,10 @@ impl RadioFranceConfigExt for Config {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_radiofrance_station_cache_valid(&self) -> bool {
|
|
||||||
self.get_radiofrance_cached_stations()
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clear_radiofrance_station_cache(&self) -> Result<()> {
|
fn clear_radiofrance_station_cache(&self) -> Result<()> {
|
||||||
// Set to null to clear
|
// Set to null to clear
|
||||||
self.set_value(&["sources", "radiofrance", "station_cache"], Value::Null)
|
self.set_value(&["sources", "radiofrance", "station_cache"], Value::Null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_radiofrance_stations_cached(&self) -> Result<Option<Vec<Station>>> {
|
|
||||||
Ok(self
|
|
||||||
.get_radiofrance_cached_stations()?
|
|
||||||
.map(|cached| cached.stations))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ pub mod models;
|
|||||||
pub mod config_ext;
|
pub mod config_ext;
|
||||||
|
|
||||||
#[cfg(feature = "pmoconfig")]
|
#[cfg(feature = "pmoconfig")]
|
||||||
pub mod stateful_client;
|
pub mod metadata_cache;
|
||||||
|
|
||||||
#[cfg(feature = "playlist")]
|
#[cfg(feature = "playlist")]
|
||||||
pub mod playlist;
|
pub mod playlist;
|
||||||
@@ -117,18 +117,18 @@ pub mod api_rest;
|
|||||||
pub use client::{ClientBuilder, RadioFranceClient};
|
pub use client::{ClientBuilder, RadioFranceClient};
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
pub use models::{
|
pub use models::{
|
||||||
BroadcastType, CachedStationList, EmbedImage, ImageSize, Line, LiveResponse, LocalRadio, Media,
|
BroadcastType, EmbedImage, ImageSize, Line, LiveResponse, LocalRadio, Media, Release,
|
||||||
Release, ShowMetadata, Song, Station, StationType, StreamFormat, StreamSource, Visuals,
|
ShowMetadata, Song, Station, StreamFormat, StreamSource, Visuals,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(feature = "pmoconfig")]
|
#[cfg(feature = "pmoconfig")]
|
||||||
pub use config_ext::RadioFranceConfigExt;
|
pub use config_ext::RadioFranceConfigExt;
|
||||||
|
|
||||||
#[cfg(feature = "pmoconfig")]
|
#[cfg(feature = "pmoconfig")]
|
||||||
pub use stateful_client::RadioFranceStatefulClient;
|
pub use metadata_cache::{CachedMetadata, MetadataCache, MetadataUpdateCallback};
|
||||||
|
|
||||||
#[cfg(feature = "playlist")]
|
#[cfg(feature = "playlist")]
|
||||||
pub use playlist::{StationGroup, StationGroups, StationPlaylist};
|
pub use playlist::{StationGroup, StationGroups};
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub use source::RadioFranceSource;
|
pub use source::RadioFranceSource;
|
||||||
|
|||||||
607
pmoradiofrance/src/metadata_cache.rs
Normal file
607
pmoradiofrance/src/metadata_cache.rs
Normal file
@@ -0,0 +1,607 @@
|
|||||||
|
//! Cache de métadonnées pour Radio France avec TTL et système d'événements
|
||||||
|
//!
|
||||||
|
//! Ce module fournit un cache centralisé pour les métadonnées des stations Radio France:
|
||||||
|
//! - Cache in-memory avec TTL basé sur `end_time` de l'API
|
||||||
|
//! - Gestion automatique du cache de covers via pmocovers
|
||||||
|
//! - Système d'événements pour la synchronisation GENA
|
||||||
|
//! - Cache persistant des stations via pmoconfig
|
||||||
|
//!
|
||||||
|
//! # Architecture
|
||||||
|
//!
|
||||||
|
//! - `CachedMetadata` : Métadonnées simplifiées pour construire un DIDL
|
||||||
|
//! - `MetadataCache` : Gère le cache in-memory + cache persistant + événements
|
||||||
|
|
||||||
|
use crate::client::RadioFranceClient;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::models::{ImageSize, LiveResponse, Station};
|
||||||
|
use pmoconfig::Config;
|
||||||
|
use pmodidl::{Container, Item, Resource};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
#[cfg(feature = "cache")]
|
||||||
|
use pmocovers::Cache as CoverCache;
|
||||||
|
|
||||||
|
#[cfg(feature = "cache")]
|
||||||
|
use pmocache::cache_trait::FileCache;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CachedMetadata
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Métadonnées simplifiées pour construire un DIDL de playlist à un item
|
||||||
|
///
|
||||||
|
/// Contient UNIQUEMENT les données nécessaires pour remplir le DIDL.
|
||||||
|
/// Construit depuis `LiveResponse` avec les règles de mapping RF → UPnP.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CachedMetadata {
|
||||||
|
/// Slug de la station
|
||||||
|
pub slug: String,
|
||||||
|
|
||||||
|
// Champs pour le DIDL (playlist + item)
|
||||||
|
pub title: String,
|
||||||
|
pub creator: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub genre: Option<String>,
|
||||||
|
pub class: String,
|
||||||
|
pub album_art: Option<String>, // URL publique de la cover
|
||||||
|
pub album_art_pk: Option<String>, // PK dans pmocovers
|
||||||
|
|
||||||
|
// Resource (stream)
|
||||||
|
pub stream_url: String,
|
||||||
|
pub protocol_info: String,
|
||||||
|
pub sample_frequency: Option<String>,
|
||||||
|
pub nr_audio_channels: Option<String>,
|
||||||
|
pub duration: Option<String>, // Calculé depuis end_time
|
||||||
|
|
||||||
|
// TTL = end_time de l'API Radio France
|
||||||
|
pub end_time: Option<u64>, // Unix timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CachedMetadata {
|
||||||
|
/// Parse depuis LiveResponse avec gestion automatique du cache de covers
|
||||||
|
///
|
||||||
|
/// IMPORTANT: Préserve les règles de mapping RF → UPnP existantes:
|
||||||
|
/// - Radio musicale avec song → métadonnées du morceau
|
||||||
|
/// - Radio parlée → agrégation émission/producteur
|
||||||
|
/// - Évite duplications du nom de station
|
||||||
|
/// - Calcule duration depuis end_time
|
||||||
|
#[cfg(feature = "cache")]
|
||||||
|
pub async fn from_live_response(
|
||||||
|
station: &Station,
|
||||||
|
live: &LiveResponse,
|
||||||
|
cover_cache: &Arc<CoverCache>,
|
||||||
|
server_base_url: &str,
|
||||||
|
) -> Result<Self> {
|
||||||
|
// 1. Extraire les champs metadata avec les règles RF → UPnP
|
||||||
|
let (title, creator, artist, album, genre, class) =
|
||||||
|
Self::extract_metadata_fields(station, live);
|
||||||
|
|
||||||
|
// 2. Gestion de la cover avec cache
|
||||||
|
let (album_art, album_art_pk) = Self::cache_cover(live, cover_cache, server_base_url).await;
|
||||||
|
|
||||||
|
// 3. Construction de la ressource (stream)
|
||||||
|
let (stream_url, protocol_info, sample_frequency, nr_audio_channels, duration) =
|
||||||
|
Self::build_stream_resource(live, &station.slug, server_base_url);
|
||||||
|
|
||||||
|
// 4. TTL = end_time
|
||||||
|
let end_time = live.now.end_time;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
slug: station.slug.clone(),
|
||||||
|
title,
|
||||||
|
creator,
|
||||||
|
artist,
|
||||||
|
album,
|
||||||
|
genre,
|
||||||
|
class,
|
||||||
|
album_art,
|
||||||
|
album_art_pk,
|
||||||
|
stream_url,
|
||||||
|
protocol_info,
|
||||||
|
sample_frequency,
|
||||||
|
nr_audio_channels,
|
||||||
|
duration,
|
||||||
|
end_time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extrait les champs de métadonnées selon le type de radio
|
||||||
|
///
|
||||||
|
/// RÈGLES DE MAPPING (préservées du code existant):
|
||||||
|
/// - Radio musicale avec song → titre/artiste/album du morceau
|
||||||
|
/// - Radio parlée → agrégation émission/producteur
|
||||||
|
fn extract_metadata_fields(
|
||||||
|
station: &Station,
|
||||||
|
metadata: &LiveResponse,
|
||||||
|
) -> (
|
||||||
|
String,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
String,
|
||||||
|
) {
|
||||||
|
let now = &metadata.now;
|
||||||
|
|
||||||
|
// Détecter si c'est une radio musicale avec un morceau en cours
|
||||||
|
if let Some(ref song) = now.song {
|
||||||
|
// Radio musicale avec morceau
|
||||||
|
let title = now.first_line.title_or_default().to_string();
|
||||||
|
let song_artist = if song.interpreters.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(song.artists_display())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Artist affiché = "Station - Artiste du morceau" pour identifier la radio
|
||||||
|
// Éviter la duplication si l'artiste est égal au nom de la station
|
||||||
|
let artist = if let Some(ref art) = song_artist {
|
||||||
|
if art != &station.name {
|
||||||
|
Some(format!("{} - {}", station.name, art))
|
||||||
|
} else {
|
||||||
|
Some(station.name.clone())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Some(station.name.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
let album = song.release.title.clone();
|
||||||
|
let creator = song_artist; // Creator reste l'artiste du morceau
|
||||||
|
let genre = Some("Music".to_string());
|
||||||
|
let class = "object.item.audioItem.musicTrack".to_string();
|
||||||
|
|
||||||
|
(title, creator, artist, album, genre, class)
|
||||||
|
} else {
|
||||||
|
// Radio parlée ou segment talk sur radio musicale
|
||||||
|
let first = now.first_line.title_or_default();
|
||||||
|
let second = now.second_line.title_or_default();
|
||||||
|
|
||||||
|
// Construire le titre en évitant les duplications
|
||||||
|
let title = if !first.is_empty() && !second.is_empty() {
|
||||||
|
// Si first contient déjà second, utiliser seulement first
|
||||||
|
if first.contains(second) {
|
||||||
|
first.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{} • {}", first, second)
|
||||||
|
}
|
||||||
|
} else if !first.is_empty() {
|
||||||
|
first.to_string()
|
||||||
|
} else {
|
||||||
|
station.name.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Artist/Creator = "{Station} - {Subtitle}"
|
||||||
|
// Éviter la duplication si subtitle == nom de la station
|
||||||
|
let artist = if !second.is_empty() && second != station.name {
|
||||||
|
Some(format!("{} - {}", station.name, second))
|
||||||
|
} else {
|
||||||
|
Some(station.name.clone())
|
||||||
|
};
|
||||||
|
let creator = artist.clone();
|
||||||
|
|
||||||
|
// Album = nom de l'émission principale
|
||||||
|
let album = if !first.is_empty() {
|
||||||
|
Some(first.to_string())
|
||||||
|
} else {
|
||||||
|
Some(station.name.clone())
|
||||||
|
};
|
||||||
|
let genre = Some("Talk Radio".to_string());
|
||||||
|
let class = "object.item.audioItem.audioBroadcast".to_string();
|
||||||
|
|
||||||
|
(title, creator, artist, album, genre, class)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache la cover et retourne (url_publique, pk)
|
||||||
|
#[cfg(feature = "cache")]
|
||||||
|
async fn cache_cover(
|
||||||
|
metadata: &LiveResponse,
|
||||||
|
cache: &Arc<CoverCache>,
|
||||||
|
server_base_url: &str,
|
||||||
|
) -> (Option<String>, Option<String>) {
|
||||||
|
// Extraire l'UUID de la cover (priorité : visual_background > visuals.card > visuals.player)
|
||||||
|
let uuid = metadata
|
||||||
|
.now
|
||||||
|
.visual_background
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.extract_uuid())
|
||||||
|
.or_else(|| {
|
||||||
|
metadata.now.visuals.as_ref().and_then(|visuals| {
|
||||||
|
visuals
|
||||||
|
.card
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|c| c.extract_uuid())
|
||||||
|
.or_else(|| visuals.player.as_ref().and_then(|p| p.extract_uuid()))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let uuid = match uuid {
|
||||||
|
Some(u) => u,
|
||||||
|
None => {
|
||||||
|
// Fallback sur le logo par défaut via l'API REST
|
||||||
|
let logo_url = format!(
|
||||||
|
"{}/api/radiofrance/default-logo",
|
||||||
|
server_base_url.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
return (Some(logo_url), None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// URL haute résolution
|
||||||
|
let cover_url = ImageSize::Large.build_url(&uuid);
|
||||||
|
|
||||||
|
// Tenter de cacher la cover
|
||||||
|
match cache.add_from_url(&cover_url, Some("radiofrance")).await {
|
||||||
|
Ok(pk) => {
|
||||||
|
// Construire l'URL publique
|
||||||
|
let public_url = format!(
|
||||||
|
"{}{}",
|
||||||
|
server_base_url.trim_end_matches('/'),
|
||||||
|
cache.route_for(&pk, None)
|
||||||
|
);
|
||||||
|
|
||||||
|
(Some(public_url), Some(pk))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
#[cfg(feature = "logging")]
|
||||||
|
tracing::warn!("Failed to cache Radio France cover: {}", e);
|
||||||
|
// Fallback sur le logo par défaut en cas d'erreur
|
||||||
|
let logo_url = format!(
|
||||||
|
"{}/api/radiofrance/default-logo",
|
||||||
|
server_base_url.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
(Some(logo_url), None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construit la ressource stream avec URL du proxy
|
||||||
|
fn build_stream_resource(
|
||||||
|
metadata: &LiveResponse,
|
||||||
|
station_slug: &str,
|
||||||
|
server_base_url: &str,
|
||||||
|
) -> (
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
) {
|
||||||
|
// Calculer la durée restante (maintenant -> end_time)
|
||||||
|
let duration = if let Some(end) = metadata.now.end_time {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
if end > now {
|
||||||
|
let duration_secs = end - now;
|
||||||
|
let hours = duration_secs / 3600;
|
||||||
|
let minutes = (duration_secs % 3600) / 60;
|
||||||
|
let seconds = duration_secs % 60;
|
||||||
|
Some(format!("{}:{:02}:{:02}", hours, minutes, seconds))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// URL du proxy PMOMusic
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/radiofrance/{}/stream",
|
||||||
|
server_base_url.trim_end_matches('/'),
|
||||||
|
station_slug
|
||||||
|
);
|
||||||
|
|
||||||
|
// Déterminer le protocol_info et caractéristiques audio
|
||||||
|
let best_stream = metadata.now.media.best_hifi_stream();
|
||||||
|
|
||||||
|
let (protocol_info, sample_frequency, nr_audio_channels) = match best_stream {
|
||||||
|
Some(stream) => {
|
||||||
|
let protocol_info = match stream.format {
|
||||||
|
crate::models::StreamFormat::Aac => "http-get:*:audio/aac:*".to_string(),
|
||||||
|
crate::models::StreamFormat::Hls => {
|
||||||
|
"http-get:*:application/vnd.apple.mpegurl:*".to_string()
|
||||||
|
}
|
||||||
|
crate::models::StreamFormat::Mp3 => "http-get:*:audio/mpeg:*".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sample_freq = match stream.format {
|
||||||
|
crate::models::StreamFormat::Aac => Some("48000".to_string()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let channels = match stream.format {
|
||||||
|
crate::models::StreamFormat::Aac | crate::models::StreamFormat::Mp3 => {
|
||||||
|
Some("2".to_string())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
(protocol_info, sample_freq, channels)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Fallback
|
||||||
|
("http-get:*:audio/aac:*".to_string(), None, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
url,
|
||||||
|
protocol_info,
|
||||||
|
sample_frequency,
|
||||||
|
nr_audio_channels,
|
||||||
|
duration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construit un Container DIDL de playlist à un item
|
||||||
|
///
|
||||||
|
/// La playlist et l'item ont EXACTEMENT les mêmes métadonnées
|
||||||
|
pub fn to_didl(&self, playlist_id: &str, parent_id: &str) -> Container {
|
||||||
|
let item = Item {
|
||||||
|
id: format!("{}:stream", playlist_id),
|
||||||
|
parent_id: playlist_id.to_string(),
|
||||||
|
restricted: Some("1".to_string()),
|
||||||
|
title: self.title.clone(),
|
||||||
|
creator: self.creator.clone(),
|
||||||
|
class: self.class.clone(),
|
||||||
|
artist: self.artist.clone(),
|
||||||
|
album: self.album.clone(),
|
||||||
|
genre: self.genre.clone(),
|
||||||
|
album_art: self.album_art.clone(),
|
||||||
|
album_art_pk: self.album_art_pk.clone(),
|
||||||
|
date: None,
|
||||||
|
original_track_number: None,
|
||||||
|
resources: vec![Resource {
|
||||||
|
protocol_info: self.protocol_info.clone(),
|
||||||
|
bits_per_sample: None,
|
||||||
|
sample_frequency: self.sample_frequency.clone(),
|
||||||
|
nr_audio_channels: self.nr_audio_channels.clone(),
|
||||||
|
duration: self.duration.clone(),
|
||||||
|
url: self.stream_url.clone(),
|
||||||
|
}],
|
||||||
|
descriptions: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Container de playlist avec LES MÊMES métadonnées
|
||||||
|
Container {
|
||||||
|
id: playlist_id.to_string(),
|
||||||
|
parent_id: parent_id.to_string(),
|
||||||
|
restricted: Some("1".to_string()),
|
||||||
|
child_count: Some("1".to_string()),
|
||||||
|
searchable: Some("0".to_string()),
|
||||||
|
title: self.title.clone(),
|
||||||
|
class: "object.container.playlistContainer".to_string(),
|
||||||
|
artist: self.artist.clone(),
|
||||||
|
album_art: self.album_art.clone(),
|
||||||
|
containers: vec![],
|
||||||
|
items: vec![item],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si le TTL est dépassé
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
if let Some(end_time) = self.end_time {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
now >= end_time
|
||||||
|
} else {
|
||||||
|
// Pas de end_time = toujours expiré (refresh systématique)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MetadataCache
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Type de callback pour les notifications de mise à jour
|
||||||
|
pub type MetadataUpdateCallback = Arc<dyn Fn(&str) + Send + Sync>;
|
||||||
|
|
||||||
|
/// Cache de métadonnées avec TTL et système d'événements
|
||||||
|
///
|
||||||
|
/// Gère:
|
||||||
|
/// - Cache in-memory des métadonnées (HashMap avec TTL)
|
||||||
|
/// - Cache persistant des stations (via pmoconfig)
|
||||||
|
/// - Système d'événements (subscribe/notify)
|
||||||
|
pub struct MetadataCache {
|
||||||
|
/// Cache in-memory slug -> CachedMetadata
|
||||||
|
cache: Arc<RwLock<HashMap<String, CachedMetadata>>>,
|
||||||
|
/// Client HTTP Radio France
|
||||||
|
client: RadioFranceClient,
|
||||||
|
/// Cache de covers (pmocovers)
|
||||||
|
#[cfg(feature = "cache")]
|
||||||
|
cover_cache: Arc<CoverCache>,
|
||||||
|
/// URL de base du serveur
|
||||||
|
server_base_url: String,
|
||||||
|
/// Configuration (pour cache persistant des stations)
|
||||||
|
config: Arc<Config>,
|
||||||
|
/// Abonnés aux événements
|
||||||
|
subscribers: Arc<RwLock<Vec<MetadataUpdateCallback>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MetadataCache {
|
||||||
|
/// Constructeur avec tous les paramètres obligatoires
|
||||||
|
#[cfg(feature = "cache")]
|
||||||
|
pub fn new(
|
||||||
|
client: RadioFranceClient,
|
||||||
|
cover_cache: Arc<CoverCache>,
|
||||||
|
server_base_url: String,
|
||||||
|
config: Arc<Config>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
cache: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
client,
|
||||||
|
cover_cache,
|
||||||
|
server_base_url,
|
||||||
|
config,
|
||||||
|
subscribers: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les métadonnées (rafraîchit si TTL expiré)
|
||||||
|
///
|
||||||
|
/// # Logique
|
||||||
|
///
|
||||||
|
/// 1. Vérifie le cache in-memory
|
||||||
|
/// 2. Si valide, retourne directement
|
||||||
|
/// 3. Sinon, appelle API Radio France
|
||||||
|
/// 4. Met à jour le cache
|
||||||
|
/// 5. Notifie les abonnés
|
||||||
|
/// 6. Retourne les métadonnées
|
||||||
|
///
|
||||||
|
/// # Graceful degradation
|
||||||
|
///
|
||||||
|
/// Si l'API Radio France est down, retourne les données expirées du cache
|
||||||
|
#[cfg(feature = "cache")]
|
||||||
|
pub async fn get(&self, slug: &str) -> Result<CachedMetadata> {
|
||||||
|
// 1. Vérifie le cache
|
||||||
|
{
|
||||||
|
let cache = self.cache.read().await;
|
||||||
|
if let Some(metadata) = cache.get(slug) {
|
||||||
|
if !metadata.is_expired() {
|
||||||
|
return Ok(metadata.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. TTL expiré ou absent: appelle API Radio France
|
||||||
|
let live_response = match self.client.live_metadata(slug).await {
|
||||||
|
Ok(resp) => resp,
|
||||||
|
Err(e) => {
|
||||||
|
// Graceful degradation: retourner les données expirées si API down
|
||||||
|
let cache = self.cache.read().await;
|
||||||
|
if let Some(metadata) = cache.get(slug) {
|
||||||
|
#[cfg(feature = "logging")]
|
||||||
|
tracing::warn!(
|
||||||
|
"API Radio France down for {}, using expired cache: {}",
|
||||||
|
slug,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return Ok(metadata.clone());
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Parse LiveResponse -> CachedMetadata
|
||||||
|
let metadata = CachedMetadata::from_live_response(
|
||||||
|
&Station {
|
||||||
|
slug: slug.to_string(),
|
||||||
|
name: slug.to_string(),
|
||||||
|
},
|
||||||
|
&live_response,
|
||||||
|
&self.cover_cache,
|
||||||
|
&self.server_base_url,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 4. Met à jour le cache
|
||||||
|
{
|
||||||
|
let mut cache = self.cache.write().await;
|
||||||
|
cache.insert(slug.to_string(), metadata.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Notifie les abonnés (async)
|
||||||
|
self.notify_async(slug).await;
|
||||||
|
|
||||||
|
// 6. Retourne les métadonnées
|
||||||
|
Ok(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère la liste des stations (cache persistant via pmoconfig)
|
||||||
|
///
|
||||||
|
/// # Logique
|
||||||
|
///
|
||||||
|
/// 1. Essaie de lire depuis pmoconfig
|
||||||
|
/// 2. Si cache valide (TTL 1 semaine), retourne
|
||||||
|
/// 3. Sinon, découvre via API et met à jour pmoconfig
|
||||||
|
pub async fn get_stations(&self) -> Result<Vec<Station>> {
|
||||||
|
// TODO: Implémenter avec config_ext
|
||||||
|
// Pour l'instant, découvre directement
|
||||||
|
self.client.discover_all_stations().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère les métadonnées live brutes de l'API (sans cache)
|
||||||
|
///
|
||||||
|
/// Cette méthode est utilisée par l'API REST pour retourner
|
||||||
|
/// la réponse complète de l'API Radio France
|
||||||
|
pub async fn get_live_metadata(&self, slug: &str) -> Result<LiveResponse> {
|
||||||
|
self.client.live_metadata(slug).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère l'URL du stream HiFi pour une station
|
||||||
|
pub async fn get_stream_url(&self, slug: &str) -> Result<String> {
|
||||||
|
self.client.get_hifi_stream_url(slug).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// S'abonner aux changements de métadonnées
|
||||||
|
///
|
||||||
|
/// Le callback sera appelé avec le slug chaque fois que
|
||||||
|
/// les métadonnées de ce slug sont rafraîchies
|
||||||
|
pub fn subscribe(&self, callback: MetadataUpdateCallback) {
|
||||||
|
// Spawn une tâche pour éviter le blocking dans le runtime
|
||||||
|
let subscribers = self.subscribers.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut subs = subscribers.write().await;
|
||||||
|
subs.push(callback);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notifier tous les abonnés qu'un slug a été mis à jour (version async)
|
||||||
|
async fn notify_async(&self, slug: &str) {
|
||||||
|
// Clone pour éviter de bloquer longtemps
|
||||||
|
let callbacks: Vec<_> = {
|
||||||
|
let subscribers = self.subscribers.read().await;
|
||||||
|
subscribers.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
for callback in callbacks.iter() {
|
||||||
|
callback(slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cached_metadata_is_expired() {
|
||||||
|
let metadata = CachedMetadata {
|
||||||
|
slug: "test".to_string(),
|
||||||
|
title: "Test".to_string(),
|
||||||
|
creator: None,
|
||||||
|
artist: None,
|
||||||
|
album: None,
|
||||||
|
genre: None,
|
||||||
|
class: "test".to_string(),
|
||||||
|
album_art: None,
|
||||||
|
album_art_pk: None,
|
||||||
|
stream_url: "".to_string(),
|
||||||
|
protocol_info: "".to_string(),
|
||||||
|
sample_frequency: None,
|
||||||
|
nr_audio_channels: None,
|
||||||
|
duration: None,
|
||||||
|
end_time: Some(0), // Dans le passé
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(metadata.is_expired());
|
||||||
|
|
||||||
|
let metadata_no_end = CachedMetadata {
|
||||||
|
end_time: None,
|
||||||
|
..metadata
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(metadata_no_end.is_expired());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,97 +10,22 @@ use serde::{Deserialize, Serialize};
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// A discovered Radio France station
|
/// A discovered Radio France station
|
||||||
|
///
|
||||||
|
/// Simplifié: juste slug + name, plus de distinction de type
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct Station {
|
pub struct Station {
|
||||||
/// Unique slug identifier (e.g., "franceculture", "fip_rock")
|
/// Unique slug identifier (e.g., "franceculture", "fip_rock")
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
/// Human-readable name (e.g., "France Culture", "FIP Rock")
|
/// Human-readable name (e.g., "France Culture", "FIP Rock")
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Type of station
|
|
||||||
pub station_type: StationType,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Type of Radio France station
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub enum StationType {
|
|
||||||
/// Main station (France Inter, France Culture, FIP, etc.)
|
|
||||||
Main,
|
|
||||||
/// Webradio variant of a main station
|
|
||||||
Webradio {
|
|
||||||
/// Parent station slug (e.g., "fip" for "fip_rock")
|
|
||||||
parent_station: String,
|
|
||||||
},
|
|
||||||
/// Local France Bleu radio
|
|
||||||
LocalRadio {
|
|
||||||
/// Region name
|
|
||||||
region: String,
|
|
||||||
/// Internal Radio France ID
|
|
||||||
id: u32,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Station {
|
impl Station {
|
||||||
/// Create a new main station
|
/// Create a new station
|
||||||
pub fn main(slug: impl Into<String>, name: impl Into<String>) -> Self {
|
pub fn new(slug: impl Into<String>, name: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
slug: slug.into(),
|
slug: slug.into(),
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
station_type: StationType::Main,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new webradio station
|
|
||||||
pub fn webradio(
|
|
||||||
slug: impl Into<String>,
|
|
||||||
name: impl Into<String>,
|
|
||||||
parent: impl Into<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
slug: slug.into(),
|
|
||||||
name: name.into(),
|
|
||||||
station_type: StationType::Webradio {
|
|
||||||
parent_station: parent.into(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new local radio station
|
|
||||||
pub fn local_radio(
|
|
||||||
slug: impl Into<String>,
|
|
||||||
name: impl Into<String>,
|
|
||||||
region: impl Into<String>,
|
|
||||||
id: u32,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
slug: slug.into(),
|
|
||||||
name: name.into(),
|
|
||||||
station_type: StationType::LocalRadio {
|
|
||||||
region: region.into(),
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this is a main station
|
|
||||||
pub fn is_main(&self) -> bool {
|
|
||||||
matches!(self.station_type, StationType::Main)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this is a webradio
|
|
||||||
pub fn is_webradio(&self) -> bool {
|
|
||||||
matches!(self.station_type, StationType::Webradio { .. })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this is a local radio
|
|
||||||
pub fn is_local_radio(&self) -> bool {
|
|
||||||
matches!(self.station_type, StationType::LocalRadio { .. })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the parent station for webradios, or the station itself for main stations
|
|
||||||
pub fn base_station(&self) -> &str {
|
|
||||||
match &self.station_type {
|
|
||||||
StationType::Webradio { parent_station } => parent_station,
|
|
||||||
_ => &self.slug,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,86 +340,15 @@ impl ImageSize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Cached Station List
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Cached list of discovered stations with timestamp
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CachedStationList {
|
|
||||||
/// List of discovered stations
|
|
||||||
pub stations: Vec<Station>,
|
|
||||||
/// Unix timestamp when the list was last updated
|
|
||||||
pub last_updated: u64,
|
|
||||||
/// Version of the discovery algorithm (for invalidation)
|
|
||||||
pub version: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CachedStationList {
|
|
||||||
/// Current version of the discovery algorithm
|
|
||||||
pub const CURRENT_VERSION: u32 = 1;
|
|
||||||
|
|
||||||
/// Default TTL for station list cache (7 days in seconds)
|
|
||||||
pub const DEFAULT_TTL_SECS: u64 = 7 * 24 * 3600;
|
|
||||||
|
|
||||||
/// Create a new cached station list
|
|
||||||
pub fn new(stations: Vec<Station>) -> Self {
|
|
||||||
Self {
|
|
||||||
stations,
|
|
||||||
last_updated: std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs())
|
|
||||||
.unwrap_or(0),
|
|
||||||
version: Self::CURRENT_VERSION,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the cache is still valid
|
|
||||||
pub fn is_valid(&self, ttl_secs: u64) -> bool {
|
|
||||||
if self.version != Self::CURRENT_VERSION {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
now.saturating_sub(self.last_updated) < ttl_secs
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if cache is valid with default TTL
|
|
||||||
pub fn is_valid_default(&self) -> bool {
|
|
||||||
self.is_valid(Self::DEFAULT_TTL_SECS)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the age of the cache in seconds
|
|
||||||
pub fn age_secs(&self) -> u64 {
|
|
||||||
let now = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
now.saturating_sub(self.last_updated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_station_creation() {
|
fn test_station_creation() {
|
||||||
let main = Station::main("franceculture", "France Culture");
|
let station = Station::new("franceculture", "France Culture");
|
||||||
assert!(main.is_main());
|
assert_eq!(station.slug, "franceculture");
|
||||||
assert_eq!(main.base_station(), "franceculture");
|
assert_eq!(station.name, "France Culture");
|
||||||
|
|
||||||
let webradio = Station::webradio("fip_rock", "FIP Rock", "fip");
|
|
||||||
assert!(webradio.is_webradio());
|
|
||||||
assert_eq!(webradio.base_station(), "fip");
|
|
||||||
|
|
||||||
let local = Station::local_radio("francebleu_alsace", "ICI Alsace", "Alsace", 12);
|
|
||||||
assert!(local.is_local_radio());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -506,13 +360,4 @@ mod tests {
|
|||||||
"https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39/200x200"
|
"https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39/200x200"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_cached_station_list_validity() {
|
|
||||||
let stations = vec![Station::main("fip", "FIP")];
|
|
||||||
let cached = CachedStationList::new(stations);
|
|
||||||
|
|
||||||
assert!(cached.is_valid(3600)); // Valid for 1 hour
|
|
||||||
assert!(cached.is_valid_default()); // Valid with default TTL
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,704 +1,291 @@
|
|||||||
//! Structures et helpers pour la construction de playlists UPnP Radio France
|
//! Structures pour organiser les stations Radio France en groupes
|
||||||
//!
|
//!
|
||||||
//! Ce module fournit les structures nécessaires pour organiser les stations
|
//! Ce module fournit les structures nécessaires pour organiser les stations
|
||||||
//! Radio France en groupes hiérarchiques et construire des playlists UPnP
|
//! Radio France en groupes hiérarchiques et construire des containers DIDL.
|
||||||
//! avec métadonnées volatiles.
|
|
||||||
//!
|
//!
|
||||||
//! # Architecture
|
//! # Architecture
|
||||||
//!
|
//!
|
||||||
//! - `StationGroups` : Organisation hiérarchique de toutes les stations
|
//! Chaque niveau a deux méthodes :
|
||||||
//! - `StationGroup` : Groupe station principale + webradios associées
|
//! - `to_didl()` : Retourne le container COMPLET avec tout son contenu
|
||||||
//! - `StationPlaylist` : Playlist UPnP volatile pour une station
|
//! - `to_stub()` : Retourne juste les infos minimales pour apparaître dans la liste du parent
|
||||||
//!
|
//!
|
||||||
//! # Exemple
|
//! Règle : `to_didl()` du niveau N appelle `to_stub()` du niveau N+1
|
||||||
//!
|
|
||||||
//! ```rust,ignore
|
|
||||||
//! use pmoradiofrance::playlist::{StationGroups, StationPlaylist};
|
|
||||||
//!
|
|
||||||
//! // Organiser les stations en groupes
|
|
||||||
//! let groups = StationGroups::from_stations(stations);
|
|
||||||
//!
|
|
||||||
//! // Construire une playlist pour une station
|
|
||||||
//! let playlist = StationPlaylist::from_live_metadata(
|
|
||||||
//! station,
|
|
||||||
//! metadata,
|
|
||||||
//! &cover_cache,
|
|
||||||
//! server_base_url,
|
|
||||||
//! ).await?;
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{ImageSize, LiveResponse, Station, StationType, StreamFormat};
|
use crate::metadata_cache::MetadataCache;
|
||||||
use pmodidl::{Item, Resource};
|
use crate::models::Station;
|
||||||
use serde::{Deserialize, Serialize};
|
use pmodidl::Container;
|
||||||
|
|
||||||
#[cfg(feature = "cache")]
|
|
||||||
use pmocache::cache_trait::FileCache;
|
|
||||||
#[cfg(feature = "cache")]
|
|
||||||
use pmocovers::Cache as CoverCache;
|
|
||||||
#[cfg(feature = "cache")]
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Groupes de stations
|
// Niveau 0: StationGroups (racine "radiofrance")
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Groupes de stations organisés hiérarchiquement
|
/// Groupes de stations - Niveau 0
|
||||||
///
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
/// Cette structure organise les stations Radio France en trois catégories :
|
|
||||||
/// - `standalone` : Stations sans webradios (France Culture, France Inter, France Info, Mouv')
|
|
||||||
/// - `with_webradios` : Groupes avec station principale + webradios (FIP, France Musique)
|
|
||||||
/// - `local_radios` : Toutes les radios ICI (ex-France Bleu)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct StationGroups {
|
pub struct StationGroups {
|
||||||
/// Stations sans webradios associées
|
pub groups: Vec<StationGroup>,
|
||||||
pub standalone: Vec<Station>,
|
|
||||||
/// Groupes station principale + webradios
|
|
||||||
pub with_webradios: Vec<StationGroup>,
|
|
||||||
/// Radios locales ICI (ex-France Bleu)
|
|
||||||
pub local_radios: Vec<Station>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Groupe station principale + webradios associées
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct StationGroup {
|
|
||||||
/// Station principale (ex: FIP)
|
|
||||||
pub main: Station,
|
|
||||||
/// Webradios associées (ex: FIP Rock, FIP Jazz, ...)
|
|
||||||
pub webradios: Vec<Station>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StationGroups {
|
impl StationGroups {
|
||||||
/// Organise une liste de stations en groupes hiérarchiques
|
/// Organise une liste de stations en groupes
|
||||||
///
|
|
||||||
/// # Logique de regroupement
|
|
||||||
///
|
|
||||||
/// 1. Les stations locales (France Bleu/ICI) sont regroupées dans `local_radios`
|
|
||||||
/// 2. Les webradios sont associées à leur station parente
|
|
||||||
/// 3. Les stations principales sans webradios vont dans `standalone`
|
|
||||||
/// 4. Les stations avec au moins une webradio vont dans `with_webradios`
|
|
||||||
pub fn from_stations(stations: Vec<Station>) -> Self {
|
pub fn from_stations(stations: Vec<Station>) -> Self {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
let mut standalone = Vec::new();
|
let mut groups_map: HashMap<String, Vec<Station>> = HashMap::new();
|
||||||
let mut local_radios = Vec::new();
|
let mut ici_stations = Vec::new();
|
||||||
let mut main_stations: HashMap<String, Station> = HashMap::new();
|
|
||||||
let mut webradios_by_parent: HashMap<String, Vec<Station>> = HashMap::new();
|
|
||||||
|
|
||||||
// Premier passage : trier par type
|
|
||||||
for station in stations {
|
for station in stations {
|
||||||
match &station.station_type {
|
// Détecter les radios locales ICI (ex-France Bleu)
|
||||||
StationType::Main => {
|
if station.slug.starts_with("francebleu_") {
|
||||||
// Filtrer France Bleu : ce n'est pas une vraie radio mais le nom générique
|
ici_stations.push(station);
|
||||||
// pour toutes les radios locales ICI (ex-France Bleu)
|
continue;
|
||||||
if station.slug != "francebleu" {
|
|
||||||
main_stations.insert(station.slug.clone(), station);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
StationType::Webradio { parent_station } => {
|
|
||||||
webradios_by_parent
|
|
||||||
.entry(parent_station.clone())
|
|
||||||
.or_default()
|
|
||||||
.push(station);
|
|
||||||
}
|
|
||||||
StationType::LocalRadio { .. } => {
|
|
||||||
local_radios.push(station);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Deuxième passage : construire les groupes
|
// Filtrer "francebleu" générique (pas une vraie station)
|
||||||
let mut with_webradios = Vec::new();
|
if station.slug == "francebleu" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for (slug, main) in main_stations {
|
// Détecter le groupe de la station (par préfixe avant _)
|
||||||
if let Some(webradios) = webradios_by_parent.remove(&slug) {
|
let group_key = if let Some(pos) = station.slug.find('_') {
|
||||||
// Cette station a des webradios
|
station.slug[..pos].to_string()
|
||||||
with_webradios.push(StationGroup { main, webradios });
|
|
||||||
} else {
|
} else {
|
||||||
// Station standalone
|
station.slug.clone()
|
||||||
standalone.push(main);
|
};
|
||||||
}
|
|
||||||
|
groups_map.entry(group_key).or_default().push(station);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trier pour un affichage cohérent
|
// Construire les groupes
|
||||||
standalone.sort_by(|a, b| a.name.cmp(&b.name));
|
let mut groups: Vec<StationGroup> = groups_map
|
||||||
local_radios.sort_by(|a, b| a.name.cmp(&b.name));
|
.into_iter()
|
||||||
with_webradios.sort_by(|a, b| a.main.name.cmp(&b.main.name));
|
.map(|(group_key, mut stations)| {
|
||||||
|
// Trier : station principale (sans _) en premier
|
||||||
|
stations.sort_by_key(|s| {
|
||||||
|
if s.slug == group_key {
|
||||||
|
0 // Station principale en premier
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
for group in &mut with_webradios {
|
StationGroup { stations }
|
||||||
group.webradios.sort_by(|a, b| a.name.cmp(&b.name));
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Ajouter le groupe ICI si on a des radios locales
|
||||||
|
if !ici_stations.is_empty() {
|
||||||
|
ici_stations.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
// Créer une station virtuelle "ici" comme station principale
|
||||||
|
let ici_main = Station {
|
||||||
|
slug: "ici".to_string(),
|
||||||
|
name: "Radios ICI".to_string(),
|
||||||
|
};
|
||||||
|
let mut ici_group_stations = vec![ici_main];
|
||||||
|
ici_group_stations.extend(ici_stations);
|
||||||
|
groups.push(StationGroup {
|
||||||
|
stations: ici_group_stations,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Self {
|
// Trier les groupes par nom de la station principale
|
||||||
standalone,
|
groups.sort_by(|a, b| a.stations[0].name.cmp(&b.stations[0].name));
|
||||||
with_webradios,
|
|
||||||
local_radios,
|
Self { groups }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retourne toutes les stations dans un ordre de navigation logique
|
/// Niveau 0: to_didl() retourne le container "radiofrance" avec tous les groupes
|
||||||
///
|
///
|
||||||
/// Ordre : standalone, puis groupes (main + webradios), puis locales
|
/// Appelle to_stub() sur chaque StationGroup
|
||||||
pub fn all_stations(&self) -> impl Iterator<Item = &Station> {
|
pub async fn to_didl(
|
||||||
self.standalone
|
&self,
|
||||||
.iter()
|
metadata_cache: &MetadataCache,
|
||||||
.chain(
|
server_base_url: &str,
|
||||||
self.with_webradios
|
) -> Result<Container> {
|
||||||
.iter()
|
let mut containers = Vec::new();
|
||||||
.flat_map(|g| std::iter::once(&g.main).chain(g.webradios.iter())),
|
|
||||||
)
|
|
||||||
.chain(self.local_radios.iter())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Nombre total de stations
|
for group in &self.groups {
|
||||||
pub fn total_count(&self) -> usize {
|
let container = group.to_stub(metadata_cache, server_base_url).await?;
|
||||||
self.standalone.len()
|
containers.push(container);
|
||||||
+ self
|
}
|
||||||
.with_webradios
|
|
||||||
.iter()
|
Ok(Container {
|
||||||
.map(|g| 1 + g.webradios.len())
|
id: "radiofrance".to_string(),
|
||||||
.sum::<usize>()
|
parent_id: "0".to_string(),
|
||||||
+ self.local_radios.len()
|
restricted: Some("1".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: vec![],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Niveau 1: StationGroup (groupe de stations)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Groupe de stations - Niveau 1
|
||||||
|
///
|
||||||
|
/// Index 0 = station principale du groupe (ex: FIP pour le groupe FIP)
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct StationGroup {
|
||||||
|
pub stations: Vec<Station>,
|
||||||
|
}
|
||||||
|
|
||||||
impl StationGroup {
|
impl StationGroup {
|
||||||
/// Retourne toutes les stations du groupe (main + webradios)
|
/// Niveau 1: to_stub() retourne comment ce groupe apparaît dans la liste de StationGroups
|
||||||
pub fn all_stations(&self) -> impl Iterator<Item = &Station> {
|
|
||||||
std::iter::once(&self.main).chain(self.webradios.iter())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Nombre de stations dans le groupe
|
|
||||||
pub fn count(&self) -> usize {
|
|
||||||
1 + self.webradios.len()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Playlist UPnP pour une station
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Playlist UPnP volatile pour une station Radio France
|
|
||||||
///
|
|
||||||
/// Contient UN SEUL item représentant le stream live.
|
|
||||||
/// Les métadonnées de l'item changent au fil du temps (émissions, morceaux)
|
|
||||||
/// mais l'URL du stream reste identique.
|
|
||||||
///
|
|
||||||
/// # Volatilité
|
|
||||||
///
|
|
||||||
/// - L'URL du stream ne change JAMAIS
|
|
||||||
/// - Le titre, artiste, album changent toutes les 2-5 minutes
|
|
||||||
/// - La cover change avec chaque nouvelle émission/morceau
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct StationPlaylist {
|
|
||||||
/// ID de la playlist (ex: "radiofrance:franceculture")
|
|
||||||
pub id: String,
|
|
||||||
|
|
||||||
/// Station source
|
|
||||||
pub station: Station,
|
|
||||||
|
|
||||||
/// Item UPnP unique représentant le stream
|
|
||||||
pub stream_item: Item,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StationPlaylist {
|
|
||||||
/// Construit une playlist depuis les métadonnées live
|
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// - Si 1 station: retourne une playlist avec métadonnées (pour avoir titre/artiste à jour)
|
||||||
///
|
/// - Si plusieurs: retourne juste un container de groupe sans métadonnées
|
||||||
/// * `station` - Station Radio France
|
pub async fn to_stub(
|
||||||
/// * `metadata` - Métadonnées live de l'API
|
&self,
|
||||||
/// * `cover_cache` - Cache des covers (optionnel)
|
metadata_cache: &MetadataCache,
|
||||||
/// * `server_base_url` - URL de base du serveur pour les covers cachées
|
server_base_url: &str,
|
||||||
///
|
) -> Result<Container> {
|
||||||
/// # Mapping des métadonnées
|
if self.stations.len() == 1 {
|
||||||
///
|
// Groupe à 1 station : retourner la playlist avec métadonnées
|
||||||
/// Pour **radios parlées** (France Culture, France Inter, France Info) :
|
self.stations[0]
|
||||||
/// - `title` = émission + titre du jour
|
.to_stub(metadata_cache, server_base_url)
|
||||||
/// - `artist` = producteur
|
.await
|
||||||
/// - `album` = nom de l'émission
|
|
||||||
///
|
|
||||||
/// Construit un Item UPnP depuis les métadonnées live (avec cache)
|
|
||||||
#[cfg(feature = "cache")]
|
|
||||||
pub async fn build_item_from_metadata(
|
|
||||||
station: &Station,
|
|
||||||
metadata: &LiveResponse,
|
|
||||||
cover_cache: Option<&Arc<CoverCache>>,
|
|
||||||
server_base_url: Option<&str>,
|
|
||||||
) -> Result<Item> {
|
|
||||||
let (title, creator, artist, album, genre, class) =
|
|
||||||
Self::extract_metadata_fields(station, metadata);
|
|
||||||
|
|
||||||
// Gestion de la cover
|
|
||||||
let (album_art, album_art_pk) = if let Some(cache) = cover_cache {
|
|
||||||
Self::cache_cover(metadata, cache, server_base_url).await
|
|
||||||
} else {
|
} else {
|
||||||
Self::extract_cover_url(metadata, server_base_url)
|
// Groupe multi-stations : juste le nom, pas de métadonnées
|
||||||
};
|
let main_station = &self.stations[0];
|
||||||
|
let album_art = Some(format!(
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!(
|
|
||||||
"Cover for {}: album_art={:?}, album_art_pk={:?}",
|
|
||||||
station.slug,
|
|
||||||
album_art,
|
|
||||||
album_art_pk
|
|
||||||
);
|
|
||||||
|
|
||||||
// Construction de la ressource (stream)
|
|
||||||
let resource = Self::build_stream_resource(metadata, &station.slug, server_base_url);
|
|
||||||
|
|
||||||
let item = Item {
|
|
||||||
id: format!("radiofrance:{}:stream", station.slug),
|
|
||||||
parent_id: format!("radiofrance:{}", station.slug),
|
|
||||||
restricted: Some("1".to_string()),
|
|
||||||
title,
|
|
||||||
creator,
|
|
||||||
class,
|
|
||||||
artist,
|
|
||||||
album,
|
|
||||||
genre,
|
|
||||||
album_art,
|
|
||||||
album_art_pk,
|
|
||||||
date: None,
|
|
||||||
original_track_number: None,
|
|
||||||
resources: vec![resource],
|
|
||||||
descriptions: vec![],
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
if let Some(res) = item.resources.first() {
|
|
||||||
tracing::info!(
|
|
||||||
"Item built for {}: title='{}', duration={:?}",
|
|
||||||
station.slug,
|
|
||||||
item.title,
|
|
||||||
res.duration
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construit un Item UPnP depuis les métadonnées live (sans cache async)
|
|
||||||
pub fn build_item_from_metadata_sync(
|
|
||||||
station: &Station,
|
|
||||||
metadata: &LiveResponse,
|
|
||||||
server_base_url: Option<&str>,
|
|
||||||
) -> Result<Item> {
|
|
||||||
let (title, creator, artist, album, genre, class) =
|
|
||||||
Self::extract_metadata_fields(station, metadata);
|
|
||||||
|
|
||||||
let (album_art, album_art_pk) = Self::extract_cover_url(metadata, server_base_url);
|
|
||||||
let resource = Self::build_stream_resource(metadata, &station.slug, server_base_url);
|
|
||||||
|
|
||||||
Ok(Item {
|
|
||||||
id: format!("radiofrance:{}:stream", station.slug),
|
|
||||||
parent_id: format!("radiofrance:{}", station.slug),
|
|
||||||
restricted: Some("1".to_string()),
|
|
||||||
title,
|
|
||||||
creator,
|
|
||||||
class,
|
|
||||||
artist,
|
|
||||||
album,
|
|
||||||
genre,
|
|
||||||
album_art,
|
|
||||||
album_art_pk,
|
|
||||||
date: None,
|
|
||||||
original_track_number: None,
|
|
||||||
resources: vec![resource],
|
|
||||||
descriptions: vec![],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extrait les champs de métadonnées selon le type de radio
|
|
||||||
fn extract_metadata_fields(
|
|
||||||
station: &Station,
|
|
||||||
metadata: &LiveResponse,
|
|
||||||
) -> (
|
|
||||||
String,
|
|
||||||
Option<String>,
|
|
||||||
Option<String>,
|
|
||||||
Option<String>,
|
|
||||||
Option<String>,
|
|
||||||
String,
|
|
||||||
) {
|
|
||||||
let now = &metadata.now;
|
|
||||||
|
|
||||||
// Détecter si c'est une radio musicale avec un morceau en cours
|
|
||||||
if let Some(ref song) = now.song {
|
|
||||||
// Radio musicale avec morceau
|
|
||||||
let title = now.first_line.title_or_default().to_string();
|
|
||||||
let song_artist = if song.interpreters.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(song.artists_display())
|
|
||||||
};
|
|
||||||
|
|
||||||
// Artist affiché = "Station - Artiste du morceau" pour identifier la radio
|
|
||||||
// Éviter la duplication si l'artiste est égal au nom de la station
|
|
||||||
let artist = if let Some(ref art) = song_artist {
|
|
||||||
if art != &station.name && art != station.display_name() {
|
|
||||||
Some(format!("{} - {}", station.display_name(), art))
|
|
||||||
} else {
|
|
||||||
Some(station.display_name().to_string())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Some(station.display_name().to_string())
|
|
||||||
};
|
|
||||||
|
|
||||||
let album = song.release.title.clone();
|
|
||||||
let creator = song_artist; // Creator reste l'artiste du morceau pour compatibilité
|
|
||||||
let genre = Some("Music".to_string());
|
|
||||||
let class = "object.item.audioItem.musicTrack".to_string();
|
|
||||||
|
|
||||||
(title, creator, artist, album, genre, class)
|
|
||||||
} else {
|
|
||||||
// Radio parlée ou segment talk sur radio musicale
|
|
||||||
let first = now.first_line.title_or_default();
|
|
||||||
let second = now.second_line.title_or_default();
|
|
||||||
|
|
||||||
// Construire le titre en évitant les duplications
|
|
||||||
let title = if !first.is_empty() && !second.is_empty() {
|
|
||||||
// Si first contient déjà second, utiliser seulement first
|
|
||||||
if first.contains(second) {
|
|
||||||
first.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{} • {}", first, second)
|
|
||||||
}
|
|
||||||
} else if !first.is_empty() {
|
|
||||||
first.to_string()
|
|
||||||
} else {
|
|
||||||
station.display_name().to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Artist/Creator = "{Station} - {Subtitle}"
|
|
||||||
// Éviter la duplication si subtitle == nom de la station
|
|
||||||
let artist =
|
|
||||||
if !second.is_empty() && second != station.name && second != station.display_name()
|
|
||||||
{
|
|
||||||
Some(format!("{} - {}", station.name, second))
|
|
||||||
} else {
|
|
||||||
Some(station.name.clone())
|
|
||||||
};
|
|
||||||
let creator = artist.clone();
|
|
||||||
// Album = nom de l'émission principale
|
|
||||||
let album = if !first.is_empty() {
|
|
||||||
Some(first.to_string())
|
|
||||||
} else {
|
|
||||||
Some(station.name.clone())
|
|
||||||
};
|
|
||||||
let genre = Some("Talk Radio".to_string());
|
|
||||||
let class = "object.item.audioItem.audioBroadcast".to_string();
|
|
||||||
|
|
||||||
(title, creator, artist, album, genre, class)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extrait l'URL de cover depuis les métadonnées (sans cache)
|
|
||||||
fn extract_cover_url(
|
|
||||||
metadata: &LiveResponse,
|
|
||||||
server_base_url: Option<&str>,
|
|
||||||
) -> (Option<String>, Option<String>) {
|
|
||||||
// Priorité : visual_background > visuals.card > visuals.player > logo par défaut
|
|
||||||
|
|
||||||
// 1. visual_background
|
|
||||||
if let Some(ref visual) = metadata.now.visual_background {
|
|
||||||
if let Some(uuid) = visual.extract_uuid() {
|
|
||||||
let url = ImageSize::Large.build_url(&uuid);
|
|
||||||
return (Some(url), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. visuals.card
|
|
||||||
if let Some(ref visuals) = metadata.now.visuals {
|
|
||||||
if let Some(ref card) = visuals.card {
|
|
||||||
if let Some(uuid) = card.extract_uuid() {
|
|
||||||
let url = ImageSize::Large.build_url(&uuid);
|
|
||||||
return (Some(url), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. visuals.player
|
|
||||||
if let Some(ref player) = visuals.player {
|
|
||||||
if let Some(uuid) = player.extract_uuid() {
|
|
||||||
let url = ImageSize::Large.build_url(&uuid);
|
|
||||||
return (Some(url), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback sur le logo par défaut via l'API REST
|
|
||||||
if let Some(base) = server_base_url {
|
|
||||||
let logo_url = format!(
|
|
||||||
"{}/api/radiofrance/default-logo",
|
"{}/api/radiofrance/default-logo",
|
||||||
base.trim_end_matches('/')
|
server_base_url.trim_end_matches('/')
|
||||||
);
|
));
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!("Using default Radio France logo: {}", logo_url);
|
|
||||||
return (Some(logo_url), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pas de cover trouvée et pas de serveur configuré
|
Ok(Container {
|
||||||
#[cfg(feature = "logging")]
|
id: format!("radiofrance:group:{}", main_station.slug),
|
||||||
tracing::warn!("No cover found and no server_base_url configured");
|
parent_id: "radiofrance".to_string(),
|
||||||
(None, None)
|
restricted: Some("1".to_string()),
|
||||||
}
|
child_count: Some(self.stations.len().saturating_sub(1).to_string()),
|
||||||
|
searchable: Some("0".to_string()),
|
||||||
/// Cache la cover et retourne (url_publique, pk)
|
title: main_station.name.clone(),
|
||||||
#[cfg(feature = "cache")]
|
class: "object.container".to_string(),
|
||||||
async fn cache_cover(
|
artist: None,
|
||||||
metadata: &LiveResponse,
|
album_art,
|
||||||
cache: &Arc<CoverCache>,
|
containers: vec![],
|
||||||
server_base_url: Option<&str>,
|
items: vec![],
|
||||||
) -> (Option<String>, Option<String>) {
|
})
|
||||||
// Extraire l'UUID de la cover (priorité : visual_background > visuals.card > visuals.player)
|
|
||||||
let uuid = metadata
|
|
||||||
.now
|
|
||||||
.visual_background
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|v| v.extract_uuid())
|
|
||||||
.or_else(|| {
|
|
||||||
metadata.now.visuals.as_ref().and_then(|visuals| {
|
|
||||||
visuals
|
|
||||||
.card
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|c| c.extract_uuid())
|
|
||||||
.or_else(|| visuals.player.as_ref().and_then(|p| p.extract_uuid()))
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
let uuid = match uuid {
|
|
||||||
Some(u) => u,
|
|
||||||
None => {
|
|
||||||
// Fallback sur le logo par défaut via l'API REST
|
|
||||||
if let Some(base) = server_base_url {
|
|
||||||
let logo_url = format!(
|
|
||||||
"{}/api/radiofrance/default-logo",
|
|
||||||
base.trim_end_matches('/')
|
|
||||||
);
|
|
||||||
return (Some(logo_url), None);
|
|
||||||
}
|
|
||||||
return (None, None);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// URL haute résolution
|
|
||||||
let cover_url = ImageSize::Large.build_url(&uuid);
|
|
||||||
|
|
||||||
// Tenter de cacher la cover
|
|
||||||
match cache.add_from_url(&cover_url, Some("radiofrance")).await {
|
|
||||||
Ok(pk) => {
|
|
||||||
// Construire l'URL publique si server_base_url est fourni
|
|
||||||
let public_url = server_base_url.map(|base| {
|
|
||||||
format!(
|
|
||||||
"{}{}",
|
|
||||||
base.trim_end_matches('/'),
|
|
||||||
cache.route_for(&pk, None)
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
(public_url.or(Some(cover_url)), Some(pk))
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Failed to cache Radio France cover: {}", e);
|
|
||||||
// Fallback sur le logo par défaut via l'API REST en cas d'erreur
|
|
||||||
if let Some(base) = server_base_url {
|
|
||||||
let logo_url = format!(
|
|
||||||
"{}/api/radiofrance/default-logo",
|
|
||||||
base.trim_end_matches('/')
|
|
||||||
);
|
|
||||||
(Some(logo_url), None)
|
|
||||||
} else {
|
|
||||||
(Some(cover_url), None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construit la ressource stream avec URL du proxy
|
/// Niveau 1: to_didl() retourne le container du groupe avec TOUT son contenu
|
||||||
fn build_stream_resource(
|
///
|
||||||
metadata: &LiveResponse,
|
/// - Si 1 station: retourne la playlist complète avec l'item stream
|
||||||
station_slug: &str,
|
/// - Si plusieurs: retourne le container avec toutes les playlists des webradios en stub
|
||||||
server_base_url: Option<&str>,
|
pub async fn to_didl(
|
||||||
) -> Resource {
|
&self,
|
||||||
// Calculer la durée restante (maintenant -> end_time)
|
metadata_cache: &MetadataCache,
|
||||||
// Cela permet au curseur de progresser de 0 jusqu'à la fin de l'émission
|
server_base_url: &str,
|
||||||
let duration = if let Some(end) = metadata.now.end_time {
|
) -> Result<Container> {
|
||||||
let now = std::time::SystemTime::now()
|
if self.stations.len() == 1 {
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
// Groupe à 1 station : retourner la playlist complète
|
||||||
.unwrap()
|
self.stations[0]
|
||||||
.as_secs();
|
.to_didl(metadata_cache, server_base_url)
|
||||||
|
.await
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!(
|
|
||||||
"Calculating duration: end_time={}, now={}, diff={}",
|
|
||||||
end,
|
|
||||||
now,
|
|
||||||
end.saturating_sub(now)
|
|
||||||
);
|
|
||||||
|
|
||||||
if end > now {
|
|
||||||
let duration_secs = end - now;
|
|
||||||
// Format UPnP: H:MM:SS ou H:MM:SS.F
|
|
||||||
let hours = duration_secs / 3600;
|
|
||||||
let minutes = (duration_secs % 3600) / 60;
|
|
||||||
let seconds = duration_secs % 60;
|
|
||||||
let duration_str = format!("{}:{:02}:{:02}", hours, minutes, seconds);
|
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::info!("Track duration set to: {}", duration_str);
|
|
||||||
|
|
||||||
Some(duration_str)
|
|
||||||
} else {
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::warn!("end_time ({}) is in the past (now={})", end, now);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
#[cfg(feature = "logging")]
|
// Groupe multi-stations : retourner un container avec les playlists en stub
|
||||||
tracing::debug!("No end_time available in metadata");
|
let main_station = &self.stations[0];
|
||||||
None
|
let mut containers = Vec::new();
|
||||||
};
|
|
||||||
|
|
||||||
// Construire l'URL du proxy ou fallback direct
|
for station in &self.stations[1..] {
|
||||||
let url = if let Some(base_url) = server_base_url {
|
// Appeler to_stub() sur chaque station
|
||||||
// Utiliser le proxy PMOMusic pour détecter quand le stream est actif
|
let playlist_stub = station.to_stub(metadata_cache, server_base_url).await?;
|
||||||
format!("{}/api/radiofrance/{}/stream", base_url, station_slug)
|
containers.push(playlist_stub);
|
||||||
} else {
|
|
||||||
// Fallback : utiliser l'URL directe si pas de base_url
|
|
||||||
metadata
|
|
||||||
.now
|
|
||||||
.media
|
|
||||||
.best_hifi_stream()
|
|
||||||
.map(|s| s.url.clone())
|
|
||||||
.unwrap_or_default()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Déterminer le protocol_info et caractéristiques audio
|
|
||||||
let best_stream = metadata.now.media.best_hifi_stream();
|
|
||||||
|
|
||||||
let (protocol_info, sample_frequency, nr_audio_channels) = match best_stream {
|
|
||||||
Some(stream) => {
|
|
||||||
let protocol_info = match stream.format {
|
|
||||||
StreamFormat::Aac => "http-get:*:audio/aac:*".to_string(),
|
|
||||||
StreamFormat::Hls => "http-get:*:application/vnd.apple.mpegurl:*".to_string(),
|
|
||||||
StreamFormat::Mp3 => "http-get:*:audio/mpeg:*".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let sample_freq = match stream.format {
|
|
||||||
StreamFormat::Aac => Some("48000".to_string()),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let channels = match stream.format {
|
|
||||||
StreamFormat::Aac | StreamFormat::Mp3 => Some("2".to_string()),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
(protocol_info, sample_freq, channels)
|
|
||||||
}
|
}
|
||||||
None => {
|
|
||||||
// Fallback : pas de stream trouvé
|
|
||||||
("http-get:*:audio/aac:*".to_string(), None, None)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let resource = Resource {
|
let album_art = Some(format!(
|
||||||
protocol_info,
|
"{}/api/radiofrance/default-logo",
|
||||||
bits_per_sample: None,
|
server_base_url.trim_end_matches('/')
|
||||||
sample_frequency,
|
));
|
||||||
nr_audio_channels,
|
|
||||||
duration: duration.clone(), // Durée calculée depuis start_time/end_time si disponible
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
Ok(Container {
|
||||||
tracing::info!(
|
id: format!("radiofrance:group:{}", main_station.slug),
|
||||||
"Built resource with duration: {:?}, url: {}",
|
parent_id: "radiofrance".to_string(),
|
||||||
resource.duration,
|
restricted: Some("1".to_string()),
|
||||||
if resource.url.is_empty() {
|
child_count: Some(containers.len().to_string()),
|
||||||
"<empty>"
|
searchable: Some("0".to_string()),
|
||||||
} else {
|
title: main_station.name.clone(),
|
||||||
&resource.url[..resource.url.len().min(50)]
|
class: "object.container".to_string(),
|
||||||
}
|
artist: None,
|
||||||
);
|
album_art,
|
||||||
|
containers,
|
||||||
resource
|
items: vec![],
|
||||||
}
|
})
|
||||||
|
}
|
||||||
/// Retourne l'URL du stream
|
|
||||||
pub fn stream_url(&self) -> Option<&str> {
|
|
||||||
self.stream_item.resources.first().map(|r| r.url.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retourne le titre actuel
|
|
||||||
pub fn current_title(&self) -> &str {
|
|
||||||
&self.stream_item.title
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retourne l'artiste actuel
|
|
||||||
pub fn current_artist(&self) -> Option<&str> {
|
|
||||||
self.stream_item.artist.as_deref()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Helpers pour le renommage France Bleu → ICI
|
// Niveau 2: Station (playlist singleton)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
impl Station {
|
impl Station {
|
||||||
/// Retourne le nom d'affichage avec renommage France Bleu → ICI
|
/// Niveau 2: to_stub() retourne comment cette station apparaît dans la liste d'un groupe
|
||||||
///
|
///
|
||||||
/// Les slugs sont conservés (francebleu_alsace) mais l'affichage
|
/// Retourne un container de playlist vide avec métadonnées live
|
||||||
/// utilise "ICI" (ICI Alsace).
|
pub async fn to_stub(
|
||||||
pub fn display_name(&self) -> &str {
|
&self,
|
||||||
// Le renommage est déjà fait lors de la découverte via l'API
|
metadata_cache: &MetadataCache,
|
||||||
// qui retourne directement "ICI Alsace" etc.
|
_server_base_url: &str,
|
||||||
&self.name
|
) -> Result<Container> {
|
||||||
|
// Récupérer les métadonnées du cache
|
||||||
|
let cached_metadata = metadata_cache.get(&self.slug).await?;
|
||||||
|
|
||||||
|
// Construire juste le container de playlist (sans l'item stream)
|
||||||
|
let playlist_id = format!("radiofrance:{}", self.slug);
|
||||||
|
let parent_id = self.compute_parent_id();
|
||||||
|
|
||||||
|
Ok(Container {
|
||||||
|
id: playlist_id,
|
||||||
|
parent_id,
|
||||||
|
restricted: Some("1".to_string()),
|
||||||
|
child_count: Some("1".to_string()),
|
||||||
|
searchable: Some("0".to_string()),
|
||||||
|
title: cached_metadata.title.clone(),
|
||||||
|
class: "object.container.playlistContainer".to_string(),
|
||||||
|
artist: cached_metadata.artist.clone(),
|
||||||
|
album_art: cached_metadata.album_art.clone(),
|
||||||
|
containers: vec![],
|
||||||
|
items: vec![],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vérifie si c'est une radio ICI (ex-France Bleu locale)
|
/// Niveau 2: to_didl() retourne la playlist complète avec l'item stream
|
||||||
pub fn is_ici_radio(&self) -> bool {
|
///
|
||||||
self.name.starts_with("ICI ") || self.slug.starts_with("francebleu_")
|
/// Retourne un container de playlist avec 1 item stream dedans
|
||||||
}
|
pub async fn to_didl(
|
||||||
}
|
&self,
|
||||||
|
metadata_cache: &MetadataCache,
|
||||||
#[cfg(test)]
|
_server_base_url: &str,
|
||||||
mod tests {
|
) -> Result<Container> {
|
||||||
use super::*;
|
// Récupérer les métadonnées du cache
|
||||||
|
let cached_metadata = metadata_cache.get(&self.slug).await?;
|
||||||
#[test]
|
|
||||||
fn test_station_groups_organization() {
|
// Construire le container de playlist avec l'item via CachedMetadata::to_didl()
|
||||||
let stations = vec![
|
let playlist_id = format!("radiofrance:{}", self.slug);
|
||||||
Station::main("franceculture", "France Culture"),
|
let parent_id = self.compute_parent_id();
|
||||||
Station::main("fip", "FIP"),
|
|
||||||
Station::webradio("fip_rock", "FIP Rock", "fip"),
|
Ok(cached_metadata.to_didl(&playlist_id, &parent_id))
|
||||||
Station::webradio("fip_jazz", "FIP Jazz", "fip"),
|
}
|
||||||
Station::local_radio("francebleu_alsace", "ICI Alsace", "Alsace", 1),
|
|
||||||
];
|
/// Calcule le parent_id selon la position de la station
|
||||||
|
fn compute_parent_id(&self) -> String {
|
||||||
let groups = StationGroups::from_stations(stations);
|
if self.slug == "ici" {
|
||||||
|
"radiofrance".to_string()
|
||||||
assert_eq!(groups.standalone.len(), 1);
|
} else if let Some(pos) = self.slug.find('_') {
|
||||||
assert_eq!(groups.standalone[0].slug, "franceculture");
|
// Webradio : parent = groupe
|
||||||
|
format!("radiofrance:group:{}", &self.slug[..pos])
|
||||||
assert_eq!(groups.with_webradios.len(), 1);
|
} else {
|
||||||
assert_eq!(groups.with_webradios[0].main.slug, "fip");
|
// Station principale : parent = racine
|
||||||
assert_eq!(groups.with_webradios[0].webradios.len(), 2);
|
"radiofrance".to_string()
|
||||||
|
}
|
||||||
assert_eq!(groups.local_radios.len(), 1);
|
|
||||||
assert_eq!(groups.local_radios[0].slug, "francebleu_alsace");
|
|
||||||
|
|
||||||
assert_eq!(groups.total_count(), 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_station_display_name() {
|
|
||||||
let station = Station::local_radio("francebleu_alsace", "ICI Alsace", "Alsace", 1);
|
|
||||||
assert_eq!(station.display_name(), "ICI Alsace");
|
|
||||||
assert!(station.is_ici_radio());
|
|
||||||
|
|
||||||
let main = Station::main("franceculture", "France Culture");
|
|
||||||
assert_eq!(main.display_name(), "France Culture");
|
|
||||||
assert!(!main.is_ici_radio());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::stateful_client::RadioFranceStatefulClient;
|
|
||||||
|
|
||||||
/// État partagé pour les handlers Radio France
|
/// État partagé pour les handlers Radio France
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RadioFranceState {
|
pub struct RadioFranceState {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@
|
|||||||
|
|
||||||
use crate::api_rest::create_router;
|
use crate::api_rest::create_router;
|
||||||
use crate::pmoserver_ext::{RadioFranceExt, RadioFranceState};
|
use crate::pmoserver_ext::{RadioFranceExt, RadioFranceState};
|
||||||
use crate::stateful_client::RadioFranceStatefulClient;
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use pmoserver::Server;
|
use pmoserver::Server;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
@@ -3,21 +3,16 @@
|
|||||||
//! This module implements the `MusicSource` trait from `pmosource` for Radio France,
|
//! This module implements the `MusicSource` trait from `pmosource` for Radio France,
|
||||||
//! providing UPnP/DLNA integration with dynamic container generation.
|
//! providing UPnP/DLNA integration with dynamic container generation.
|
||||||
|
|
||||||
|
use crate::client::RadioFranceClient;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{Station, StationType};
|
use crate::metadata_cache::MetadataCache;
|
||||||
use crate::playlist::{StationGroup, StationGroups, StationPlaylist};
|
use crate::playlist::StationGroups;
|
||||||
use crate::stateful_client::RadioFranceStatefulClient;
|
|
||||||
use pmoconfig::Config;
|
use pmoconfig::Config;
|
||||||
use pmodidl::{Container, Item};
|
use pmodidl::{Container, Item};
|
||||||
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, SourceCapabilities};
|
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, SourceCapabilities};
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
|
|
||||||
#[cfg(feature = "cache")]
|
|
||||||
use pmocovers::Cache as CoverCache;
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use pmoupnp;
|
use pmoupnp;
|
||||||
@@ -28,22 +23,15 @@ pub const RADIOFRANCE_DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/radiofran
|
|||||||
/// Radio France music source
|
/// Radio France music source
|
||||||
///
|
///
|
||||||
/// Provides access to ~70 Radio France stations via UPnP/DLNA with:
|
/// Provides access to ~70 Radio France stations via UPnP/DLNA with:
|
||||||
/// - Dynamic container generation based on station structure
|
/// - Cache de métadonnées avec TTL et système d'événements
|
||||||
/// - Automatic metadata refresh for active streams
|
/// - Construction dynamique de containers DIDL
|
||||||
/// - Hierarchical organization (standalone, groups, local radios)
|
/// - Notifications GENA pour les playlists
|
||||||
pub struct RadioFranceSource {
|
pub struct RadioFranceSource {
|
||||||
/// Stateful client with automatic caching
|
/// Cache de métadonnées centralisé
|
||||||
pub(crate) client: RadioFranceStatefulClient,
|
metadata_cache: Arc<MetadataCache>,
|
||||||
|
|
||||||
/// Background tasks for metadata refresh
|
/// Server base URL
|
||||||
refresh_handles: Arc<RwLock<HashMap<String, JoinHandle<()>>>>,
|
server_base_url: String,
|
||||||
|
|
||||||
/// Cover cache (optional)
|
|
||||||
#[cfg(feature = "cache")]
|
|
||||||
cover_cache: Option<Arc<CoverCache>>,
|
|
||||||
|
|
||||||
/// Server base URL for cover URLs
|
|
||||||
server_base_url: Option<String>,
|
|
||||||
|
|
||||||
/// Update counter for change tracking
|
/// Update counter for change tracking
|
||||||
update_id: Arc<RwLock<u32>>,
|
update_id: Arc<RwLock<u32>>,
|
||||||
@@ -60,7 +48,7 @@ impl RadioFranceSource {
|
|||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
/// * `config` - Configuration for the client
|
/// * `config` - Configuration for cache and discovery
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
///
|
///
|
||||||
@@ -75,47 +63,33 @@ impl RadioFranceSource {
|
|||||||
/// Ok(())
|
/// Ok(())
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
#[cfg(feature = "cache")]
|
||||||
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 = RadioFranceClient::new().await?;
|
||||||
|
let cover_cache = pmoupnp::cache_registry::get_cover_cache()
|
||||||
|
.ok_or_else(|| crate::error::Error::Other("Cover cache not initialized".to_string()))?;
|
||||||
|
|
||||||
|
// TODO: Récupérer server_base_url depuis config
|
||||||
|
let server_base_url = "http://localhost:8080".to_string();
|
||||||
|
|
||||||
|
let metadata_cache = Arc::new(MetadataCache::new(
|
||||||
|
client,
|
||||||
|
cover_cache,
|
||||||
|
server_base_url.clone(),
|
||||||
|
config,
|
||||||
|
));
|
||||||
|
|
||||||
|
let update_id = Arc::new(RwLock::new(0));
|
||||||
|
let last_change = Arc::new(RwLock::new(None));
|
||||||
|
|
||||||
let source = Self {
|
let source = Self {
|
||||||
client,
|
metadata_cache,
|
||||||
refresh_handles: Arc::new(RwLock::new(HashMap::new())),
|
server_base_url,
|
||||||
#[cfg(feature = "cache")]
|
update_id,
|
||||||
cover_cache: None,
|
last_change,
|
||||||
server_base_url: None,
|
|
||||||
update_id: Arc::new(RwLock::new(0)),
|
|
||||||
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)
|
Ok(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,336 +98,86 @@ impl RadioFranceSource {
|
|||||||
mut self,
|
mut self,
|
||||||
notifier: Arc<dyn Fn(&[String]) + Send + Sync + 'static>,
|
notifier: Arc<dyn Fn(&[String]) + Send + Sync + 'static>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
// S'abonner aux événements du cache de métadonnées
|
||||||
|
let container_notifier = Arc::new(notifier.clone());
|
||||||
|
let update_id = self.update_id.clone();
|
||||||
|
let last_change = self.last_change.clone();
|
||||||
|
|
||||||
|
self.metadata_cache.subscribe(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());
|
||||||
|
|
||||||
|
// Notifier le container de playlist (pas l'item)
|
||||||
|
container_notifier(&[format!("radiofrance:{}", slug)]);
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
self.container_notifier = Some(notifier);
|
self.container_notifier = Some(notifier);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the cover cache
|
|
||||||
#[cfg(feature = "cache")]
|
|
||||||
pub fn with_cover_cache(mut self, cache: Arc<CoverCache>) -> Self {
|
|
||||||
self.cover_cache = Some(cache);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the server base URL for cover serving
|
|
||||||
pub fn with_server_base_url(mut self, url: impl Into<String>) -> Self {
|
|
||||||
self.server_base_url = Some(url.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new Radio France source from the cache registry
|
/// Create a new Radio France source from the cache registry
|
||||||
///
|
///
|
||||||
/// This is the recommended way to create a source when using the UPnP server.
|
/// This is the recommended way to create a source when using the UPnP server.
|
||||||
/// The cover cache is automatically retrieved from the global registry.
|
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
/// * `client` - Radio France stateful client
|
/// * `config` - Configuration
|
||||||
/// * `base_url` - Base URL for streaming server (e.g., "http://192.168.0.138:8080")
|
/// * `base_url` - Base URL for streaming server
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error if the cover cache is not initialized in the registry
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub fn from_registry(
|
pub async fn from_registry(config: Arc<Config>, base_url: impl Into<String>) -> Result<Self> {
|
||||||
client: RadioFranceStatefulClient,
|
let client = RadioFranceClient::new().await?;
|
||||||
base_url: impl Into<String>,
|
let cover_cache = pmoupnp::cache_registry::get_cover_cache()
|
||||||
) -> Result<Self> {
|
.ok_or_else(|| crate::error::Error::Other("Cover cache not initialized".to_string()))?;
|
||||||
#[cfg(feature = "cache")]
|
let server_base_url = base_url.into();
|
||||||
let cover_cache = pmoupnp::cache_registry::get_cover_cache();
|
|
||||||
|
|
||||||
let source = Self {
|
let metadata_cache = Arc::new(MetadataCache::new(
|
||||||
client,
|
client,
|
||||||
refresh_handles: Arc::new(RwLock::new(HashMap::new())),
|
|
||||||
#[cfg(feature = "cache")]
|
|
||||||
cover_cache,
|
cover_cache,
|
||||||
server_base_url: Some(base_url.into()),
|
server_base_url.clone(),
|
||||||
update_id: Arc::new(RwLock::new(0)),
|
config,
|
||||||
last_change: Arc::new(RwLock::new(None)),
|
));
|
||||||
|
|
||||||
|
let update_id = Arc::new(RwLock::new(0));
|
||||||
|
let last_change = Arc::new(RwLock::new(None));
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
metadata_cache,
|
||||||
|
server_base_url,
|
||||||
|
update_id,
|
||||||
|
last_change,
|
||||||
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
|
|
||||||
///
|
|
||||||
/// 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;
|
|
||||||
|
|
||||||
// If already running, do nothing
|
|
||||||
if handles.contains_key(station_slug) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = self.client.clone();
|
|
||||||
let slug = station_slug.to_string();
|
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
// 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 polling for station: {}",
|
|
||||||
station_slug
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop metadata refresh task for a station
|
|
||||||
pub async fn stop_metadata_refresh(&self, station_slug: &str) {
|
|
||||||
let mut handles = self.refresh_handles.write().await;
|
|
||||||
if let Some(handle) = handles.remove(station_slug) {
|
|
||||||
handle.abort();
|
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!("Stopped metadata refresh for station: {}", station_slug);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the UPnP container tree dynamically from station data
|
|
||||||
async fn build_container_tree(&self) -> Result<Container> {
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!("Building container tree");
|
|
||||||
|
|
||||||
let stations = self.client.get_stations().await?;
|
|
||||||
let groups = StationGroups::from_stations(stations);
|
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!(
|
|
||||||
"Groups: {} standalone, {} with webradios, {} local radios",
|
|
||||||
groups.standalone.len(),
|
|
||||||
groups.with_webradios.len(),
|
|
||||||
groups.local_radios.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut containers = Vec::new();
|
|
||||||
|
|
||||||
// 1. Standalone stations → playlist containers (plus des items directs)
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!(
|
|
||||||
"Building {} standalone station playlist containers",
|
|
||||||
groups.standalone.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
for station in &groups.standalone {
|
|
||||||
containers.push(self.build_station_playlist(station).await?);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Stations with webradios → containers
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!("Building {} group containers", groups.with_webradios.len());
|
|
||||||
|
|
||||||
for group in &groups.with_webradios {
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!("Building container for group: {}", group.main.name);
|
|
||||||
containers.push(self.build_station_container(group).await?);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Local radios → single "Radios ICI" container
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!(
|
|
||||||
"Building ICI container with {} local radios",
|
|
||||||
groups.local_radios.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
if !groups.local_radios.is_empty() {
|
|
||||||
containers.push(self.build_ici_container(&groups.local_radios).await?);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!(
|
|
||||||
"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().to_string()),
|
|
||||||
searchable: Some("0".to_string()),
|
|
||||||
title: "Radio France".to_string(),
|
|
||||||
class: "object.container".to_string(),
|
|
||||||
artist: None,
|
|
||||||
album_art: None,
|
|
||||||
containers,
|
|
||||||
items: vec![], // Plus d'items directs - tout est dans des playlists
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a container for a station group (main + webradios)
|
impl RadioFranceSource {
|
||||||
/// Returns an empty container - items will be built when browsing into it
|
/// Get the list of all Radio France stations
|
||||||
async fn build_station_container(&self, group: &StationGroup) -> Result<Container> {
|
pub async fn get_stations(&self) -> Result<Vec<crate::models::Station>> {
|
||||||
let child_count = 1 + group.webradios.len(); // main + webradios
|
self.metadata_cache.get_stations().await
|
||||||
|
|
||||||
// Utiliser le logo par défaut si server_base_url est configuré
|
|
||||||
let album_art = self.server_base_url.as_ref().map(|base| {
|
|
||||||
format!(
|
|
||||||
"{}/api/radiofrance/default-logo",
|
|
||||||
base.trim_end_matches('/')
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Container {
|
|
||||||
id: format!("radiofrance:group:{}", group.main.slug),
|
|
||||||
parent_id: "radiofrance".to_string(),
|
|
||||||
restricted: Some("1".to_string()),
|
|
||||||
child_count: Some(child_count.to_string()),
|
|
||||||
searchable: Some("0".to_string()),
|
|
||||||
title: group.main.name.clone(),
|
|
||||||
class: "object.container".to_string(),
|
|
||||||
artist: None,
|
|
||||||
album_art,
|
|
||||||
containers: vec![],
|
|
||||||
items: vec![],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the "Radios ICI" container
|
/// Get live metadata for a station
|
||||||
/// Returns an empty container - items will be built when browsing into it
|
pub async fn get_live_metadata(&self, slug: &str) -> Result<crate::models::LiveResponse> {
|
||||||
async fn build_ici_container(&self, local_radios: &[Station]) -> Result<Container> {
|
self.metadata_cache.get_live_metadata(slug).await
|
||||||
// Utiliser le logo par défaut si server_base_url est configuré
|
|
||||||
let album_art = self.server_base_url.as_ref().map(|base| {
|
|
||||||
format!(
|
|
||||||
"{}/api/radiofrance/default-logo",
|
|
||||||
base.trim_end_matches('/')
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Container {
|
|
||||||
id: "radiofrance:ici".to_string(),
|
|
||||||
parent_id: "radiofrance".to_string(),
|
|
||||||
restricted: Some("1".to_string()),
|
|
||||||
child_count: Some(local_radios.len().to_string()),
|
|
||||||
searchable: Some("0".to_string()),
|
|
||||||
title: "Radios ICI".to_string(),
|
|
||||||
class: "object.container".to_string(),
|
|
||||||
artist: None,
|
|
||||||
album_art,
|
|
||||||
containers: vec![],
|
|
||||||
items: vec![],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construit le container de playlist avec son unique item (métadonnées cohérentes)
|
/// Get the HiFi stream URL for a station
|
||||||
///
|
pub async fn get_stream_url(&self, slug: &str) -> Result<String> {
|
||||||
/// Cette méthode crée un container de type `playlistContainer` contenant un seul item.
|
self.metadata_cache.get_stream_url(slug).await
|
||||||
/// 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")]
|
|
||||||
tracing::debug!(
|
|
||||||
"Building station playlist for: {} ({})",
|
|
||||||
station.name,
|
|
||||||
station.slug
|
|
||||||
);
|
|
||||||
|
|
||||||
// UN SEUL appel au cache - garantit cohérence container/item
|
|
||||||
let metadata = self.client.get_live_metadata(&station.slug).await?;
|
|
||||||
|
|
||||||
// Build l'item de stream avec pmoDidl
|
|
||||||
#[cfg(feature = "cache")]
|
|
||||||
let mut item = StationPlaylist::build_item_from_metadata(
|
|
||||||
station,
|
|
||||||
&metadata,
|
|
||||||
self.cover_cache.as_ref(),
|
|
||||||
self.server_base_url.as_deref(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
#[cfg(not(feature = "cache"))]
|
|
||||||
let mut item = StationPlaylist::build_item_from_metadata_sync(
|
|
||||||
station,
|
|
||||||
&metadata,
|
|
||||||
self.server_base_url.as_deref(),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// 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 playlist container for {}: {} items",
|
|
||||||
station.slug,
|
|
||||||
container.items.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
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(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for RadioFranceSource {
|
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("server_base_url", &self.server_base_url)
|
||||||
.field("refresh_handles_count", &"<locked>")
|
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -506,81 +230,68 @@ impl MusicSource for RadioFranceSource {
|
|||||||
async fn browse(&self, object_id: &str) -> pmosource::Result<BrowseResult> {
|
async fn browse(&self, object_id: &str) -> pmosource::Result<BrowseResult> {
|
||||||
match object_id {
|
match object_id {
|
||||||
"radiofrance" => {
|
"radiofrance" => {
|
||||||
let container = self
|
// Niveau 0: retourne le Container avec les groupes
|
||||||
.build_container_tree()
|
let stations = self
|
||||||
|
.metadata_cache
|
||||||
|
.get_stations()
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
|
|
||||||
|
let groups = StationGroups::from_stations(stations);
|
||||||
|
let container = groups
|
||||||
|
.to_didl(&self.metadata_cache, &self.server_base_url)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
|
|
||||||
// Retourne uniquement des containers (playlists + groupes)
|
|
||||||
Ok(BrowseResult::Containers(container.containers))
|
Ok(BrowseResult::Containers(container.containers))
|
||||||
}
|
}
|
||||||
id if id.starts_with("radiofrance:group:") => {
|
id if id.starts_with("radiofrance:group:") || id == "radiofrance:ici" => {
|
||||||
|
// Niveau 1: browse d'un groupe
|
||||||
let slug = id
|
let slug = id
|
||||||
.strip_prefix("radiofrance:group:")
|
.strip_prefix("radiofrance:group:")
|
||||||
|
.or_else(|| {
|
||||||
|
if id == "radiofrance:ici" {
|
||||||
|
Some("ici")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?;
|
.ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?;
|
||||||
|
|
||||||
let stations = self
|
let stations = self
|
||||||
.client
|
.metadata_cache
|
||||||
.get_stations()
|
.get_stations()
|
||||||
.await
|
.await
|
||||||
.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);
|
||||||
let group = groups
|
let group = groups
|
||||||
.with_webradios
|
.groups
|
||||||
.iter()
|
.iter()
|
||||||
.find(|g| g.main.slug == slug)
|
.find(|g| g.stations[0].slug == slug)
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?;
|
.ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?;
|
||||||
|
|
||||||
// Build playlist containers for this group (main + webradios)
|
let container = group
|
||||||
// Paralléliser les fetches pour éviter les timeouts
|
.to_didl(&self.metadata_cache, &self.server_base_url)
|
||||||
let mut futures = vec![self.build_station_playlist(&group.main)];
|
|
||||||
for webradio in &group.webradios {
|
|
||||||
futures.push(self.build_station_playlist(webradio));
|
|
||||||
}
|
|
||||||
|
|
||||||
let results = futures::future::join_all(futures).await;
|
|
||||||
|
|
||||||
let mut containers = Vec::new();
|
|
||||||
for result in results {
|
|
||||||
let container =
|
|
||||||
result.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
||||||
containers.push(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(BrowseResult::Containers(containers))
|
|
||||||
}
|
|
||||||
"radiofrance:ici" => {
|
|
||||||
let stations = self
|
|
||||||
.client
|
|
||||||
.get_stations()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
let groups = StationGroups::from_stations(stations);
|
|
||||||
|
|
||||||
// Build playlist containers for local radios only
|
// Si c'est une playlist (1 station), retourner le container lui-même
|
||||||
let mut containers = Vec::new();
|
// Sinon retourner ses sous-containers
|
||||||
for station in &groups.local_radios {
|
if container.class == "object.container.playlistContainer" {
|
||||||
let container = self
|
Ok(BrowseResult::Containers(vec![container]))
|
||||||
.build_station_playlist(station)
|
} else {
|
||||||
.await
|
Ok(BrowseResult::Containers(container.containers))
|
||||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
|
||||||
|
|
||||||
containers.push(container);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(BrowseResult::Containers(containers))
|
|
||||||
}
|
}
|
||||||
id if id.starts_with("radiofrance:") && !id.contains(":stream") => {
|
id if id.starts_with("radiofrance:") && !id.contains(":stream") => {
|
||||||
// Browse d'un container de playlist (ex: radiofrance:fip)
|
// Niveau 2: browse d'une station (playlist)
|
||||||
// Le container contient déjà son item, on retourne juste le container
|
|
||||||
let slug = id
|
let slug = id
|
||||||
.strip_prefix("radiofrance:")
|
.strip_prefix("radiofrance:")
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?;
|
.ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?;
|
||||||
|
|
||||||
// Trouver la station correspondante
|
|
||||||
let stations = self
|
let stations = self
|
||||||
.client
|
.metadata_cache
|
||||||
.get_stations()
|
.get_stations()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
@@ -590,13 +301,13 @@ impl MusicSource for RadioFranceSource {
|
|||||||
.find(|s| s.slug == slug)
|
.find(|s| s.slug == slug)
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?;
|
.ok_or_else(|| MusicSourceError::ObjectNotFound(id.to_string()))?;
|
||||||
|
|
||||||
let container = self
|
let container = station
|
||||||
.build_station_playlist(station)
|
.to_didl(&self.metadata_cache, &self.server_base_url)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
|
|
||||||
// Retourner le container lui-même (qui contient l'item)
|
// Retourner les items de la playlist
|
||||||
Ok(BrowseResult::Containers(vec![container]))
|
Ok(BrowseResult::Items(container.items))
|
||||||
}
|
}
|
||||||
_ => Err(MusicSourceError::ObjectNotFound(object_id.to_string())),
|
_ => Err(MusicSourceError::ObjectNotFound(object_id.to_string())),
|
||||||
}
|
}
|
||||||
@@ -609,9 +320,8 @@ 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()))?;
|
||||||
|
|
||||||
// Trouver la station correspondante
|
|
||||||
let stations = self
|
let stations = self
|
||||||
.client
|
.metadata_cache
|
||||||
.get_stations()
|
.get_stations()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
@@ -621,13 +331,11 @@ impl MusicSource for RadioFranceSource {
|
|||||||
.find(|s| s.slug == slug)
|
.find(|s| s.slug == slug)
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
||||||
|
|
||||||
// Construire le container de playlist et extraire l'item
|
let container = station
|
||||||
let container = self
|
.to_didl(&self.metadata_cache, &self.server_base_url)
|
||||||
.build_station_playlist(station)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||||
|
|
||||||
// Extraire l'unique item du container
|
|
||||||
container
|
container
|
||||||
.items
|
.items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -637,27 +345,24 @@ impl MusicSource for RadioFranceSource {
|
|||||||
|
|
||||||
async fn resolve_uri(&self, object_id: &str) -> pmosource::Result<String> {
|
async fn resolve_uri(&self, object_id: &str) -> pmosource::Result<String> {
|
||||||
// Extract station slug from object_id (format: radiofrance:{slug}:stream)
|
// Extract station slug from object_id (format: radiofrance:{slug}:stream)
|
||||||
let slug = object_id
|
let _slug = object_id
|
||||||
.strip_prefix("radiofrance:")
|
.strip_prefix("radiofrance:")
|
||||||
.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()))?;
|
||||||
|
|
||||||
// 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
|
// 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()
|
||||||
.map(|r| r.url.clone())
|
.map(|r| r.url.clone())
|
||||||
.ok_or_else(|| MusicSourceError::UriResolutionError("No resource found".to_string()))
|
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn supports_fifo(&self) -> bool {
|
fn supports_fifo(&self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn append_track(&self, _track: Item) -> pmosource::Result<()> {
|
async fn append_track(&self, _item: Item) -> pmosource::Result<()> {
|
||||||
Err(MusicSourceError::FifoNotSupported)
|
Err(MusicSourceError::FifoNotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,34 +378,7 @@ impl MusicSource for RadioFranceSource {
|
|||||||
*self.last_change.read().await
|
*self.last_change.read().await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_items(&self, offset: usize, count: usize) -> pmosource::Result<Vec<Item>> {
|
async fn get_items(&self, _offset: usize, _count: usize) -> pmosource::Result<Vec<Item>> {
|
||||||
// Not applicable for radio stations
|
|
||||||
let _ = (offset, count);
|
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for RadioFranceSource {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// Abort all refresh tasks on drop
|
|
||||||
if let Ok(handles) = self.refresh_handles.try_write() {
|
|
||||||
for (_, handle) in handles.iter() {
|
|
||||||
handle.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
// Note: These tests require a valid pmoconfig setup
|
|
||||||
// They are primarily structural tests
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_source_metadata() {
|
|
||||||
// Test that we can create a source with proper metadata
|
|
||||||
// Actual async tests would go in integration tests
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,500 +0,0 @@
|
|||||||
//! Stateful client for Radio France with automatic caching
|
|
||||||
//!
|
|
||||||
//! This module provides a higher-level client that automatically manages
|
|
||||||
//! station discovery caching through pmoconfig, providing a simpler API
|
|
||||||
//! for integration into PMOMusic.
|
|
||||||
//!
|
|
||||||
//! # Example
|
|
||||||
//!
|
|
||||||
//! ```no_run
|
|
||||||
//! use pmoradiofrance::RadioFranceStatefulClient;
|
|
||||||
//! use pmoconfig::get_config;
|
|
||||||
//!
|
|
||||||
//! #[tokio::main]
|
|
||||||
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
//! let config = get_config();
|
|
||||||
//! let client = RadioFranceStatefulClient::new(config).await?;
|
|
||||||
//!
|
|
||||||
//! // Get stations (automatically cached with 7-day TTL)
|
|
||||||
//! let stations = client.get_stations().await?;
|
|
||||||
//!
|
|
||||||
//! // Get live metadata (handles caching internally)
|
|
||||||
//! let metadata = client.get_live_metadata("franceculture").await?;
|
|
||||||
//!
|
|
||||||
//! Ok(())
|
|
||||||
//! }
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use crate::client::RadioFranceClient;
|
|
||||||
use crate::config_ext::RadioFranceConfigExt;
|
|
||||||
use crate::error::{Error, Result};
|
|
||||||
use crate::models::{LiveResponse, Station};
|
|
||||||
use pmoconfig::Config;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, SystemTime};
|
|
||||||
|
|
||||||
/// 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
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct LiveMetadataCache {
|
|
||||||
/// Cached metadata
|
|
||||||
metadata: LiveResponse,
|
|
||||||
/// When the cache should be invalidated (based on delayToRefresh)
|
|
||||||
valid_until: SystemTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LiveMetadataCache {
|
|
||||||
/// Create a new cache entry
|
|
||||||
fn new(metadata: LiveResponse) -> Self {
|
|
||||||
let delay = Duration::from_millis(metadata.delay_to_refresh);
|
|
||||||
let valid_until = SystemTime::now() + delay;
|
|
||||||
|
|
||||||
Self {
|
|
||||||
metadata,
|
|
||||||
valid_until,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the cache is still valid
|
|
||||||
fn is_valid(&self) -> bool {
|
|
||||||
SystemTime::now() < self.valid_until
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the remaining time until the cache expires
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
fn remaining_ttl(&self) -> Duration {
|
|
||||||
self.valid_until
|
|
||||||
.duration_since(SystemTime::now())
|
|
||||||
.unwrap_or(Duration::ZERO)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stateful Radio France client with automatic caching
|
|
||||||
///
|
|
||||||
/// This client wraps `RadioFranceClient` and adds:
|
|
||||||
/// - Automatic station list caching via pmoconfig
|
|
||||||
/// - Live metadata caching (in-memory, respecting delayToRefresh)
|
|
||||||
/// - Simple high-level API for PMOMusic integration
|
|
||||||
///
|
|
||||||
/// # Caching Strategy
|
|
||||||
///
|
|
||||||
/// - **Station List**: Cached in pmoconfig with 7-day TTL (configurable)
|
|
||||||
/// - **Live Metadata**: Cached in-memory per station, TTL from API's delayToRefresh
|
|
||||||
///
|
|
||||||
/// # Thread Safety
|
|
||||||
///
|
|
||||||
/// This client is thread-safe (Clone + Send + Sync) and can be shared
|
|
||||||
/// across async tasks.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct RadioFranceStatefulClient {
|
|
||||||
/// Underlying HTTP client
|
|
||||||
client: RadioFranceClient,
|
|
||||||
/// Configuration handle (Arc for sharing)
|
|
||||||
config: Arc<Config>,
|
|
||||||
/// In-memory cache for live metadata (thread-safe)
|
|
||||||
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 {
|
|
||||||
/// Create a new stateful client
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `config` - Configuration handle for caching station lists
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// use pmoconfig::get_config;
|
|
||||||
/// use pmoradiofrance::RadioFranceStatefulClient;
|
|
||||||
///
|
|
||||||
/// #[tokio::main]
|
|
||||||
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
/// let config = get_config();
|
|
||||||
/// let client = RadioFranceStatefulClient::new(config).await?;
|
|
||||||
/// Ok(())
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
pub async fn new(config: Arc<Config>) -> Result<Self> {
|
|
||||||
let client = RadioFranceClient::new().await?;
|
|
||||||
Ok(Self {
|
|
||||||
client,
|
|
||||||
config,
|
|
||||||
metadata_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
|
|
||||||
update_callbacks: Arc::new(std::sync::RwLock::new(Vec::new())),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a client from global configuration
|
|
||||||
///
|
|
||||||
/// This is a convenience method that reads the configuration from
|
|
||||||
/// the global config singleton.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```ignore
|
|
||||||
/// use pmoradiofrance::RadioFranceStatefulClient;
|
|
||||||
///
|
|
||||||
/// #[tokio::main]
|
|
||||||
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
/// let client = RadioFranceStatefulClient::from_config().await?;
|
|
||||||
/// Ok(())
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
pub async fn from_config() -> Result<Self> {
|
|
||||||
let config = pmoconfig::get_config();
|
|
||||||
Self::new(config).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a client with a custom RadioFranceClient
|
|
||||||
pub fn with_client(client: RadioFranceClient, config: Arc<Config>) -> Self {
|
|
||||||
Self {
|
|
||||||
client,
|
|
||||||
config,
|
|
||||||
metadata_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
|
|
||||||
update_callbacks: Arc::new(std::sync::RwLock::new(Vec::new())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the underlying HTTP client
|
|
||||||
pub fn client(&self) -> &RadioFranceClient {
|
|
||||||
&self.client
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the configuration
|
|
||||||
pub fn config(&self) -> &Arc<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)
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
/// Get all stations, using cache if valid
|
|
||||||
///
|
|
||||||
/// This method automatically:
|
|
||||||
/// 1. Checks if Radio France is enabled in config
|
|
||||||
/// 2. Tries to use cached station list
|
|
||||||
/// 3. If cache miss/expired, discovers and caches stations
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns error if:
|
|
||||||
/// - Radio France is disabled in config
|
|
||||||
/// - Discovery fails and no valid cache exists
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// # use pmoradiofrance::RadioFranceStatefulClient;
|
|
||||||
/// # use pmoconfig::get_config;
|
|
||||||
/// # #[tokio::main]
|
|
||||||
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
/// # let config = get_config();
|
|
||||||
/// # let client = RadioFranceStatefulClient::new(config).await?;
|
|
||||||
/// let stations = client.get_stations().await?;
|
|
||||||
/// for station in stations {
|
|
||||||
/// println!("{} - {}", station.name, station.slug);
|
|
||||||
/// }
|
|
||||||
/// # Ok(())
|
|
||||||
/// # }
|
|
||||||
/// ```
|
|
||||||
pub async fn get_stations(&self) -> Result<Vec<Station>> {
|
|
||||||
// Check if Radio France is enabled
|
|
||||||
if !self.config.get_radiofrance_enabled()? {
|
|
||||||
return Err(Error::other("Radio France is disabled in configuration"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to get from cache
|
|
||||||
if let Some(stations) = self.config.get_radiofrance_stations_cached()? {
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!("Using {} cached stations", stations.len());
|
|
||||||
return Ok(stations);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache miss - discover and cache with timeout
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::info!("Station cache miss - discovering stations");
|
|
||||||
|
|
||||||
let stations = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(10),
|
|
||||||
self.client.discover_all_stations(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| Error::other("Timeout while discovering Radio France stations (10s)"))??;
|
|
||||||
|
|
||||||
// Cache the results
|
|
||||||
self.config.set_radiofrance_cached_stations(&stations)?;
|
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::info!("Discovered and cached {} stations", stations.len());
|
|
||||||
|
|
||||||
Ok(stations)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Force refresh of the station list (bypass cache)
|
|
||||||
///
|
|
||||||
/// Use this to force re-discovery, for example after a manual
|
|
||||||
/// cache invalidation or to get the latest station list.
|
|
||||||
pub async fn refresh_stations(&self) -> Result<Vec<Station>> {
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::info!("Force refreshing station list");
|
|
||||||
|
|
||||||
let stations = self.client.discover_all_stations().await?;
|
|
||||||
self.config.set_radiofrance_cached_stations(&stations)?;
|
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::info!("Refreshed {} stations", stations.len());
|
|
||||||
|
|
||||||
Ok(stations)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear the station cache
|
|
||||||
///
|
|
||||||
/// Forces next `get_stations()` call to re-discover stations.
|
|
||||||
pub fn clear_station_cache(&self) -> Result<()> {
|
|
||||||
Ok(self.config.clear_radiofrance_station_cache()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Live Metadata (with intelligent caching)
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
/// Get live metadata for a station, using cache if valid
|
|
||||||
///
|
|
||||||
/// This method automatically:
|
|
||||||
/// 1. Checks in-memory cache
|
|
||||||
/// 2. If cache valid (based on delayToRefresh), returns cached data
|
|
||||||
/// 3. If cache expired, fetches fresh data and updates cache
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `station` - Station slug (e.g., "franceculture", "fip_rock")
|
|
||||||
///
|
|
||||||
/// # Caching Behavior
|
|
||||||
///
|
|
||||||
/// The cache TTL is determined by the API's `delayToRefresh` field,
|
|
||||||
/// which respects Radio France's recommended polling interval.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// # use pmoradiofrance::RadioFranceStatefulClient;
|
|
||||||
/// # use pmoconfig::get_config;
|
|
||||||
/// # #[tokio::main]
|
|
||||||
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
/// # let config = get_config();
|
|
||||||
/// # let client = RadioFranceStatefulClient::new(config).await?;
|
|
||||||
/// let metadata = client.get_live_metadata("franceculture").await?;
|
|
||||||
/// println!("Now: {} - {}",
|
|
||||||
/// metadata.now.first_line.title_or_default(),
|
|
||||||
/// metadata.now.second_line.title_or_default()
|
|
||||||
/// );
|
|
||||||
/// # Ok(())
|
|
||||||
/// # }
|
|
||||||
/// ```
|
|
||||||
pub async fn get_live_metadata(&self, station: &str) -> Result<LiveResponse> {
|
|
||||||
// Check cache first
|
|
||||||
{
|
|
||||||
let cache = self.metadata_cache.read().unwrap();
|
|
||||||
if let Some(entry) = cache.get(station) {
|
|
||||||
if entry.is_valid() {
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!(
|
|
||||||
"Using cached metadata for {} (TTL: {:?})",
|
|
||||||
station,
|
|
||||||
entry.remaining_ttl()
|
|
||||||
);
|
|
||||||
return Ok(entry.metadata.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache miss or expired - fetch fresh data with timeout
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!("Fetching live metadata for {}", station);
|
|
||||||
|
|
||||||
let metadata = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(5),
|
|
||||||
self.client.live_metadata(station),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| {
|
|
||||||
Error::other(format!(
|
|
||||||
"Timeout while fetching metadata for {} (5s)",
|
|
||||||
station
|
|
||||||
))
|
|
||||||
})??;
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!(
|
|
||||||
"Cached metadata for {} (TTL: {} ms)",
|
|
||||||
station,
|
|
||||||
metadata.delay_to_refresh
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear the metadata cache for a specific station
|
|
||||||
pub fn clear_metadata_cache(&self, station: &str) {
|
|
||||||
let mut cache = self.metadata_cache.write().unwrap();
|
|
||||||
cache.remove(station);
|
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!("Cleared metadata cache for {}", station);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all metadata caches
|
|
||||||
pub fn clear_all_metadata_caches(&self) {
|
|
||||||
let mut cache = self.metadata_cache.write().unwrap();
|
|
||||||
cache.clear();
|
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!("Cleared all metadata caches");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Convenience Methods
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
/// Get the HiFi stream URL for a station
|
|
||||||
///
|
|
||||||
/// Convenience wrapper around `get_live_metadata()` that extracts
|
|
||||||
/// the best HiFi stream URL.
|
|
||||||
pub async fn get_stream_url(&self, station: &str) -> Result<String> {
|
|
||||||
self.client.get_hifi_stream_url(station).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if Radio France is enabled in configuration
|
|
||||||
pub fn is_enabled(&self) -> Result<bool> {
|
|
||||||
Ok(self.config.get_radiofrance_enabled()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enable Radio France in configuration
|
|
||||||
pub fn set_enabled(&self, enabled: bool) -> Result<()> {
|
|
||||||
Ok(self.config.set_radiofrance_enabled(enabled)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the station cache TTL in seconds
|
|
||||||
pub fn get_station_cache_ttl(&self) -> Result<u64> {
|
|
||||||
Ok(self.config.get_radiofrance_station_cache_ttl()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the station cache TTL in seconds
|
|
||||||
pub fn set_station_cache_ttl(&self, ttl_secs: u64) -> Result<()> {
|
|
||||||
Ok(self.config.set_radiofrance_station_cache_ttl(ttl_secs)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get cache statistics
|
|
||||||
///
|
|
||||||
/// Returns (number of cached stations, number of cached metadata entries)
|
|
||||||
pub fn cache_stats(&self) -> (usize, usize) {
|
|
||||||
let station_count = self
|
|
||||||
.config
|
|
||||||
.get_radiofrance_stations_cached()
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.map(|s| s.len())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let metadata_count = self.metadata_cache.read().unwrap().len();
|
|
||||||
|
|
||||||
(station_count, metadata_count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Debug for RadioFranceStatefulClient {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
let (station_cache, metadata_cache) = self.cache_stats();
|
|
||||||
f.debug_struct("RadioFranceStatefulClient")
|
|
||||||
.field("client", &self.client)
|
|
||||||
.field("cached_stations", &station_cache)
|
|
||||||
.field("cached_metadata_entries", &metadata_cache)
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
// Note: Real integration tests would require pmoconfig setup
|
|
||||||
// These are just structural tests
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_live_metadata_cache_validity() {
|
|
||||||
let response = LiveResponse {
|
|
||||||
station_name: "test".to_string(),
|
|
||||||
delay_to_refresh: 5000, // 5 seconds
|
|
||||||
migrated: true,
|
|
||||||
now: crate::models::ShowMetadata {
|
|
||||||
print_prog_music: false,
|
|
||||||
start_time: None,
|
|
||||||
end_time: None,
|
|
||||||
producer: None,
|
|
||||||
first_line: Default::default(),
|
|
||||||
second_line: Default::default(),
|
|
||||||
third_line: None,
|
|
||||||
intro: None,
|
|
||||||
react_available: false,
|
|
||||||
visual_background: None,
|
|
||||||
song: None,
|
|
||||||
media: Default::default(),
|
|
||||||
visuals: None,
|
|
||||||
local_radios: None,
|
|
||||||
},
|
|
||||||
next: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let cache = LiveMetadataCache::new(response);
|
|
||||||
assert!(cache.is_valid());
|
|
||||||
|
|
||||||
// Verify the cache expires in the future
|
|
||||||
assert!(cache.valid_until > SystemTime::now());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user