diff --git a/.DS_Store b/.DS_Store
index 5815a533..119d664b 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/.gitignore b/.gitignore
index ef7ac385..e8cec106 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,7 +8,7 @@
**/*.o
**/*.o.d
**/*.a
-**/*.flac
+**/*.flac
**/*.aif
**/*.aiff
**/*.wav
@@ -25,7 +25,7 @@ xxx
.DS_Store
target
/.pmomusic_covers
-/.pmomusic_audio/**
+/.pmomusic_audio/**
C/src/soxr-0.1.3/Release/tests
**/Release/
**/Debug/
@@ -42,4 +42,6 @@ setup-env.sh
cache
gupnp-tools
pmo*_[0_9]*.txt
-webapp_[0_9]*.txt
\ No newline at end of file
+webapp_[0_9]*.txt
+RF.json
+RF_old.json
diff --git a/Blackboard/Report/Construire_pmoradiofrance.md b/Blackboard/Report/Construire_pmoradiofrance.md
new file mode 100644
index 00000000..f982b83c
--- /dev/null
+++ b/Blackboard/Report/Construire_pmoradiofrance.md
@@ -0,0 +1,206 @@
+# Rapport : Implémentation de pmoradiofrance (Round 3)
+
+**Date** : 2026-01-22
+**Crate** : `pmoradiofrance`
+**Statut** : Implémentation initiale complète
+
+---
+
+## Résumé
+
+Création de la crate `pmoradiofrance` qui fournit un client Rust pour accéder aux APIs publiques de Radio France. Le client permet :
+
+- La découverte dynamique de toutes les stations (~70+)
+- La récupération des métadonnées live (émission en cours, producteur, visuels)
+- L'accès aux flux audio HiFi (AAC 192 kbps, HLS)
+- Le cache de la liste des stations avec TTL configurable
+
+---
+
+## Fichiers créés
+
+| Fichier | Description |
+|---------|-------------|
+| `pmoradiofrance/Cargo.toml` | Configuration de la crate avec dépendances |
+| `pmoradiofrance/src/lib.rs` | Point d'entrée et exports publics |
+| `pmoradiofrance/src/error.rs` | Types d'erreurs (`Error`, `Result`) |
+| `pmoradiofrance/src/models.rs` | Structures de données pour l'API |
+| `pmoradiofrance/src/client.rs` | `RadioFranceClient` et `ClientBuilder` |
+| `pmoradiofrance/src/config_ext.rs` | Extension `RadioFranceConfigExt` pour pmoconfig |
+| `pmoradiofrance/examples/discover_stations.rs` | Exemple de découverte |
+| `pmoradiofrance/examples/live_metadata.rs` | Exemple de métadonnées live |
+
+## Fichiers modifiés
+
+| Fichier | Modification |
+|---------|--------------|
+| `Cargo.toml` (racine) | Ajout de `pmoradiofrance` au workspace |
+
+---
+
+## Architecture du client
+
+### `RadioFranceClient`
+
+Client HTTP stateless pour interroger les APIs Radio France :
+
+```rust
+// Création
+let client = RadioFranceClient::new().await?;
+
+// Découverte des stations
+let stations = client.discover_all_stations().await?;
+
+// Métadonnées live
+let metadata = client.live_metadata("franceculture").await?;
+
+// URL du flux HiFi
+let stream_url = client.get_hifi_stream_url("fip_rock").await?;
+```
+
+### Découverte des stations
+
+Le client découvre dynamiquement :
+
+1. **Stations principales** (7) : France Inter, France Info, France Culture, France Musique, FIP, Mouv', France Bleu
+2. **Webradios** (~15-20) : FIP Rock, FIP Jazz, France Musique Baroque, etc.
+3. **Radios locales France Bleu** (~44) : via le champ `now.localRadios` de l'API
+
+### Gestion des webradios
+
+Le parsing des slugs gère automatiquement les webradios :
+
+| Slug | Base station | Paramètre webradio |
+|------|--------------|-------------------|
+| `fip` | `fip` | - |
+| `fip_rock` | `fip` | `?webradio=fip_rock` |
+| `francemusique_jazz` | `francemusique` | `?webradio=francemusique_jazz` |
+| `francebleu_alsace` | `francebleu_alsace` | - (slug direct) |
+
+---
+
+## Extension de configuration
+
+Le trait `RadioFranceConfigExt` permet de cacher la liste des stations :
+
+```rust
+use pmoconfig::get_config;
+use pmoradiofrance::RadioFranceConfigExt;
+
+let config = get_config();
+
+// Vérifier le cache (TTL par défaut : 7 jours)
+if let Some(stations) = config.get_radiofrance_stations_cached()? {
+ // Utiliser les stations du cache
+} else {
+ // Découvrir et mettre en cache
+ let client = RadioFranceClient::new().await?;
+ let stations = client.discover_all_stations().await?;
+ config.set_radiofrance_cached_stations(&stations)?;
+}
+```
+
+### Configuration YAML générée
+
+```yaml
+sources:
+ radiofrance:
+ enabled: true
+ station_cache_ttl_secs: 604800 # 7 jours
+ station_cache:
+ stations: [...]
+ last_updated: 1769112000
+ version: 1
+```
+
+---
+
+## Tests d'intégration
+
+17 tests d'intégration qui appellent la vraie API Radio France :
+
+```bash
+# Exécuter tous les tests d'intégration
+cargo test -p pmoradiofrance -- --ignored
+
+# Avec output visible
+cargo test -p pmoradiofrance -- --ignored --nocapture
+```
+
+| Test | Description | Status |
+|------|-------------|--------|
+| `test_client_creation` | Création du client | ✅ |
+| `test_live_metadata_franceculture` | Métadonnées France Culture | ✅ |
+| `test_live_metadata_franceinter` | Métadonnées France Inter | ✅ |
+| `test_live_metadata_fip` | Métadonnées FIP (avec chanson) | ✅ |
+| `test_live_metadata_fip_rock` | Métadonnées webradio FIP Rock | ✅ |
+| `test_live_metadata_francemusique` | Métadonnées France Musique | ✅ |
+| `test_live_metadata_francebleu` | Métadonnées + radios locales | ✅ |
+| `test_live_metadata_mouv` | Métadonnées Mouv' | ✅ |
+| `test_get_hifi_stream_url` | URLs des flux HiFi | ✅ |
+| `test_get_available_streams` | Liste des flux disponibles | ✅ |
+| `test_discover_main_stations` | Découverte stations principales | ✅ |
+| `test_discover_fip_webradios` | Découverte webradios FIP | ✅ |
+| `test_discover_francemusique_webradios` | Découverte webradios FM | ✅ |
+| `test_discover_local_radios` | Découverte radios locales | ✅ |
+| `test_discover_all_stations` | Découverte complète | ✅ |
+| `test_invalid_station` | Gestion d'erreur | ✅ |
+| `test_refresh_delay` | Calcul délai refresh | ✅ |
+
+---
+
+## Correction effectuée pendant l'implémentation
+
+**Bug découvert** : Le champ `localRadios` de l'API France Bleu est dans `now.localRadios` (imbriqué dans `ShowMetadata`), pas au niveau racine de `LiveResponse`.
+
+**Correction** :
+1. Déplacé le champ `local_radios` de `LiveResponse` vers `ShowMetadata`
+2. Ajouté une méthode helper `LiveResponse::local_radios()` pour accès simplifié
+3. Mis à jour le client et les tests
+
+---
+
+## Features Cargo
+
+| Feature | Description | Dépendances |
+|---------|-------------|-------------|
+| `default` | Configuration de base | `pmoconfig` |
+| `pmoconfig` | Support extension config | `dep:pmoconfig` |
+| `cache` | Support cache audio/covers | `pmocovers`, `pmoaudiocache` |
+| `playlist` | Support playlists FIFO | `pmoplaylist` |
+| `logging` | Logs avec tracing | - |
+| `server` | Support serveur complet | Toutes les features |
+| `full` | Toutes les features | `server`, `logging` |
+
+---
+
+## Prochaines étapes
+
+1. **Implémenter `source.rs`** : Trait `MusicSource` pour intégration UPnP
+2. **Ajouter support FIFO** : Pour les radios musicales (FIP, France Musique)
+3. **Cache des métadonnées live** : Respecter `delayToRefresh`
+4. **Intégration serveur** : Routes API REST via pmoserver
+
+---
+
+## Exemples d'utilisation
+
+### Découverte des stations
+
+```bash
+cargo run -p pmoradiofrance --example discover_stations
+```
+
+### Métadonnées live
+
+```bash
+# Station par défaut (France Culture)
+cargo run -p pmoradiofrance --example live_metadata
+
+# Station spécifique
+cargo run -p pmoradiofrance --example live_metadata -- fip_rock
+```
+
+---
+
+**Fin du rapport**
diff --git a/Blackboard/ToDiscuss/Construire_pmoradiofrance.md b/Blackboard/ToDiscuss/Construire_pmoradiofrance.md
new file mode 100644
index 00000000..14b1c274
--- /dev/null
+++ b/Blackboard/ToDiscuss/Construire_pmoradiofrance.md
@@ -0,0 +1,64 @@
+** Tu dois suivre scrupuleusement les règles définies dans le fichier [@Rules.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules.md) **
+
+
+** Cette tâche n'est pas une tâche de codage, c'est une tâche de réflexion. Elle doit conduire à la rédaction d'un rapport dans le répertoire [@ToThinkAbout](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/ToThinkAbout) **
+
+à parir de la page [web](https://www.radiofrance.fr/franceculture) peux-tu comprendre comment elle obtient les informations prsésenté ci-dessous:
+
+```html
+
+```
+
+et
+
+```html
+Les Matins par Guillaume Erner
+```
+
+## Round 2:
+
+À partir de [@api_radiofrance_complete.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/ToThinkAbout/api_radiofrance_complete.md) et de [@music_source.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Architecture/music_source.md)
+
+Nous allons commencer l'implémentation de pmoradiofrance en implémentant dans un fichier client.rs Une API de requêtes sur Radio France. Pour les fonctionnalités, il faut effectivement se reporter au fichier `api_radiofrance_complete.md` Et pour l'architecture, il est possible de s'inspirer de [@client.rs](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoparadise/src/client.rs)
+
+## Round 3
+
+A partir des découvertes faites durant le round 2, Tu as maintenant le droit d'écrire du code. Tu peux donc écrire le fichier: client.rs de la nouvelle crate pmoradiofrance. Pour cela, tu devras t'appuyer sur les rapports [@api_radiofrance_complete.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/ToThinkAbout/api_radiofrance_complete.md) et de [@music_source.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Architecture/music_source.md)
+
+Il faut absolument cacher les réponses de l'API, Afin de limiter au maximum les requêtes inutiles, Notamment, on sait que les chaînes et les web radios ne changent que très rarement. On peut peut-être penser à faire une extension de configuration [@pmoconfig_ext.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Architecture/pmoconfig_ext.md) Pour stocker les informations principales ainsi qu'un timestamp et se dire que sauf requêtes forcées on ne va pas mettre à jour cette liste plus d'une fois par semaine.
+
+## Round 4
+
+Je pense qu'il faut maintenant implémenter le deuxième niveau de la Crate Radio France, En implémentant un client Stateful qui peut retourner facilement des listes de radio, des playlists, qui charge ces informations soit à part de la configuration, soit à partir de l'API, suivant que le TTL est dépassé ou pas. Le tout pour préparer la construction de la source Radio France.
+
+Pensez comme rêgle métier à renommer les choses qui apparaissent comme `France Bleue` en `ICI` Au niveau des labels d'affichage, pas des slugs évidemment.
+
+Voilà le type d'arborescence de browsing qu'on pourrait avoir.
+On ne présente à chaque fois qu'un lien vers le flux de plus haute résolution.
+
+
+```
+Radio France
+├── France Culture
+├── FIP
+│ ├── FIP
+│ ├── FIP Cultes
+│ ├── FIP Nouveautes
+│ ├── FIP ...
+│ └── FIP Pop
+├── Mouv'
+├── ...
+└── ICI
+ ├── ICI Alsace
+ ├── ICI Armorique
+ ├── ICI Auxerre
+ ├── ...
+ └── ICI Vaucluse
+```
+
+Sous le folder principal Radio France, On doit avoir une playlist par station. Les stations qui n'ont qu'une seule chaîne ne contiennent que cette chaîne dans leur PMOplaylist, Les autres, si elles ont une station principale comme FIP, commencent par cette station principale puis leur station annexe.
+Évidemment, normalement, le contenu en titre des playlists ne doit pas évoluer. Mais les métadonnées si régulièrement Si l'on met le titre de la station comme équivalent d'un titre d'album, Le nom de l'émission pourrait être l'auteur. Et le titre de l'émission du jour, le titre du morceau. Ainsi, au fur et à mesure du temps, on fait évoluer des métadonnées pour changer la couverture, le nom de l'émission, le titre de l'émissions du jour.
+
+Je pense que les playlists doivent être des playlists volatiles. Les covers doivent être cachés dans [@pmocovers](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmocovers) Par contre les URL doivent être passées telles qu'elles, C'est du pur stream, on ne va pas les cacher dans le PMOaudiocache.
+
+étend le document de réflexion pour proposer une architecture: [@api_radiofrance_complete.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/ToThinkAbout/api_radiofrance_complete.md)
diff --git a/Blackboard/ToThinkAbout/analyse_metadonnees_franceculture.md b/Blackboard/ToThinkAbout/analyse_metadonnees_franceculture.md
new file mode 100644
index 00000000..1ebd2833
--- /dev/null
+++ b/Blackboard/ToThinkAbout/analyse_metadonnees_franceculture.md
@@ -0,0 +1,474 @@
+# Analyse : Récupération des métadonnées France Culture
+
+## Objectif
+Comprendre comment le site web de France Culture (https://www.radiofrance.fr/franceculture) obtient et affiche les informations sur l'émission en cours.
+
+## Architecture du site
+
+### Framework utilisé
+**SvelteKit** avec Server-Side Rendering (SSR)
+
+Le site utilise SvelteKit, comme en témoignent :
+- L'attribut `data-sveltekit-preload-data="hover"` sur le ``
+- Les classes CSS préfixées par `svelte-` (ex: `svelte-1thibul`, `svelte-qz676b`)
+- Les chemins vers les assets : `/client/immutable/assets/`
+
+### Rendu des données
+**SSR (Server-Side Rendering)** - Les données sont déjà présentes dans le HTML initial
+
+## Méthode de récupération des informations
+
+### ✅ API publique JSON découverte !
+
+**Après analyse du trafic réseau (fichier HAR), l'API officielle existe et est OUVERTE :**
+
+#### API LiveMeta (métadonnées en temps réel)
+```
+https://api.radiofrance.fr/livemeta/live/5/transistor_culture_player
+```
+
+**Caractéristiques :**
+- ✅ **Aucune authentification requise** (pas de token)
+- ✅ **Endpoint officiel** utilisé par le site web
+- ✅ **JSON structuré** avec émission en cours, précédente et suivante
+- ✅ **Timestamps précis** de début et fin d'émission
+- ✅ **UUIDs des émissions** pour récupérer plus de détails
+- ✅ **Indicateur de rafraîchissement** (`delayToRefresh` en millisecondes)
+
+**Exemple de réponse :**
+```json
+{
+ "prev": [{
+ "firstLine": "Le direct",
+ "firstLineUuid": null,
+ "firstLinePath": null,
+ "secondLine": "France Culture, l'esprit d'ouverture",
+ "cover": "4e9fba8d-7675-409d-86a0-fce40f0cd4a6",
+ "startTime": null,
+ "endTime": null
+ }],
+ "now": {
+ "firstLine": "La Série fiction",
+ "firstLineUuid": "69cf4362-6bfb-48d1-89cf-9d11202f9938",
+ "firstLineExpressionUuid": "69cf4362-6bfb-48d1-89cf-9d11202f9938",
+ "firstLinePath": "franceculture/podcasts/fictions-le-feuilleton",
+ "firstLinePathUuid": "3c1c2e55-41a0-11e5-9fe0-005056a87c89",
+ "secondLine": "\"Ségou\" de Maryse Condé 9/10 : Deuil et pénitence",
+ "secondLineExpressionUuid": "69cf4362-6bfb-48d1-89cf-9d11202f9938",
+ "cover": "436430f7-5b2b-43f2-9f3c-28f2ad6cae39",
+ "startTime": 1769108400,
+ "endTime": 1769110122
+ },
+ "next": [{
+ "firstLine": "L'Instant poésie",
+ "firstLinePath": "franceculture/podcasts/l-instant-poesie",
+ "firstLineUuid": "06fe22c7-144c-41b8-983d-ec956595b694",
+ "secondLine": "L'Instant poésie d'Abd al Malik 14/20 : \"Roman inachevé\" de Louis Aragon, une main tendue",
+ "cover": "a18a392b-f7d5-41bd-972a-e64451f35213",
+ "startTime": 1769110200,
+ "endTime": 1769110555
+ }],
+ "delayToRefresh": 742000
+}
+```
+
+**Paramètres optionnels :**
+- `?date=` : Récupérer les métadonnées à un moment donné (historique)
+
+#### API Pikapi (images de couverture)
+```
+https://www.radiofrance.fr/pikapi/images/{uuid}/{taille}
+```
+
+**Exemples :**
+- `https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39/200x200`
+- Autres tailles disponibles (à tester)
+
+### Anciennes tentatives (pour référence historique)
+Les tentatives d'accès aux endpoints suivants ont échoué :
+- `https://www.radiofrance.fr/api/v2.1/stations/franceculture` → retourne du HTML
+- `https://www.radiofrance.fr/api/v2.1/stations/franceculture/live` → retourne du HTML
+- `https://openapi.radiofrance.fr/v1/graphql` → nécessite un header `x-token`
+
+### Données embarquées dans le HTML (SSR)
+Les informations sont également directement rendues dans le HTML par le serveur SvelteKit (méthode de fallback).
+
+## Structure HTML des métadonnées
+
+### Zone principale : CoverRadio
+Les informations de l'émission en cours se trouvent dans la section `class="CoverRadio"` :
+
+```html
+
+
+
+
+
+
+
+ Les Matins
+ par Guillaume Erner
+
+
+
+
+
+
+```
+
+### Classes CSS identifiées
+
+| Classe CSS | Contenu | Utilité |
+|------------|---------|---------|
+| `CoverRadio-title` | Titre du segment/chronique en cours | Titre principal |
+| `CoverRadio-subtitle` | Nom de l'émission parente | Contexte de diffusion |
+| `CoverRadio-producer` | Nom du producteur/animateur | Crédit |
+| `CoverRadio-labelDirect` | Badge "En direct" | Statut de diffusion |
+
+## Stratégies d'extraction
+
+### Option 1 : Scraping HTML simple
+Récupérer la page HTML et extraire les données via :
+- Parsing HTML (BeautifulSoup en Python, scraper en Rust)
+- Regex ciblées sur les classes CSS
+
+**Avantages :**
+- Pas de token nécessaire
+- Données toujours présentes dans le HTML
+- Méthode robuste
+
+**Inconvénients :**
+- Dépendant de la structure HTML
+- Risque de cassure si le site change
+- Parsing HTML plus lourd
+
+### Option 2 : API GraphQL avec token
+L'API GraphQL existe (`https://openapi.radiofrance.fr/v1/graphql`) mais nécessite un `x-token`.
+
+**Étapes :**
+1. Analyser le code JavaScript du site pour trouver comment le token est généré
+2. Extraire ou reproduire la logique de génération de token
+3. Utiliser l'API GraphQL
+
+**Avantages :**
+- API structurée et officielle
+- Données JSON propres
+- Moins de risque de changement
+
+**Inconvénients :**
+- Nécessite un token (non documenté publiquement)
+- Potentiellement bloqué/limité en débit
+- Reverse engineering requis
+
+### Option 3 : API interne SvelteKit
+SvelteKit utilise des endpoints `/__data.json` pour l'hydratation client.
+
+**À explorer :**
+- `https://www.radiofrance.fr/franceculture/__data.json`
+- Endpoints de données internes
+
+## Recommandation
+
+### Pour un projet comme PMOMusic (pmoradiofrance)
+
+**Approche hybride recommandée :**
+
+1. **Court terme : Scraping HTML**
+ - Implémenter un parser HTML en Rust
+ - Cibler les classes CSS `CoverRadio-*`
+ - Parser avec `scraper` ou `select` en Rust
+
+2. **Moyen terme : Investigation API**
+ - Analyser le code JavaScript pour trouver le token
+ - Tenter d'utiliser l'API GraphQL si possible
+
+3. **Mise en cache et rafraîchissement**
+ - Rafraîchir les métadonnées toutes les 1-5 minutes
+ - Mettre en cache pour éviter les requêtes excessives
+
+## Exemple de code conceptuel (Rust)
+
+```rust
+use scraper::{Html, Selector};
+
+async fn fetch_current_show() -> Result {
+ let html = reqwest::get("https://www.radiofrance.fr/franceculture")
+ .await?
+ .text()
+ .await?;
+
+ let document = Html::parse_document(&html);
+
+ // Sélecteurs CSS
+ let title_selector = Selector::parse(".CoverRadio-title a").unwrap();
+ let subtitle_selector = Selector::parse(".CoverRadio-subtitle a").unwrap();
+ let producer_selector = Selector::parse(".CoverRadio-producer").unwrap();
+
+ let title = document
+ .select(&title_selector)
+ .next()
+ .map(|e| e.inner_html())
+ .unwrap_or_default();
+
+ let show_name = document
+ .select(&subtitle_selector)
+ .next()
+ .map(|e| e.inner_html())
+ .unwrap_or_default();
+
+ let producer = document
+ .select(&producer_selector)
+ .next()
+ .map(|e| e.inner_html().replace("par ", ""))
+ .unwrap_or_default();
+
+ Ok(ShowInfo {
+ title,
+ show_name,
+ producer,
+ })
+}
+```
+
+## Points d'attention
+
+1. **Rate limiting** : Ne pas surcharger le site avec des requêtes trop fréquentes
+2. **User-Agent** : Utiliser un User-Agent identifiable pour un projet open-source
+3. **Gestion d'erreurs** : Le site peut être temporairement indisponible
+4. **Structure HTML** : Peut changer sans préavis
+5. **Respect des CGU** : Vérifier les conditions d'utilisation de Radio France
+
+## Mise à jour de la page côté client
+
+### Comment la page se rafraîchit-elle ?
+
+**Réponse : La page ne se met PAS à jour automatiquement côté client.**
+
+Après analyse :
+1. **Pas de polling/WebSocket** : Aucun mécanisme de `setInterval`, `setTimeout`, WebSocket ou Server-Sent Events (SSE) détecté dans le HTML
+2. **Pas de JavaScript de mise à jour** : Le DOM n'est pas modifié dynamiquement pour les métadonnées `CoverRadio-*`
+3. **Navigation SvelteKit** : Les mises à jour se font via la navigation SPA de SvelteKit
+
+### Mécanisme de navigation SvelteKit
+
+SvelteKit utilise le **preloading** et les **endpoints `__data.json`** :
+
+```
+https://www.radiofrance.fr/franceculture/__data.json
+```
+
+Cet endpoint retourne un **JSON structuré** contenant toutes les données de la page, incluant :
+- Métadonnées de l'émission en cours
+- Configuration du site
+- Contenu de la page
+
+**Format de données** :
+```json
+{
+ "type": "data",
+ "nodes": [
+ {
+ "metadata": { ... },
+ "context": { ... },
+ "mainStationLive": { ... }
+ }
+ ]
+}
+```
+
+### Stratégie de rafraîchissement
+
+Pour un utilisateur sur le site :
+1. **Chargement initial** : SSR complet avec HTML
+2. **Navigation ultérieure** : SvelteKit charge `__data.json` en AJAX
+3. **Rechargement manuel** : L'utilisateur doit recharger la page (F5) pour voir les nouvelles métadonnées
+
+**Il n'y a pas de mise à jour automatique en temps réel.**
+
+## Recommandation mise à jour
+
+### 🏆 Option privilégiée : API LiveMeta officielle (DÉCOUVERTE !)
+
+**URL :** `https://api.radiofrance.fr/livemeta/live/5/transistor_culture_player`
+
+**Avantages :**
+- ✅ **API officielle Radio France** : Endpoint public et documenté
+- ✅ **Aucune authentification** : Pas de token, pas de restriction
+- ✅ **JSON léger et structuré** : Format simple et prévisible
+- ✅ **Données optimales** : Juste ce qu'il faut (prev/now/next)
+- ✅ **Polling intelligent** : `delayToRefresh` indique quand rafraîchir
+- ✅ **Stable** : API de production utilisée par le site officiel
+- ✅ **Support historique** : Paramètre `?date=` pour l'historique
+- ✅ **UUIDs** : Références pour récupérer plus de détails si besoin
+
+**Inconvénients :**
+- Aucun majeur identifié
+
+**Code Rust recommandé :**
+```rust
+use serde::{Deserialize, Serialize};
+use reqwest;
+
+#[derive(Debug, Deserialize, Serialize)]
+struct LiveMetadata {
+ prev: Vec,
+ now: ShowInfo,
+ next: Vec,
+ #[serde(rename = "delayToRefresh")]
+ delay_to_refresh: u64,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+struct ShowInfo {
+ #[serde(rename = "firstLine")]
+ first_line: String,
+ #[serde(rename = "firstLineUuid")]
+ first_line_uuid: Option,
+ #[serde(rename = "firstLinePath")]
+ first_line_path: Option,
+ #[serde(rename = "secondLine")]
+ second_line: String,
+ cover: String,
+ #[serde(rename = "startTime")]
+ start_time: Option,
+ #[serde(rename = "endTime")]
+ end_time: Option,
+}
+
+async fn fetch_franceculture_live() -> Result {
+ let url = "https://api.radiofrance.fr/livemeta/live/5/transistor_culture_player";
+
+ reqwest::get(url)
+ .await?
+ .json::()
+ .await
+}
+
+// Utilisation avec polling intelligent
+async fn monitor_live() {
+ loop {
+ match fetch_franceculture_live().await {
+ Ok(metadata) => {
+ println!("En cours : {} - {}",
+ metadata.now.first_line,
+ metadata.now.second_line
+ );
+
+ // Attendre le temps recommandé avant de rafraîchir
+ tokio::time::sleep(
+ tokio::time::Duration::from_millis(metadata.delay_to_refresh)
+ ).await;
+ }
+ Err(e) => {
+ eprintln!("Erreur : {}", e);
+ // Fallback : attendre 60 secondes
+ tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
+ }
+ }
+ }
+}
+```
+
+### Hiérarchie des options (mise à jour)
+
+1. **🥇 Premier choix : API LiveMeta** - API officielle Radio France
+2. **🥈 Fallback niveau 1 : `__data.json`** - Endpoint SvelteKit si LiveMeta indisponible
+3. **🥉 Fallback niveau 2 : Scraping HTML** - Si les API JSON sont toutes indisponibles
+4. **💭 Exploration future : API GraphQL** - Si un token public devient disponible
+
+## Conclusion
+
+**Pour la mise à jour côté serveur (PMOMusic) :**
+- ✅ **Utiliser l'API LiveMeta officielle** : `https://api.radiofrance.fr/livemeta/live/5/transistor_culture_player`
+- ✅ **Polling intelligent** : Utiliser `delayToRefresh` pour optimiser les appels
+- ✅ **Récupération des images** : Via Pikapi avec l'UUID de `cover`
+- ✅ **Gestion d'erreur** : Fallback sur `__data.json` puis HTML si nécessaire
+
+**Pour la page web elle-même :**
+- **Aucune mise à jour automatique** : L'utilisateur doit recharger la page manuellement
+- Navigation SPA via SvelteKit charge `__data.json` en AJAX
+- Le SSR initial contient déjà toutes les données dans le HTML
+
+## URLs de flux audio découvertes
+
+### Flux HLS (recommandé)
+
+**Master playlist :**
+```
+https://stream.radiofrance.fr/franceculture/franceculture.m3u8?id=radiofrance
+```
+
+**Qualités disponibles :**
+- **lofi** : 105 kbps (BANDWIDTH=107000) - `franceculture_lofi.m3u8?id=radiofrance`
+- **midfi** : 178 kbps (BANDWIDTH=185000) - `franceculture_midfi.m3u8?id=radiofrance`
+- **hifi** : 268 kbps (BANDWIDTH=280000) - `franceculture_hifi.m3u8?id=radiofrance`
+
+Codec : `mp4a.40.2` (AAC-LC)
+
+### Flux Icecast (à confirmer)
+
+D'après RF_old.json, ces URLs devraient exister (non observées dans le HAR car le player web utilise HLS) :
+
+**MP3 :**
+```
+https://icecast.radiofrance.fr/franceculture-lofi.mp3?id=radiofrance
+https://icecast.radiofrance.fr/franceculture-midfi.mp3?id=radiofrance
+https://icecast.radiofrance.fr/franceculture-hifi.mp3?id=radiofrance
+```
+
+**AAC :**
+```
+https://icecast.radiofrance.fr/franceculture-lofi.aac?id=radiofrance
+https://icecast.radiofrance.fr/franceculture-midfi.aac?id=radiofrance
+https://icecast.radiofrance.fr/franceculture-hifi.aac?id=radiofrance
+```
+
+## Mapping des stations Radio France
+
+D'après l'analyse du fichier HAR et RF_old.json, voici le mapping des IDs de stations :
+
+| Station | ID Station | Endpoint LiveMeta |
+|---------|-----------|-------------------|
+| France Culture | 5 | `/livemeta/live/5/transistor_culture_player` |
+| France Inter | ? | À découvrir |
+| France Musique | ? | À découvrir |
+| FIP | ? | À découvrir |
+| Mouv' | ? | À découvrir |
+| France Bleu (national) | ? | À découvrir |
+
+**Note :** Les IDs des autres stations peuvent être découverts en analysant le HAR de leurs pages respectives ou en testant des valeurs séquentielles (1, 2, 3, 4, 6, 7...).
+
+## Prochaines étapes recommandées
+
+1. ✅ **Implémenter le client LiveMeta** en Rust avec les structures proposées
+2. 🔍 **Découvrir les IDs des autres stations** Radio France
+3. 🔍 **Tester les URLs Icecast** pour confirmer leur disponibilité
+4. 📋 **Documenter l'API complète** dans le code PMOMusic
+5. 🧪 **Tester le paramètre `?date=`** pour l'accès historique
+6. 🎨 **Tester les tailles d'images Pikapi** disponibles (200x200, 400x400, etc.)
+
+## Annexe : Analyse du fichier HAR
+
+**Source :** `www.radiofrance.fr.har`
+**Date de capture :** 2026-01-22
+**Page analysée :** https://www.radiofrance.fr/franceculture
+
+**Découvertes principales :**
+- API LiveMeta accessible et ouverte
+- Aucune authentification requise
+- Polling intelligent via `delayToRefresh`
+- Support HLS multi-bitrate
+- API Pikapi pour les images
+
+Cette analyse confirme que Radio France expose des APIs publiques utilisables pour des projets comme PMOMusic.
diff --git a/Blackboard/ToThinkAbout/api_radiofrance_complete.md b/Blackboard/ToThinkAbout/api_radiofrance_complete.md
new file mode 100644
index 00000000..91ef83ab
--- /dev/null
+++ b/Blackboard/ToThinkAbout/api_radiofrance_complete.md
@@ -0,0 +1,1227 @@
+# API Radio France - Documentation complète
+
+## Vue d'ensemble
+
+Radio France expose plusieurs APIs publiques **sans authentification** pour accéder aux métadonnées des émissions en direct et aux flux audio.
+
+**Date d'analyse :** 2026-01-22
+**Sources :** Analyse de fichiers HAR + tests directs
+
+---
+
+## 1. API Live par station
+
+### Format général
+```
+https://www.radiofrance.fr/{station}/api/live?
+```
+
+### Stations disponibles
+
+| Station | Endpoint | Status |
+|---------|----------|--------|
+| France Inter | `/franceinter/api/live?` | ✅ Fonctionne |
+| France Info | `/franceinfo/api/live?` | ✅ Fonctionne |
+| France Culture | `/franceculture/api/live?` | ✅ Fonctionne |
+| France Musique | `/francemusique/api/live?` | ✅ Fonctionne |
+| FIP | `/fip/api/live?` | ✅ Fonctionne |
+| Mouv' | `/mouv/api/live?` | ✅ Fonctionne |
+| France Bleu (national) | `/francebleu/api/live?` | ✅ Fonctionne |
+| Mon Petit France Inter | `/monpetitfranceinter/api/live?` | ✅ Fonctionne |
+
+### Structure de réponse
+
+```json
+{
+ "stationName": "franceculture",
+ "delayToRefresh": 262000,
+ "migrated": true,
+ "now": {
+ "printProgMusic": true,
+ "startTime": 1769108400,
+ "endTime": 1769110122,
+ "producer": "Nom du producteur",
+ "firstLine": {
+ "title": "Nom de l'émission",
+ "id": "uuid-emission",
+ "path": "franceculture/podcasts/emission"
+ },
+ "secondLine": {
+ "title": "Titre de l'épisode/chronique",
+ "id": "uuid-episode",
+ "path": "franceculture/podcasts/emission/episode"
+ },
+ "thirdLine": {
+ "title": "Sous-titre éventuel",
+ "id": "uuid",
+ "path": null
+ },
+ "intro": "Description de l'émission...",
+ "reactAvailable": false,
+ "visualBackground": {
+ "model": "EmbedImage",
+ "src": "https://www.radiofrance.fr/pikapi/images/uuid",
+ "width": 4000,
+ "height": 1000,
+ "dominant": "#c8e8f8",
+ "copyright": "Radio France"
+ },
+ "song": {
+ "id": "uuid-morceau",
+ "year": 2024,
+ "interpreters": ["Artiste"],
+ "release": {
+ "label": "Label",
+ "title": "Album",
+ "reference": null
+ }
+ },
+ "media": {
+ "sources": [
+ {
+ "url": "https://icecast.radiofrance.fr/franceculture-lofi.mp3?id=radiofrance",
+ "broadcastType": "live",
+ "format": "mp3",
+ "bitrate": 32
+ },
+ {
+ "url": "https://stream.radiofrance.fr/franceculture/franceculture.m3u8?id=radiofrance",
+ "broadcastType": "live",
+ "format": "hls",
+ "bitrate": 0
+ },
+ {
+ "url": "https://icecast.radiofrance.fr/franceculture-hifi.aac?id=radiofrance",
+ "broadcastType": "live",
+ "format": "aac",
+ "bitrate": 192
+ },
+ {
+ "url": "https://icecast.radiofrance.fr/franceculture-midfi.aac?id=radiofrance",
+ "broadcastType": "live",
+ "format": "aac",
+ "bitrate": 128
+ },
+ {
+ "url": "https://stream.radiofrance.fr/franceculture/franceculture.m3u8?id=radiofrance",
+ "broadcastType": "timeshift",
+ "format": "hls",
+ "bitrate": 0
+ }
+ ]
+ },
+ "localRadios": [],
+ "visuals": {
+ "card": { /* Image pour la carte */ },
+ "player": { /* Image pour le player */ }
+ }
+ },
+ "next": {
+ /* Même structure pour l'émission suivante */
+ }
+}
+```
+
+### Champs importants
+
+- **`delayToRefresh`** : Temps en millisecondes avant le prochain rafraîchissement recommandé
+- **`now.song`** : Présent si c'est une musique (FIP, France Musique)
+- **`now.media.sources`** : Liste de tous les flux disponibles avec formats et bitrates
+- **`localRadios`** : Liste des radios locales (pour France Bleu)
+
+---
+
+## 2. API LiveMeta (ancienne API, toujours fonctionnelle)
+
+### Format
+```
+https://api.radiofrance.fr/livemeta/live/{id}/transistor_{station}_player
+```
+
+### IDs connus
+
+| Station | ID | Endpoint |
+|---------|-----|----------|
+| France Culture | 5 | `/livemeta/live/5/transistor_culture_player` |
+
+### Exemple de réponse
+
+```json
+{
+ "prev": [{
+ "firstLine": "Le direct",
+ "secondLine": "France Culture, l'esprit d'ouverture",
+ "cover": "uuid-image",
+ "startTime": null,
+ "endTime": null
+ }],
+ "now": {
+ "firstLine": "La Série fiction",
+ "firstLineUuid": "uuid",
+ "firstLinePath": "franceculture/podcasts/emission",
+ "secondLine": "Titre de l'épisode",
+ "cover": "uuid-image",
+ "startTime": 1769108400,
+ "endTime": 1769110122
+ },
+ "next": [{ /* émission suivante */ }],
+ "delayToRefresh": 742000
+}
+```
+
+**Note :** Cette API retourne moins de détails que `/api/live?` mais fonctionne toujours.
+
+---
+
+## 3. Flux audio
+
+### Format des URLs
+
+#### HLS (recommandé)
+```
+https://stream.radiofrance.fr/{station}/{station}.m3u8?id=radiofrance
+```
+
+#### Icecast (AAC et MP3)
+```
+https://icecast.radiofrance.fr/{station}-{qualite}.{format}?id=radiofrance
+```
+
+### Qualités disponibles
+
+| Qualité | Bitrate AAC | Bitrate MP3 | Utilisation |
+|---------|-------------|-------------|-------------|
+| `lofi` | 32 kbps | 32 kbps | Connexions lentes |
+| `midfi` | 128 kbps | 128 kbps | Standard |
+| `hifi` | 192 kbps | - | Haute qualité |
+
+### Exemples d'URLs
+
+**France Culture :**
+```
+https://stream.radiofrance.fr/franceculture/franceculture.m3u8?id=radiofrance
+https://icecast.radiofrance.fr/franceculture-hifi.aac?id=radiofrance
+https://icecast.radiofrance.fr/franceculture-midfi.aac?id=radiofrance
+https://icecast.radiofrance.fr/franceculture-midfi.mp3?id=radiofrance
+https://icecast.radiofrance.fr/franceculture-lofi.aac?id=radiofrance
+https://icecast.radiofrance.fr/franceculture-lofi.mp3?id=radiofrance
+```
+
+**France Inter :**
+```
+https://stream.radiofrance.fr/franceinter/franceinter.m3u8?id=radiofrance
+https://icecast.radiofrance.fr/franceinter-hifi.aac?id=radiofrance
+https://icecast.radiofrance.fr/franceinter-midfi.aac?id=radiofrance
+https://icecast.radiofrance.fr/franceinter-midfi.mp3?id=radiofrance
+https://icecast.radiofrance.fr/franceinter-lofi.aac?id=radiofrance
+https://icecast.radiofrance.fr/franceinter-lofi.mp3?id=radiofrance
+```
+
+---
+
+## 4. Webradios thématiques
+
+### FIP Webradios
+
+FIP propose plusieurs webradios thématiques. Format des URLs :
+
+```
+https://icecast.radiofrance.fr/fip{variant}-{qualite}.aac?id=radiofrance
+```
+
+#### Variantes disponibles (confirmées)
+
+| Variante | URL | Status |
+|----------|-----|--------|
+| FIP principale | `fip-hifi.aac` | ✅ |
+| FIP Rock | `fiprock-hifi.aac` | ✅ |
+| FIP Jazz | `fipjazz-hifi.aac` | ✅ |
+| FIP Groove | `fipgroove-hifi.aac` | ✅ |
+| FIP Reggae | `fipreggae-hifi.aac` | ✅ |
+| FIP Electro | `fipelectro-hifi.aac` | ✅ |
+| FIP Metal | `fipmetal-hifi.aac` | ✅ |
+| FIP Nouveautés | `fipnouveautes-hifi.aac` | ✅ |
+| FIP Pop | `fippop-hifi.aac` | ✅ |
+
+**Exemples :**
+```
+https://icecast.radiofrance.fr/fiprock-hifi.aac?id=radiofrance
+https://icecast.radiofrance.fr/fipjazz-midfi.aac?id=radiofrance
+https://icecast.radiofrance.fr/fipgroove-lofi.aac?id=radiofrance
+```
+
+### France Musique Webradios
+
+Format similaire :
+
+```
+https://icecast.radiofrance.fr/francemusique{variant}-{qualite}.aac?id=radiofrance
+```
+
+#### Variantes disponibles (confirmées)
+
+| Variante | URL | Status |
+|----------|-----|--------|
+| France Musique principale | `francemusique-hifi.aac` | ✅ |
+| La Jazz | `francemusiquelajazz-hifi.aac` | ✅ |
+| La Contemporaine | `francemusiquelacontemporaine-hifi.aac` | ✅ |
+| Baroque | `francemusiquebaroque-hifi.aac` | ✅ |
+| Opéra | `francemusiqueopera-hifi.aac` | ✅ |
+
+**Exemples :**
+```
+https://icecast.radiofrance.fr/francemusiquelajazz-hifi.aac?id=radiofrance
+https://icecast.radiofrance.fr/francemusiquebaroque-midfi.aac?id=radiofrance
+```
+
+---
+
+## 5. France Bleu - Radios locales
+
+### API
+```
+https://www.radiofrance.fr/francebleu/api/live?
+```
+
+### Structure spécifique
+
+Le champ `localRadios` contient la liste de toutes les radios locales :
+
+```json
+{
+ "stationName": "francebleu",
+ "delayToRefresh": 2090000,
+ "now": { /* ... */ },
+ "localRadios": [
+ {
+ "id": 12,
+ "title": "ICI Alsace",
+ "name": "francebleu_alsace",
+ "isOnAir": true
+ },
+ {
+ "id": 13,
+ "title": "ICI Armorique",
+ "name": "francebleu_armorique",
+ "isOnAir": true
+ }
+ // ... ~40 radios locales
+ ]
+}
+```
+
+### Format des flux locaux
+
+**Hypothèse (à confirmer) :**
+```
+https://icecast.radiofrance.fr/fb{nom}-hifi.aac?id=radiofrance
+```
+
+Exemple :
+```
+https://icecast.radiofrance.fr/fbalsace-hifi.aac?id=radiofrance
+```
+
+---
+
+## 6. API Pikapi (Images)
+
+### Format
+```
+https://www.radiofrance.fr/pikapi/images/{uuid}/{taille}
+```
+
+### Tailles disponibles
+
+Basé sur l'analyse des réponses, plusieurs tailles semblent disponibles :
+
+- `88x88` - Miniature
+- `200x200` - Petite
+- `420x720` - Moyenne portrait
+- `560x960` - Grande portrait
+- `1200x680` - Grande paysage
+- `raw` - Taille originale
+
+**Exemples :**
+```
+https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39/200x200
+https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39/1200x680
+```
+
+---
+
+## 7. Autres endpoints (configuration)
+
+D'après l'analyse du fichier HAR, voici d'autres endpoints internes utilisés :
+
+### Endpoints de configuration (dans `__data.json`)
+
+- **`https://kirby.radiofrance.fr`** - CMS Kirby
+- **`https://www.radiofrance.fr/pikapi`** - API images
+- **`https://www.radiofrance.fr/transistor`** - API Transistor
+- **`https://api.radiofrance.fr/livemeta/live`** - API LiveMeta
+- **`https://preroll.radiofrance.fr`** - Publicités pre-roll
+
+### API Expressions (contenu éditorial)
+
+```
+https://www.radiofrance.fr/api/expressions?variant=vertical&limit=36&ids={uuid,uuid,...}
+```
+
+Retourne des contenus éditoriaux par UUIDs.
+
+---
+
+## 8. Résumé pour PMOMusic
+
+### Recommandations d'implémentation
+
+#### Pour les métadonnées live
+
+**Option 1 (recommandée) :** API `/api/live?` par station
+```rust
+async fn fetch_live_metadata(station: &str) -> Result {
+ let url = format!("https://www.radiofrance.fr/{}/api/live?", station);
+ reqwest::get(&url).await?.json().await
+}
+```
+
+**Avantages :**
+- ✅ Données complètes (émission, producteur, intro, visuels)
+- ✅ Flux audio inclus dans la réponse
+- ✅ `delayToRefresh` pour polling intelligent
+- ✅ Support des radios locales (France Bleu)
+
+#### Pour les flux audio
+
+**Priorisation recommandée :**
+
+1. **HLS** (format moderne, adaptatif)
+2. **AAC hifi** (192 kbps, meilleure qualité)
+3. **AAC midfi** (128 kbps, bon compromis)
+4. **MP3 midfi** (128 kbps, compatibilité maximale)
+5. **AAC/MP3 lofi** (32 kbps, fallback)
+
+#### Polling intelligent
+
+Utiliser le champ `delayToRefresh` pour optimiser :
+
+```rust
+loop {
+ let metadata = fetch_live_metadata("franceculture").await?;
+
+ // Afficher/utiliser les métadonnées
+ println!("{} - {}",
+ metadata.now.first_line.title,
+ metadata.now.second_line.title
+ );
+
+ // Attendre le temps recommandé
+ tokio::time::sleep(
+ Duration::from_millis(metadata.delay_to_refresh)
+ ).await;
+}
+```
+
+### Liste complète des stations à supporter
+
+**Stations principales :**
+- France Inter
+- France Info
+- France Culture
+- France Musique
+- FIP
+- Mouv'
+- Mon Petit France Inter
+
+**Webradios FIP (9):**
+- FIP principale
+- FIP Rock, Jazz, Groove, Reggae, Electro, Metal, Nouveautés, Pop
+
+**Webradios France Musique (5+):**
+- France Musique principale
+- La Jazz, La Contemporaine, Baroque, Opéra
+
+**Radios locales France Bleu (~40):**
+- À récupérer dynamiquement via `/francebleu/api/live?`
+
+---
+
+## 9. Points d'attention
+
+### Rate limiting
+- Pas de limite documentée observée
+- Utiliser `delayToRefresh` pour respecter les recommandations
+- Éviter les requêtes inutiles (cache local)
+
+### User-Agent
+Pour un projet open-source, utiliser un User-Agent identifiable :
+```
+PMOMusic/0.3.10 (https://github.com/votre-repo)
+```
+
+### Gestion d'erreurs
+- Les APIs peuvent retourner des données vides (`null`)
+- Le champ `song` n'existe que pour les radios musicales
+- `localRadios` n'existe que pour France Bleu
+
+### Respect des CGU
+- Ces APIs sont utilisées par le site officiel
+- Usage pour un projet open-source personnel/non-commercial
+- Ne pas redistribuer les flux audio commercialement
+
+---
+
+## 10. Annexes
+
+### Exemple complet en Rust
+
+```rust
+use serde::{Deserialize, Serialize};
+use reqwest;
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct LiveResponse {
+ pub station_name: String,
+ pub delay_to_refresh: u64,
+ pub migrated: bool,
+ pub now: ShowMetadata,
+ pub next: Option,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ShowMetadata {
+ pub start_time: Option,
+ pub end_time: Option,
+ pub producer: Option,
+ pub first_line: Line,
+ pub second_line: Line,
+ pub third_line: Option,
+ pub intro: Option,
+ pub song: Option,
+ pub media: Media,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct Line {
+ pub title: Option,
+ pub id: Option,
+ pub path: Option,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct Song {
+ pub id: String,
+ pub year: Option,
+ pub interpreters: Vec,
+ pub release: Release,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct Release {
+ pub label: Option,
+ pub title: Option,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct Media {
+ pub sources: Vec,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Source {
+ pub url: String,
+ pub broadcast_type: String,
+ pub format: String,
+ pub bitrate: u32,
+}
+
+pub async fn get_live_metadata(station: &str) -> Result {
+ let url = format!("https://www.radiofrance.fr/{}/api/live?", station);
+
+ reqwest::Client::new()
+ .get(&url)
+ .header("User-Agent", "PMOMusic/0.3.10")
+ .send()
+ .await?
+ .json()
+ .await
+}
+```
+
+### Stations complètes testées
+
+| Station | API Live | Flux HLS | Flux AAC | Flux MP3 |
+|---------|----------|----------|----------|----------|
+| France Inter | ✅ | ✅ | ✅ | ✅ |
+| France Info | ✅ | ✅ | ✅ | ✅ |
+| France Culture | ✅ | ✅ | ✅ | ✅ |
+| France Musique | ✅ | ✅ | ✅ | ✅ |
+| FIP | ✅ | ✅ | ✅ | ✅ |
+| Mouv' | ✅ | ✅ | ✅ | ✅ |
+| France Bleu | ✅ | ✅ | ✅ | ✅ |
+| Mon Petit France Inter | ✅ | ✅ (à tester) | ✅ (à tester) | ✅ (à tester) |
+
+---
+
+**Dernière mise à jour :** 2026-01-22
+**Méthode d'analyse :** Capture HAR + tests directs des endpoints
+**Statut :** Toutes les APIs sont publiques et fonctionnelles sans authentification
+
+---
+
+# Round 4 : Architecture Client Stateful pour PMORadioFrance
+
+## Date : 2026-01-22
+
+## Contexte
+
+Le Round 3 a permis d'implémenter un client HTTP basique (`RadioFranceClient`) pour interroger l'API Radio France. Ce client est **stateless** : il ne gère pas de cache, ne maintient pas d'état, et doit interroger l'API à chaque requête.
+
+Le Round 4 vise à construire la couche suivante : un **client stateful** qui :
+- Gère un cache des stations découvertes avec TTL
+- Expose des méthodes de haut niveau pour obtenir des listes de radios
+- Prépare les données pour la construction de la source UPnP
+- Intègre avec pmoconfig pour stocker les informations persistantes
+
+## Objectifs du client stateful
+
+### 1. Cache intelligent des stations
+
+**Problématique** : La découverte de toutes les stations (méthode `discover_all_stations()`) fait ~10 requêtes HTTP et prend 3-5 secondes. Les stations Radio France ne changent que très rarement (nouvelles webradios ~1-2 fois par an, nouvelles stations locales jamais).
+
+**Solution** : Utiliser pmoconfig pour stocker la liste des stations avec un timestamp, et ne rafraîchir que si le TTL est dépassé (ou sur requête forcée).
+
+**Stratégie de cache** :
+```yaml
+# Dans .pmomusic/config.yaml
+sources:
+ radiofrance:
+ stations_cache:
+ version: 1 # Version du schéma de découverte
+ last_updated: 1737565200 # Unix timestamp
+ ttl_days: 7 # TTL par défaut : 7 jours
+ stations:
+ - slug: "franceculture"
+ name: "France Culture"
+ type: "main"
+ - slug: "fip"
+ name: "FIP"
+ type: "main"
+ - slug: "fip_rock"
+ name: "FIP Rock"
+ type: "webradio"
+ parent: "fip"
+ - slug: "francebleu_alsace"
+ name: "ICI Alsace"
+ type: "local"
+ region: "Alsace"
+ id: 12
+ # ... ~50+ stations au total
+```
+
+**Logique de rafraîchissement** :
+1. Lire le cache depuis la config
+2. Vérifier `version` (invalide si ancienne version de découverte)
+3. Vérifier TTL : `now - last_updated < ttl_days * 86400`
+4. Si valide : retourner le cache
+5. Si invalide ou absent : appeler `discover_all_stations()` et mettre à jour la config
+
+### 2. Organisation des stations
+
+Les stations doivent être organisées logiquement pour la navigation UPnP :
+
+```
+Radio France (racine)
+├── France Culture
+├── France Inter
+├── France Info
+├── France Musique
+├── FIP
+│ ├── FIP (principale)
+│ ├── FIP Rock
+│ ├── FIP Jazz
+│ ├── FIP Groove
+│ ├── FIP Reggae
+│ ├── FIP Electro
+│ ├── FIP Metal
+│ ├── FIP Nouveautés
+│ └── FIP Pop
+├── Mouv'
+└── ICI (France Bleu renommé)
+ ├── ICI Alsace
+ ├── ICI Armorique
+ ├── ICI Auxerre
+ ├── ... (~40 radios locales)
+ └── ICI Vaucluse
+```
+
+**Règles de regroupement** :
+- **Stations principales** : Une entrée par station principale (France Culture, France Inter, etc.)
+- **Stations avec webradios** (FIP, France Musique) : Un folder contenant :
+ 1. La station principale en premier
+ 2. Les webradios triées alphabétiquement
+- **France Bleu** : Renommé "ICI" avec toutes les radios locales dedans
+
+**Changement de label** :
+- API retourne : `"France Bleu"` → Affichage : `"ICI"`
+- API retourne : `"ICI Alsace"` → Affichage : `"ICI Alsace"` (inchangé)
+- Slugs conservés tels quels : `francebleu_alsace`, `francebleu`, etc.
+
+### 3. Métadonnées live avec rafraîchissement intelligent
+
+Pour chaque station, on doit pouvoir obtenir les métadonnées live avec cache court terme :
+
+**Cache de métadonnées live** :
+- Durée : Utiliser le champ `delayToRefresh` de l'API (généralement 2-5 minutes)
+- Stockage : En mémoire uniquement (pas dans pmoconfig)
+- Invalidation : Automatique après `delayToRefresh` millisecondes
+
+**Stratégie** :
+```rust
+struct LiveMetadataCache {
+ metadata: LiveResponse,
+ fetched_at: SystemTime,
+ valid_until: SystemTime,
+}
+
+// Pseudo-code
+fn get_live_metadata(station: &str) -> Result {
+ if let Some(cached) = memory_cache.get(station) {
+ if SystemTime::now() < cached.valid_until {
+ return Ok(cached.metadata.clone());
+ }
+ }
+
+ let metadata = client.live_metadata(station).await?;
+ let delay = Duration::from_millis(metadata.delay_to_refresh);
+
+ memory_cache.insert(station, LiveMetadataCache {
+ metadata: metadata.clone(),
+ fetched_at: SystemTime::now(),
+ valid_until: SystemTime::now() + delay,
+ });
+
+ Ok(metadata)
+}
+```
+
+### 4. Construction de playlists pour la source UPnP
+
+Chaque station doit être exposée comme une **playlist volatile** contenant un seul item : le stream de plus haute qualité.
+
+**Règles métier pour les playlists** :
+
+#### Format des playlists
+
+```rust
+// Pseudo-structure d'une playlist de station
+PMOPlaylist {
+ id: "radiofrance:franceculture",
+ role: PlaylistRole::Radio,
+ volatile: true, // Les métadonnées changent, pas le contenu
+
+ // Métadonnées de la playlist (= métadonnées de la station)
+ title: "France Culture", // Nom de la station
+ artist: "Les Matins", // Nom de l'émission en cours (now.firstLine.title)
+ album: "France Culture", // Nom de la station (répété)
+ cover_pk: "COVER_PK", // Cover de l'émission en cours (now.visualBackground)
+
+ // Contenu : UN SEUL ITEM
+ items: [
+ PMOItem {
+ id: "radiofrance:franceculture:stream",
+ title: "Le Journal de l'éco • Le jouet profite...", // now.secondLine.title
+ artist: "Guillaume Erner", // now.producer ou show producer
+ album: "Les Matins", // now.firstLine.title (émission)
+ genre: "Talk Radio", // Type de station
+
+ // Stream URL (AAC 192 kbps ou HLS)
+ url: "https://icecast.radiofrance.fr/franceculture-hifi.aac?id=radiofrance",
+
+ // Métadonnées techniques
+ protocol_info: "http-get:*:audio/aac:*",
+ bitrate: 192000,
+ sample_rate: 48000,
+ channels: 2,
+
+ // Cover de l'émission/morceau
+ cover_pk: "COVER_PK",
+ }
+ ]
+}
+```
+
+#### Mapping des métadonnées API → UPnP
+
+**Pour les radios parlées (France Culture, France Inter, France Info)** :
+
+| Champ UPnP | Source API | Exemple |
+|------------|------------|---------|
+| Playlist Title | Station name | "France Culture" |
+| Playlist Artist | `now.firstLine.title` | "Les Matins" |
+| Playlist Cover | `now.visualBackground` → cache | UUID de cover |
+| Item Title | `now.firstLine.title` + `now.secondLine.title` | "Les Matins • Le Journal de l'éco" |
+| Item Artist | `now.producer` | "Guillaume Erner" |
+| Item Album | `now.firstLine.title` | "Les Matins" |
+| Item Cover | `now.visualBackground` → cache | UUID de cover |
+| Item Genre | "Talk Radio" | Fixe |
+
+**Pour les radios musicales (FIP, France Musique)** :
+
+| Champ UPnP | Source API | Exemple |
+|------------|------------|---------|
+| Playlist Title | Station name | "FIP Rock" |
+| Playlist Artist | `now.song.artists` OU `now.firstLine.title` | "The Rolling Stones" |
+| Playlist Cover | `now.song` image OU `now.visualBackground` | UUID de cover |
+| Item Title | `now.song.title` OU `now.firstLine.title` | "Paint It Black" |
+| Item Artist | `now.song.artists` | "The Rolling Stones" |
+| Item Album | `now.song.release.title` | "Aftermath" |
+| Item Cover | `now.song` image OU `now.visualBackground` | UUID de cover |
+| Item Genre | "Music" OU genre spécifique | "Rock" |
+
+**Note importante** : Les métadonnées changent régulièrement (toutes les 2-5 minutes), mais l'URL du stream reste la même. C'est pour cela que les playlists sont **volatiles** : on ne change pas leur contenu (toujours 1 item), mais on met à jour les métadonnées de cet item.
+
+#### Gestion des covers
+
+**Stratégie de cache** :
+- Les covers doivent être cachées dans `pmocovers`
+- URL source : `now.visualBackground.src` ou image de `now.song`
+- Extraction UUID : Parser l'URL Pikapi pour extraire l'UUID
+- Transformation : Télécharger et convertir en WebP si nécessaire
+- Stockage : Cache avec le PK = `RADIOFRANCE:{uuid}`
+
+**Workflow de cache de cover** :
+```rust
+// Pseudo-code
+async fn cache_cover_from_metadata(metadata: &ShowMetadata) -> Option {
+ // 1. Extraire l'URL de l'image
+ let image_url = metadata.visual_background.as_ref()?.src.clone();
+
+ // 2. Extraire UUID
+ let uuid = extract_uuid_from_url(&image_url)?;
+
+ // 3. Construire URL en haute résolution
+ let hires_url = ImageSize::XLarge.build_url(&uuid);
+
+ // 4. Cacher avec pmocovers
+ let cover_pk = cache_manager.cache_cover(&hires_url).await.ok()?;
+
+ Some(cover_pk)
+}
+```
+
+**Tailles de cover** :
+- Pour les métadonnées UPnP : Utiliser `ImageSize::XLarge` (1200x680) ou `ImageSize::Large` (560x960)
+- Pikapi supporte plusieurs tailles, on choisit la plus grande disponible
+
+#### URL des streams
+
+**Règle métier** : Ne présenter que le stream de **plus haute résolution** disponible.
+
+**Priorité de sélection** :
+1. AAC 192 kbps (HiFi) : `https://icecast.radiofrance.fr/{station}-hifi.aac?id=radiofrance`
+2. HLS adaptatif : `https://stream.radiofrance.fr/{station}/{station}.m3u8?id=radiofrance`
+3. AAC 128 kbps (MidFi) : Fallback si HiFi indisponible
+4. MP3 128 kbps : Fallback ultime
+
+**Pas de cache audio** : Les streams sont des flux en direct, on ne les cache JAMAIS dans `pmoaudiocache`. Les URLs sont passées telles quelles au renderer.
+
+### 5. Interface du client stateful
+
+**Proposition d'API publique** :
+
+```rust
+/// Client stateful pour Radio France avec cache et gestion d'état
+pub struct RadioFranceStatefulClient {
+ client: RadioFranceClient, // Client HTTP basique
+ config: Arc, // Configuration pmoconfig
+ metadata_cache: Arc>>,
+ cache_manager: SourceCacheManager, // Pour covers
+}
+
+impl RadioFranceStatefulClient {
+ /// Créer un nouveau client stateful
+ pub async fn new() -> Result;
+
+ /// Créer avec un client HTTP personnalisé
+ pub fn with_client_and_config(
+ client: RadioFranceClient,
+ config: Arc,
+ ) -> Self;
+
+ // ========================================================================
+ // Station Discovery (avec cache)
+ // ========================================================================
+
+ /// Obtenir toutes les stations (depuis cache si valide, sinon découverte)
+ pub async fn get_all_stations(&self) -> Result>;
+
+ /// Forcer la redécouverte des stations (ignore le cache)
+ pub async fn refresh_stations(&self) -> Result>;
+
+ /// Obtenir les stations principales uniquement
+ pub async fn get_main_stations(&self) -> Result>;
+
+ /// Obtenir les webradios d'une station (ex: FIP Rock, FIP Jazz)
+ pub async fn get_webradios(&self, parent_station: &str) -> Result>;
+
+ /// Obtenir les radios locales ICI (France Bleu)
+ pub async fn get_local_radios(&self) -> Result>;
+
+ // ========================================================================
+ // Organisation hiérarchique
+ // ========================================================================
+
+ /// Obtenir les stations organisées par groupe
+ pub async fn get_stations_by_group(&self) -> Result;
+
+ // ========================================================================
+ // Métadonnées live (avec cache court terme)
+ // ========================================================================
+
+ /// Obtenir les métadonnées live d'une station (cache 2-5 min)
+ pub async fn get_live_metadata(&self, station: &str) -> Result;
+
+ /// Forcer le rafraîchissement des métadonnées (ignore le cache)
+ pub async fn refresh_live_metadata(&self, station: &str) -> Result;
+
+ // ========================================================================
+ // Construction de playlists
+ // ========================================================================
+
+ /// Construire une playlist UPnP pour une station
+ pub async fn build_station_playlist(&self, station: &str) -> Result;
+
+ /// Mettre à jour les métadonnées d'une playlist existante
+ pub async fn update_playlist_metadata(
+ &self,
+ station: &str,
+ playlist: &mut StationPlaylist,
+ ) -> Result<()>;
+
+ // ========================================================================
+ // Helpers
+ // ========================================================================
+
+ /// Obtenir l'URL du stream HiFi pour une station
+ pub async fn get_stream_url(&self, station: &str) -> Result;
+
+ /// Vérifier si le cache des stations est valide
+ pub fn is_station_cache_valid(&self) -> bool;
+
+ /// Obtenir l'âge du cache des stations (en secondes)
+ pub fn station_cache_age_secs(&self) -> Option;
+}
+
+/// Groupes de stations organisés hiérarchiquement
+pub struct StationGroups {
+ /// Stations principales sans webradios (France Culture, France Inter, etc.)
+ pub standalone: Vec,
+
+ /// Stations avec webradios (FIP, France Musique)
+ pub with_webradios: Vec,
+
+ /// Radios locales ICI (France Bleu)
+ pub local_radios: Vec,
+}
+
+/// Groupe de stations (principale + webradios)
+pub struct StationGroup {
+ /// Station principale
+ pub main: Station,
+
+ /// Webradios associées (triées alphabétiquement)
+ pub webradios: Vec,
+}
+
+/// Playlist UPnP pour une station
+pub struct StationPlaylist {
+ /// ID de la playlist
+ pub id: String,
+
+ /// Station source
+ pub station: Station,
+
+ /// Métadonnées de la playlist (changent avec les émissions)
+ pub metadata: PlaylistMetadata,
+
+ /// Item unique (stream)
+ pub stream_item: StreamItem,
+}
+
+/// Métadonnées de playlist (volatiles)
+pub struct PlaylistMetadata {
+ pub title: String, // Nom de la station
+ pub artist: Option, // Émission en cours
+ pub album: Option, // Nom de la station (répété)
+ pub cover_pk: Option, // Cover cachée
+}
+
+/// Item de stream
+pub struct StreamItem {
+ pub id: String,
+ pub title: String, // Titre de l'émission/morceau
+ pub artist: Option, // Producteur/artiste
+ pub album: Option, // Nom de l'émission/album
+ pub genre: Option,
+ pub url: String, // URL du stream (AAC HiFi ou HLS)
+ pub protocol_info: String,
+ pub bitrate: Option,
+ pub sample_rate: Option,
+ pub channels: Option,
+ pub cover_pk: Option,
+}
+```
+
+### 6. Extension de configuration (config_ext.rs)
+
+**Trait d'extension pour pmoconfig** :
+
+```rust
+pub trait RadioFranceConfigExt {
+ // ========================================================================
+ // Activation de la source
+ // ========================================================================
+
+ fn get_radiofrance_enabled(&self) -> Result;
+ fn set_radiofrance_enabled(&self, enabled: bool) -> Result<()>;
+
+ // ========================================================================
+ // Cache des stations
+ // ========================================================================
+
+ fn get_radiofrance_stations_cache(&self) -> Result>;
+ fn set_radiofrance_stations_cache(&self, cache: &CachedStationList) -> Result<()>;
+ fn clear_radiofrance_stations_cache(&self) -> Result<()>;
+
+ fn get_radiofrance_cache_ttl_days(&self) -> Result;
+ fn set_radiofrance_cache_ttl_days(&self, days: u64) -> Result<()>;
+
+ // ========================================================================
+ // Configuration client HTTP
+ // ========================================================================
+
+ fn get_radiofrance_base_url(&self) -> Result;
+ fn set_radiofrance_base_url(&self, url: String) -> Result<()>;
+
+ fn get_radiofrance_timeout_secs(&self) -> Result;
+ fn set_radiofrance_timeout_secs(&self, secs: u64) -> Result<()>;
+
+ // ========================================================================
+ // Factory method
+ // ========================================================================
+
+ fn create_radiofrance_client(&self) -> Result;
+}
+```
+
+**Chemins de configuration** :
+
+```yaml
+sources:
+ radiofrance:
+ enabled: true # Activation de la source
+ base_url: "https://www.radiofrance.fr"
+ timeout_secs: 30
+ cache_ttl_days: 7 # TTL du cache des stations
+
+ stations_cache: # Cache des stations découvertes
+ version: 1
+ last_updated: 1737565200
+ stations:
+ - slug: "franceculture"
+ name: "France Culture"
+ type: "main"
+ # ... reste des stations
+```
+
+## Workflow de mise à jour des métadonnées
+
+### Scénario 1 : Première utilisation
+
+1. Utilisateur ouvre la source Radio France dans son client UPnP
+2. `RadioFranceSource::browse("radiofrance")` est appelé
+3. Source appelle `stateful_client.get_all_stations()`
+4. Cache vide → Appel `discover_all_stations()` (~3-5 secondes)
+5. Résultat stocké dans config avec timestamp
+6. Retour de la liste des stations
+
+### Scénario 2 : Utilisation ultérieure (cache valide)
+
+1. Utilisateur ouvre la source Radio France
+2. Source appelle `stateful_client.get_all_stations()`
+3. Cache présent et valide (< 7 jours) → Retour immédiat depuis config
+4. Pas d'appel réseau
+
+### Scénario 3 : Lecture d'une station
+
+1. Utilisateur sélectionne "France Culture" et lance la lecture
+2. Source appelle `stateful_client.build_station_playlist("franceculture")`
+3. Stateful client :
+ - Appelle `get_live_metadata("franceculture")` (cache 2-5 min si présent)
+ - Extrait les métadonnées de l'émission en cours
+ - Cache la cover de l'émission via `pmocovers`
+ - Construit la playlist avec 1 item (stream HiFi)
+4. Retour de la playlist au renderer
+
+### Scénario 4 : Mise à jour des métadonnées pendant la lecture
+
+1. Renderer lit le stream depuis 3 minutes
+2. Control point demande les métadonnées à jour
+3. Source appelle `stateful_client.update_playlist_metadata()`
+4. Stateful client :
+ - Vérifie le cache des métadonnées live
+ - Si expiré (> `delayToRefresh` ms) : appelle l'API
+ - Met à jour les métadonnées de la playlist
+ - Cache la nouvelle cover si différente
+5. Control point reçoit les nouvelles métadonnées
+
+**Important** : L'URL du stream ne change JAMAIS pendant la lecture. Seules les métadonnées (titre, artiste, cover) changent.
+
+## Architecture des fichiers
+
+```
+pmoradiofrance/
+├── src/
+│ ├── lib.rs # Exports publics
+│ ├── client.rs # Client HTTP basique (Round 3) ✅
+│ ├── models.rs # Structures de données (Round 3) ✅
+│ ├── error.rs # Types d'erreur ✅
+│ ├── stateful_client.rs # Client stateful (Round 4) 🆕
+│ ├── playlist.rs # Construction de playlists (Round 4) 🆕
+│ ├── config_ext.rs # Extension pmoconfig (Round 4) 🆕
+│ └── source.rs # Implémentation MusicSource (Round 5)
+├── assets/
+│ └── default.webp # Logo Radio France 300x300px
+├── Cargo.toml
+└── README.md
+```
+
+## Dépendances supplémentaires
+
+```toml
+[dependencies]
+# Déjà présentes (Round 3)
+reqwest = { version = "0.12", features = ["json"] }
+tokio = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+serde_yaml = { workspace = true }
+chrono = { workspace = true }
+async-trait = { workspace = true }
+thiserror = { workspace = true }
+anyhow = { workspace = true }
+tracing = { workspace = true }
+url = "2.5"
+scraper = "0.22"
+regex = "1.11"
+pmosource = { path = "../pmosource" }
+
+# Nouvelles (Round 4)
+pmoconfig = { path = "../pmoconfig" } # Configuration persistante
+pmocovers = { path = "../pmocovers" } # Cache de covers
+# pmoaudiocache NON utilisé (pas de cache audio pour les streams live)
+
+[features]
+default = ["pmoconfig"]
+pmoconfig = ["dep:pmoconfig"]
+cache = ["dep:pmocovers"]
+logging = []
+server = ["pmosource/server", "pmoconfig", "cache"]
+full = ["server", "logging"]
+```
+
+## Considérations d'implémentation
+
+### Thread safety
+
+Le client stateful doit être thread-safe car il sera partagé entre plusieurs threads (ContentDirectory, AVTransport, etc.) :
+
+```rust
+pub struct RadioFranceStatefulClient {
+ client: RadioFranceClient, // Clone cheap (Arc interne)
+ config: Arc, // Partagé
+ metadata_cache: Arc>>, // Cache mémoire protégé
+ cache_manager: SourceCacheManager, // Thread-safe
+}
+
+impl Clone for RadioFranceStatefulClient {
+ fn clone(&self) -> Self {
+ // Clone cheap : tous les champs sont Arc ou Clone
+ Self {
+ client: self.client.clone(),
+ config: self.config.clone(),
+ metadata_cache: self.metadata_cache.clone(),
+ cache_manager: self.cache_manager.clone(),
+ }
+ }
+}
+```
+
+### Performances
+
+**Cache des stations** :
+- Stockage : YAML dans config (~10-20 KB pour ~50 stations)
+- Lecture : Désérialisation YAML (~1-2 ms)
+- TTL : 7 jours (configurable)
+
+**Cache des métadonnées live** :
+- Stockage : Mémoire (HashMap)
+- Taille : ~5-10 KB par station
+- TTL : 2-5 minutes (champ `delayToRefresh` de l'API)
+- Limite : ~100 stations max = ~1 MB max
+
+**Cache des covers** :
+- Via `pmocovers` (LRU disk cache)
+- Taille moyenne : 50-200 KB par cover WebP
+- Limite : Configurable via `pmocovers` (défaut : 2000 items)
+
+### Gestion d'erreurs
+
+**Stratégie de fallback** :
+
+1. **Cache des stations invalide ou absent** → Redécouverte (erreur propagée si échec)
+2. **Métadonnées live indisponibles** → Utiliser cache expiré si présent, sinon erreur
+3. **Cover indisponible** → Utiliser cover par défaut de la source
+4. **Stream HiFi indisponible** → Fallback sur HLS puis AAC MidFi
+
+### Logging
+
+Utiliser `tracing` pour logger :
+- Découverte des stations (nombre, durée)
+- Hits/miss du cache
+- Rafraîchissement des métadonnées
+- Erreurs réseau
+
+## Tests
+
+### Tests unitaires
+
+- Validation du cache (TTL, version, invalidation)
+- Parsing des métadonnées
+- Construction des playlists
+- Mapping API → UPnP
+
+### Tests d'intégration
+
+- Découverte réelle des stations
+- Récupération des métadonnées live
+- Cache et invalidation
+- Construction de playlists complètes
+
+## Prochaines étapes (Round 5)
+
+Le Round 5 implémentera la `MusicSource` finale qui :
+- Utilise le `RadioFranceStatefulClient`
+- Implémente le trait `MusicSource` de `pmosource`
+- Expose l'arborescence UPnP ContentDirectory
+- Gère les playlists volatiles via `pmoplaylist`
+- Notifie les changements de métadonnées
+
+---
+
+**Fin du Round 4**
diff --git a/Blackboard/ToThinkAbout/client_radiofrance_architecture.md b/Blackboard/ToThinkAbout/client_radiofrance_architecture.md
new file mode 100644
index 00000000..e84837a1
--- /dev/null
+++ b/Blackboard/ToThinkAbout/client_radiofrance_architecture.md
@@ -0,0 +1,831 @@
+# Architecture du client Radio France (client.rs)
+
+**Date** : 2026-01-22
+**Objectif** : Conception d'une API Rust pour interroger les métadonnées live et flux audio de Radio France
+**Référence** : Architecture inspirée de `pmoparadise/src/client.rs`
+
+---
+
+## Table des matières
+
+1. [Vue d'ensemble](#vue-densemble)
+2. [Découverte dynamique des stations](#découverte-dynamique-des-stations)
+3. [Architecture du client](#architecture-du-client)
+4. [Structures de données](#structures-de-données)
+5. [Méthodes principales](#méthodes-principales)
+6. [Exemple d'utilisation](#exemple-dutilisation)
+7. [Points d'attention](#points-dattention)
+
+---
+
+## Vue d'ensemble
+
+Le client Radio France doit permettre :
+- **Découverte dynamique** de ~73 stations/webradios (scraping HTML)
+- **Métadonnées live** via `/api/live?` avec polling intelligent
+- **Flux audio** en qualité maximale uniquement (AAC 192 kbps + HLS)
+- **Un seul client** pour toutes les stations (pas un client par station)
+
+### Philosophie
+
+- **Pas de hardcoding** : Toutes les stations sont découvertes dynamiquement
+- **Qualité maximale uniquement** : AAC 192 kbps (hifi) + HLS, pas de choix lofi/midfi
+- **Architecture simple** : Un client unique, les stations sont des paramètres
+
+---
+
+## Découverte dynamique des stations
+
+### Stratégie complète
+
+Radio France n'expose **pas d'API centralisée** listant toutes les stations. La découverte se fait par **scraping HTML** des pages principales :
+
+#### 1. Stations principales (8)
+
+**Source** : `https://www.radiofrance.fr/`
+
+**Méthode** : Scraper le HTML et extraire tous les slugs via regex `(franceinter|franceinfo|franceculture|francemusique|fip|mouv|francebleu|monpetit)`
+
+**Résultat attendu** :
+```
+franceinter
+franceinfo
+franceculture
+francemusique
+fip
+mouv
+francebleu
+monpetitfranceinter
+```
+
+#### 2. Webradios de chaque station (nombre variable)
+
+**Principe** : **TOUTES les stations** peuvent avoir des webradios, pas seulement FIP et France Musique.
+
+**Méthode** : Pour chaque station principale découverte, scraper sa page `https://www.radiofrance.fr/{station}` et extraire les identifiants via regex `{station}_[a-z_]+`
+
+**Exemples découverts** :
+
+**FIP** (`https://www.radiofrance.fr/fip`) :
+```
+fip_cultes
+fip_electro
+fip_groove
+fip_hiphop
+fip_jazz
+fip_metal
+fip_nouveautes
+fip_pop
+fip_reggae
+fip_rock
+fip_sacre_francais
+fip_world
+```
+
+**France Musique** (`https://www.radiofrance.fr/francemusique`) :
+```
+francemusique_baroque
+francemusique_classique_easy
+francemusique_classique_love
+francemusique_classique_plus
+francemusique_concert_rf
+francemusique_evenementielle
+francemusique_la_contemporaine
+francemusique_la_jazz
+francemusique_ocora_monde
+francemusique_opera
+francemusique_piano_zen
+```
+
+**Autres stations** : À découvrir dynamiquement (France Inter, Mouv, etc. pourraient avoir des webradios futures)
+
+#### 3. Radios locales France Bleu (~40)
+
+**Source** : API `/francebleu/api/live?` → champ `localRadios[]`
+
+**Méthode** : Appel API et extraction du tableau JSON
+
+**Exemple de structure** :
+```json
+{
+ "localRadios": [
+ {"id": 12, "title": "ICI Alsace", "name": "francebleu_alsace", "isOnAir": true},
+ {"id": 13, "title": "ICI Armorique", "name": "francebleu_armorique", "isOnAir": true},
+ ...
+ ]
+}
+```
+
+### Total découvert
+
+- **8** stations principales
+- **~23** webradios (12 FIP + 11 France Musique + possibles autres)
+- **~40** radios locales France Bleu
+- **= ~71+ stations au total** (extensible automatiquement si nouvelles webradios)
+
+---
+
+## Architecture du client
+
+### Client unique
+
+Contrairement à une approche "un client par station", nous utilisons **un seul client** avec les stations comme **paramètres de méthode**.
+
+```rust
+pub struct RadioFranceClient {
+ client: reqwest::Client,
+ timeout: Duration,
+}
+```
+
+### Pas de cache interne
+
+Le client est **stateless** et ne cache rien. La gestion du cache (métadonnées, images) sera faite par les couches supérieures (`SourceCacheManager`).
+
+### Builder pattern
+
+Pour permettre la configuration :
+
+```rust
+pub struct ClientBuilder {
+ client: Option,
+ timeout: Duration,
+ user_agent: String,
+}
+```
+
+---
+
+## Structures de données
+
+### 1. Station découverte
+
+```rust
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Station {
+ pub slug: String, // "fip_rock", "franceinter"
+ pub name: String, // "FIP Rock", "France Inter"
+ pub station_type: StationType,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum StationType {
+ Main, // Station principale
+ Webradio { // Webradio de n'importe quelle station
+ parent_station: String, // "fip", "francemusique", "mouv", etc.
+ },
+ LocalRadio { region: String }, // Radio locale France Bleu
+}
+```
+
+### 2. Réponse API Live
+
+```rust
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct LiveResponse {
+ pub station_name: String,
+ pub delay_to_refresh: u64, // millisecondes
+ pub migrated: bool,
+ pub now: ShowMetadata,
+ pub next: Option,
+ pub local_radios: Option>, // France Bleu uniquement
+}
+```
+
+### 3. Métadonnées d'émission
+
+```rust
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ShowMetadata {
+ pub start_time: Option,
+ pub end_time: Option,
+ pub producer: Option,
+ pub first_line: Line, // Titre émission
+ pub second_line: Line, // Titre épisode/chronique
+ pub third_line: Option, // Sous-titre
+ pub intro: Option, // Description
+ pub song: Option, // Pour radios musicales (FIP, France Musique)
+ pub media: Media, // Flux audio disponibles
+ pub visual_background: Option,
+ pub visuals: Option,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct Line {
+ pub title: Option,
+ pub id: Option,
+ pub path: Option,
+}
+```
+
+### 4. Morceau musical (FIP, France Musique)
+
+```rust
+#[derive(Debug, Clone, Deserialize)]
+pub struct Song {
+ pub id: String,
+ pub year: Option,
+ pub interpreters: Vec,
+ pub release: Release,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct Release {
+ pub label: Option,
+ pub title: Option,
+ pub reference: Option,
+}
+```
+
+### 5. Flux audio
+
+```rust
+#[derive(Debug, Clone, Deserialize)]
+pub struct Media {
+ pub sources: Vec,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct StreamSource {
+ pub url: String,
+ pub broadcast_type: BroadcastType,
+ pub format: StreamFormat,
+ pub bitrate: u32,
+}
+
+#[derive(Debug, Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "lowercase")]
+pub enum BroadcastType {
+ Live,
+ Timeshift,
+}
+
+#[derive(Debug, Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "lowercase")]
+pub enum StreamFormat {
+ Mp3,
+ Aac,
+ Hls,
+}
+```
+
+### 6. Images
+
+```rust
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct EmbedImage {
+ pub model: String,
+ pub src: String,
+ pub width: Option,
+ pub height: Option,
+ pub dominant: Option,
+ pub copyright: Option,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct Visuals {
+ pub card: Option,
+ pub player: Option,
+}
+
+pub enum ImageSize {
+ Tiny, // 88x88
+ Small, // 200x200
+ Medium, // 420x720
+ Large, // 560x960
+ XLarge, // 1200x680
+ Raw, // Taille originale
+}
+```
+
+### 7. Radios locales
+
+```rust
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct LocalRadio {
+ pub id: u32,
+ pub title: String,
+ pub name: String,
+ pub is_on_air: bool,
+}
+```
+
+---
+
+## Méthodes principales
+
+### 1. Création du client
+
+```rust
+impl RadioFranceClient {
+ /// Créer un nouveau client avec settings par défaut
+ pub async fn new() -> Result {
+ Self::builder().build().await
+ }
+
+ /// Créer un builder pour configuration avancée
+ pub fn builder() -> ClientBuilder {
+ ClientBuilder::default()
+ }
+
+ /// Créer avec un reqwest::Client existant
+ pub fn with_client(client: reqwest::Client) -> Self {
+ Self {
+ client,
+ timeout: Duration::from_secs(30),
+ }
+ }
+}
+```
+
+### 2. Découverte des stations
+
+```rust
+impl RadioFranceClient {
+ /// Découvrir toutes les stations disponibles (scraping + API)
+ pub async fn discover_all_stations(&self) -> Result> {
+ let mut stations = Vec::new();
+
+ // 1. Découvrir les stations principales
+ let main_stations = self.scrape_main_stations().await?;
+
+ // 2. Pour CHAQUE station principale, découvrir ses webradios éventuelles
+ for main_station in main_stations {
+ // Ajouter la station principale
+ stations.push(main_station.clone());
+
+ // Découvrir ses webradios (peut retourner 0 si aucune)
+ if let Ok(webradios) = self.scrape_station_webradios(&main_station.slug).await {
+ stations.extend(webradios);
+ }
+ }
+
+ // 3. Cas spécial : radios locales France Bleu (via API)
+ if let Ok(locals) = self.discover_local_radios().await {
+ stations.extend(locals);
+ }
+
+ Ok(stations)
+ }
+
+ /// Scraper les stations principales depuis homepage
+ async fn scrape_main_stations(&self) -> Result> {
+ let html = self.client
+ .get("https://www.radiofrance.fr/")
+ .timeout(self.timeout)
+ .send()
+ .await?
+ .text()
+ .await?;
+
+ let re = regex::Regex::new(
+ r"(franceinter|franceinfo|franceculture|francemusique|fip|mouv|francebleu|monpetit)"
+ )?;
+
+ let mut slugs = std::collections::HashSet::new();
+ for cap in re.captures_iter(&html) {
+ slugs.insert(cap[0].to_string());
+ }
+
+ Ok(slugs.into_iter().map(|slug| Station {
+ slug: slug.clone(),
+ name: Self::slug_to_name(&slug),
+ station_type: StationType::Main,
+ }).collect())
+ }
+
+ /// Scraper les webradios d'une station donnée
+ ///
+ /// Fonctionne pour n'importe quelle station (fip, francemusique, mouv, etc.)
+ /// Retourne un Vec vide si aucune webradio n'est trouvée.
+ async fn scrape_station_webradios(&self, station: &str) -> Result> {
+ let url = format!("https://www.radiofrance.fr/{}", station);
+ let html = self.client
+ .get(&url)
+ .timeout(self.timeout)
+ .send()
+ .await?
+ .text()
+ .await?;
+
+ // Pattern générique : {station}_[a-z_]+
+ let pattern = format!(r"{}_[a-z_]+", station);
+ let re = regex::Regex::new(&pattern)?;
+
+ let mut slugs = std::collections::HashSet::new();
+ for cap in re.captures_iter(&html) {
+ slugs.insert(cap[0].to_string());
+ }
+
+ Ok(slugs.into_iter().map(|slug| Station {
+ slug: slug.clone(),
+ name: Self::slug_to_name(&slug),
+ station_type: StationType::Webradio {
+ parent_station: station.to_string(),
+ },
+ }).collect())
+ }
+
+ /// Découvrir les radios locales France Bleu via API
+ async fn discover_local_radios(&self) -> Result> {
+ let response = self.live_metadata("francebleu").await?;
+
+ Ok(response.local_radios
+ .unwrap_or_default()
+ .into_iter()
+ .map(|local| Station {
+ slug: local.name,
+ name: local.title,
+ station_type: StationType::LocalRadio {
+ region: local.title.replace("ICI ", ""),
+ },
+ })
+ .collect())
+ }
+
+ /// Convertir slug en nom lisible (heuristique simple)
+ fn slug_to_name(slug: &str) -> String {
+ // Transformations basiques, à améliorer
+ slug.replace('_', " ")
+ .split_whitespace()
+ .map(|w| {
+ let mut c = w.chars();
+ match c.next() {
+ None => String::new(),
+ Some(f) => f.to_uppercase().collect::() + c.as_str(),
+ }
+ })
+ .collect::>()
+ .join(" ")
+ }
+}
+```
+
+### 3. Métadonnées live
+
+```rust
+impl RadioFranceClient {
+ /// Récupérer les métadonnées live d'une station
+ ///
+ /// # Arguments
+ /// * `station` - Slug de la station (ex: "franceculture", "fip_rock")
+ ///
+ /// # Webradios
+ /// Pour les webradios FIP/France Musique, utiliser le format :
+ /// - Principales : "fip", "francemusique"
+ /// - Webradios : "fip_rock", "francemusique_jazz"
+ ///
+ /// L'API utilise le paramètre `?webradio=` automatiquement si nécessaire.
+ pub async fn live_metadata(&self, station: &str) -> Result {
+ let (base_station, webradio) = Self::parse_station_slug(station);
+
+ let mut url = url::Url::parse(&format!(
+ "https://www.radiofrance.fr/{}/api/live?",
+ base_station
+ ))?;
+
+ // Ajouter le paramètre webradio si nécessaire
+ if let Some(wr) = webradio {
+ url.query_pairs_mut().append_pair("webradio", wr);
+ }
+
+ let response = self.client
+ .get(url)
+ .timeout(self.timeout)
+ .send()
+ .await?;
+
+ if !response.status().is_success() {
+ return Err(Error::ApiError(format!(
+ "API returned status: {}",
+ response.status()
+ )));
+ }
+
+ Ok(response.json().await?)
+ }
+
+ /// Parser le slug pour extraire station de base et webradio
+ ///
+ /// Exemples :
+ /// - "fip" → ("fip", None)
+ /// - "fip_rock" → ("fip", Some("fip_rock"))
+ /// - "francemusique_jazz" → ("francemusique", Some("francemusique_jazz"))
+ /// - "franceinter" → ("franceinter", None)
+ fn parse_station_slug(slug: &str) -> (&str, Option<&str>) {
+ if slug.starts_with("fip_") {
+ ("fip", Some(slug))
+ } else if slug.starts_with("francemusique_") {
+ ("francemusique", Some(slug))
+ } else if slug.starts_with("francebleu_") {
+ // Radios locales : pas de paramètre webradio, slug direct
+ (slug, None)
+ } else {
+ // Stations principales
+ (slug, None)
+ }
+ }
+
+ /// Récupérer uniquement les métadonnées de l'émission actuelle
+ pub async fn now_playing(&self, station: &str) -> Result {
+ let response = self.live_metadata(station).await?;
+ Ok(response.now)
+ }
+}
+```
+
+### 4. Flux audio (qualité maximale uniquement)
+
+```rust
+impl RadioFranceClient {
+ /// Récupérer l'URL du flux audio en qualité maximale
+ ///
+ /// Priorité : AAC 192 kbps (hifi) > HLS
+ pub async fn get_hifi_stream_url(&self, station: &str) -> Result {
+ let metadata = self.live_metadata(station).await?;
+
+ // Chercher AAC hifi (192 kbps)
+ if let Some(source) = metadata.now.media.sources.iter().find(|s| {
+ s.format == StreamFormat::Aac
+ && s.broadcast_type == BroadcastType::Live
+ && s.bitrate == 192
+ }) {
+ return Ok(source.url.clone());
+ }
+
+ // Fallback HLS
+ if let Some(source) = metadata.now.media.sources.iter().find(|s| {
+ s.format == StreamFormat::Hls
+ && s.broadcast_type == BroadcastType::Live
+ }) {
+ return Ok(source.url.clone());
+ }
+
+ Err(Error::NoHifiStream(format!(
+ "No HiFi stream found for station: {}",
+ station
+ )))
+ }
+
+ /// Lister tous les flux disponibles pour une station
+ pub async fn get_available_streams(&self, station: &str) -> Result> {
+ let metadata = self.live_metadata(station).await?;
+ Ok(metadata.now.media.sources)
+ }
+}
+```
+
+### 5. Images (Pikapi)
+
+```rust
+impl RadioFranceClient {
+ /// Construire l'URL d'une image Pikapi
+ ///
+ /// # Arguments
+ /// * `uuid` - UUID de l'image (extrait des métadonnées)
+ /// * `size` - Taille souhaitée
+ pub fn get_image_url(uuid: &str, size: ImageSize) -> String {
+ let size_str = match size {
+ ImageSize::Tiny => "88x88",
+ ImageSize::Small => "200x200",
+ ImageSize::Medium => "420x720",
+ ImageSize::Large => "560x960",
+ ImageSize::XLarge => "1200x680",
+ ImageSize::Raw => "raw",
+ };
+
+ format!("https://www.radiofrance.fr/pikapi/images/{}/{}", uuid, size_str)
+ }
+
+ /// Extraire l'UUID d'une URL Pikapi existante
+ pub fn extract_image_uuid(url: &str) -> Option {
+ let re = regex::Regex::new(r"/pikapi/images/([a-f0-9-]+)").ok()?;
+ re.captures(url)
+ .and_then(|cap| cap.get(1))
+ .map(|m| m.as_str().to_string())
+ }
+}
+```
+
+### 6. Polling intelligent
+
+```rust
+impl RadioFranceClient {
+ /// Calculer le délai avant le prochain refresh recommandé
+ pub fn next_refresh_delay(metadata: &LiveResponse) -> Duration {
+ Duration::from_millis(metadata.delay_to_refresh)
+ }
+
+ /// Calculer le délai en tenant compte du temps écoulé
+ pub fn adjusted_refresh_delay(
+ metadata: &LiveResponse,
+ fetched_at: std::time::SystemTime,
+ ) -> Duration {
+ let base_delay = Duration::from_millis(metadata.delay_to_refresh);
+ let elapsed = fetched_at.elapsed().unwrap_or(Duration::ZERO);
+
+ base_delay.saturating_sub(elapsed)
+ }
+}
+```
+
+---
+
+## Exemple d'utilisation
+
+### Découverte et affichage de toutes les stations
+
+```rust
+use pmoradiofrance::RadioFranceClient;
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ let client = RadioFranceClient::new().await?;
+
+ println!("Découverte des stations...");
+ let stations = client.discover_all_stations().await?;
+
+ println!("Trouvé {} stations :", stations.len());
+ for station in &stations {
+ println!(" - {} ({})", station.name, station.slug);
+ }
+
+ Ok(())
+}
+```
+
+### Récupération des métadonnées live
+
+```rust
+use pmoradiofrance::RadioFranceClient;
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ let client = RadioFranceClient::new().await?;
+
+ // Station principale
+ let fc_live = client.live_metadata("franceculture").await?;
+ println!("France Culture : {} - {}",
+ fc_live.now.first_line.title.unwrap_or_default(),
+ fc_live.now.second_line.title.unwrap_or_default()
+ );
+
+ // Webradio FIP
+ let fip_rock_live = client.live_metadata("fip_rock").await?;
+ if let Some(song) = &fip_rock_live.now.song {
+ println!("FIP Rock : {} - {}",
+ song.interpreters.join(", "),
+ fip_rock_live.now.first_line.title.unwrap_or_default()
+ );
+ }
+
+ Ok(())
+}
+```
+
+### Polling avec délai intelligent
+
+```rust
+use pmoradiofrance::RadioFranceClient;
+use std::time::{Duration, SystemTime};
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ let client = RadioFranceClient::new().await?;
+
+ loop {
+ let fetched_at = SystemTime::now();
+ let metadata = client.live_metadata("fip").await?;
+
+ println!("Now: {} - {}",
+ metadata.now.second_line.title.unwrap_or_default(),
+ metadata.now.first_line.title.unwrap_or_default()
+ );
+
+ // Attendre le délai recommandé
+ let delay = RadioFranceClient::adjusted_refresh_delay(&metadata, fetched_at);
+ tokio::time::sleep(delay).await;
+ }
+}
+```
+
+### Récupération du flux HiFi
+
+```rust
+use pmoradiofrance::RadioFranceClient;
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ let client = RadioFranceClient::new().await?;
+
+ let stream_url = client.get_hifi_stream_url("franceculture").await?;
+ println!("Stream HiFi : {}", stream_url);
+ // Exemple : https://icecast.radiofrance.fr/franceculture-hifi.aac?id=radiofrance
+
+ Ok(())
+}
+```
+
+---
+
+## Points d'attention
+
+### 1. Rate limiting
+
+- Pas de limite documentée observée
+- **Toujours** respecter `delayToRefresh` pour éviter les requêtes inutiles
+- Mettre en cache les résultats de `discover_all_stations()` (TTL : 24h recommandé)
+
+### 2. User-Agent
+
+Pour un projet open-source, utiliser un User-Agent identifiable :
+
+```rust
+impl Default for ClientBuilder {
+ fn default() -> Self {
+ Self {
+ user_agent: "PMOMusic/0.3.10 (https://github.com/votre-repo)".to_string(),
+ // ...
+ }
+ }
+}
+```
+
+### 3. Gestion d'erreurs
+
+Les APIs peuvent retourner :
+- **Données vides** (`null`) pour certains champs
+- **`song`** absent pour radios non-musicales (France Inter, France Info, France Culture)
+- **`localRadios`** uniquement pour France Bleu
+- **`visual_background`** parfois absent
+
+Toujours utiliser `Option<>` et gérer les cas manquants.
+
+### 4. Webradios et paramètre `?webradio=`
+
+- **Stations principales** : `/franceinter/api/live?`
+- **Webradios FIP** : `/fip/api/live?webradio=fip_rock`
+- **Webradios France Musique** : `/francemusique/api/live?webradio=francemusique_jazz`
+- **Radios locales** : `/francebleu_alsace/api/live?` (slug direct, pas de paramètre)
+
+### 5. Images Pikapi
+
+Les URLs dans les réponses API utilisent parfois des chemins complets, parfois juste l'UUID :
+
+```json
+"src": "https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39"
+```
+
+Toujours normaliser en extrayant l'UUID et en reconstruisant l'URL avec la taille souhaitée.
+
+### 6. Scraping HTML
+
+Le scraping HTML est **fragile** par nature. Recommandations :
+
+- **Cache agressif** : Stocker les résultats de découverte (TTL 24h minimum)
+- **Fallback** : Avoir une liste de base hardcodée si le scraping échoue
+- **Validation optionnelle** : Tester chaque station découverte avec `/api/live?` avant de l'ajouter (peut être lent)
+- **Monitoring** : Logger les échecs de découverte
+
+### 7. Performance
+
+Pour découvrir ~70 stations :
+- **Scraping** : 1 homepage + 8 pages stations (une par station principale)
+- **Validation France Bleu** : 1 requête API
+- **Total** : ~10 requêtes HTTP
+
+Temps estimé : 3-5 secondes avec timeout 30s (parallélisable pour réduire à ~1-2s).
+
+### 8. Respect des CGU
+
+- APIs publiques utilisées par le site officiel
+- Usage acceptable pour un projet open-source personnel/non-commercial
+- **Ne pas redistribuer** les flux audio commercialement
+- **Ne pas surcharger** les serveurs (respecter `delayToRefresh`)
+
+---
+
+## Prochaines étapes
+
+1. **Implémenter `client.rs`** avec l'architecture décrite
+2. **Ajouter les tests** :
+ - Tests unitaires pour parsing de slugs
+ - Tests d'intégration pour découverte
+ - Tests d'API live (avec captures VCR)
+3. **Intégrer avec `pmosource`** :
+ - Implémenter le trait `MusicSource`
+ - Gérer le cache via `SourceCacheManager`
+ - Support FIFO pour radios musicales (FIP)
+4. **Documenter les limitations** :
+ - Stations non accessibles
+ - Cas d'erreur connus
+ - Métriques de fiabilité
+
+---
+
+**Fin du rapport d'architecture client.rs**
diff --git a/Cargo.lock b/Cargo.lock
index 3da10aa3..5f2098ae 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "PMOMusic"
-version = "0.3.10"
+version = "0.3.11"
dependencies = [
"axum 0.8.7",
"console-subscriber",
@@ -693,7 +693,7 @@ dependencies = [
"bevy_ptr",
"bevy_reflect_derive",
"bevy_utils",
- "derive_more",
+ "derive_more 2.0.1",
"disqualified",
"downcast-rs",
"erased-serde",
@@ -1276,6 +1276,29 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "cssparser"
+version = "0.34.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa",
+ "phf",
+ "smallvec",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn 2.0.110",
+]
+
[[package]]
name = "ctr"
version = "0.9.2"
@@ -1335,6 +1358,17 @@ dependencies = [
"syn 2.0.110",
]
+[[package]]
+name = "derive_more"
+version = "0.99.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.110",
+]
+
[[package]]
name = "derive_more"
version = "2.0.1"
@@ -1438,12 +1472,33 @@ version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+[[package]]
+name = "ego-tree"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
+
[[package]]
name = "either"
version = "1.15.0"
@@ -1708,6 +1763,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+[[package]]
+name = "futf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
+dependencies = [
+ "mac",
+ "new_debug_unreachable",
+]
+
[[package]]
name = "futures"
version = "0.3.31"
@@ -1810,6 +1875,15 @@ dependencies = [
"slab",
]
+[[package]]
+name = "fxhash"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
+dependencies = [
+ "byteorder",
+]
+
[[package]]
name = "gcc"
version = "0.3.55"
@@ -1848,6 +1922,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "getopts"
+version = "0.2.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
+dependencies = [
+ "unicode-width 0.2.2",
+]
+
[[package]]
name = "getrandom"
version = "0.2.16"
@@ -2044,6 +2127,18 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "html5ever"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
+dependencies = [
+ "log",
+ "mac",
+ "markup5ever",
+ "match_token",
+]
+
[[package]]
name = "htmlescape"
version = "0.3.1"
@@ -2713,6 +2808,12 @@ dependencies = [
"hashbrown 0.15.5",
]
+[[package]]
+name = "mac"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+
[[package]]
name = "mach2"
version = "0.4.3"
@@ -2722,6 +2823,31 @@ dependencies = [
"libc",
]
+[[package]]
+name = "markup5ever"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18"
+dependencies = [
+ "log",
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+ "tendril",
+]
+
+[[package]]
+name = "match_token"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.110",
+]
+
[[package]]
name = "matchers"
version = "0.2.0"
@@ -3528,6 +3654,58 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand 0.8.5",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.110",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+]
+
[[package]]
name = "pin-project"
version = "1.1.10"
@@ -3963,6 +4141,32 @@ dependencies = [
"utoipa",
]
+[[package]]
+name = "pmoradiofrance"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "chrono",
+ "pmoaudiocache",
+ "pmoconfig",
+ "pmocovers",
+ "pmoplaylist",
+ "pmosource",
+ "regex",
+ "reqwest",
+ "scraper",
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "thiserror 2.0.17",
+ "tokio",
+ "tokio-test",
+ "tracing",
+ "tracing-subscriber",
+ "url",
+]
+
[[package]]
name = "pmoserver"
version = "0.1.0"
@@ -4146,6 +4350,12 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -4447,7 +4657,7 @@ dependencies = [
"strum",
"unicode-segmentation",
"unicode-truncate",
- "unicode-width",
+ "unicode-width 0.1.14",
]
[[package]]
@@ -4836,6 +5046,21 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "scraper"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc3d051b884f40e309de6c149734eab57aa8cc1347992710dc80bcc1c2194c15"
+dependencies = [
+ "cssparser",
+ "ego-tree",
+ "getopts",
+ "html5ever",
+ "precomputed-hash",
+ "selectors",
+ "tendril",
+]
+
[[package]]
name = "security-framework"
version = "2.11.1"
@@ -4859,6 +5084,25 @@ dependencies = [
"libc",
]
+[[package]]
+name = "selectors"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
+dependencies = [
+ "bitflags 2.10.0",
+ "cssparser",
+ "derive_more 0.99.20",
+ "fxhash",
+ "log",
+ "new_debug_unreachable",
+ "phf",
+ "phf_codegen",
+ "precomputed-hash",
+ "servo_arc",
+ "smallvec",
+]
+
[[package]]
name = "semver"
version = "1.0.27"
@@ -4959,6 +5203,15 @@ dependencies = [
"unsafe-libyaml",
]
+[[package]]
+name = "servo_arc"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
+dependencies = [
+ "stable_deref_trait",
+]
+
[[package]]
name = "sha1"
version = "0.10.6"
@@ -5047,6 +5300,12 @@ version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
+[[package]]
+name = "siphasher"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
+
[[package]]
name = "slab"
version = "0.4.11"
@@ -5158,6 +5417,31 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+[[package]]
+name = "string_cache"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+ "serde",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
[[package]]
name = "strum"
version = "0.26.3"
@@ -5509,6 +5793,17 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "tendril"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
+dependencies = [
+ "futf",
+ "mac",
+ "utf-8",
+]
+
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -6002,7 +6297,7 @@ checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools 0.13.0",
"unicode-segmentation",
- "unicode-width",
+ "unicode-width 0.1.14",
]
[[package]]
@@ -6011,6 +6306,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
[[package]]
name = "unicode-xid"
version = "0.2.6"
diff --git a/Cargo.toml b/Cargo.toml
index 8d468211..0ddf5dce 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,10 +16,12 @@ members = [
"pmoaudio",
"pmoqobuz",
"pmoparadise",
+ "pmoradiofrance",
"pmosource",
"pmoplaylist",
"pmoflac",
- "pmometadata", "pmocontrol",
+ "pmometadata",
+ "pmocontrol",
]
[workspace.dependencies]
diff --git a/pmoradiofrance/Cargo.toml b/pmoradiofrance/Cargo.toml
new file mode 100644
index 00000000..1784c26c
--- /dev/null
+++ b/pmoradiofrance/Cargo.toml
@@ -0,0 +1,80 @@
+[package]
+name = "pmoradiofrance"
+version = "0.1.0"
+edition = "2021"
+authors = ["PMOMusic Contributors"]
+description = "Rust client for Radio France streaming services"
+license = "MIT OR Apache-2.0"
+repository = "https://github.com/yourusername/pmomusic"
+keywords = ["radio", "france", "streaming", "music", "aac"]
+categories = ["multimedia", "api-bindings"]
+
+[dependencies]
+# HTTP client for Radio France API requests
+reqwest = { version = "0.12", features = ["json"] }
+
+# Async runtime
+tokio = { workspace = true }
+
+# Serialization/Deserialization
+serde = { workspace = true }
+serde_json = { workspace = true }
+serde_yaml = { workspace = true }
+
+# Helpers
+chrono = { workspace = true }
+async-trait = { workspace = true }
+
+# Error handling
+thiserror = { workspace = true }
+anyhow = { workspace = true }
+
+# Logging
+tracing = { workspace = true }
+
+# URL manipulation
+url = "2.5"
+
+# HTML scraping for station discovery
+scraper = "0.22"
+regex = "1.11"
+
+# Common music source traits
+pmosource = { path = "../pmosource" }
+
+# Configuration support
+pmoconfig = { path = "../pmoconfig", optional = true }
+
+# Cache support
+pmocovers = { path = "../pmocovers", optional = true }
+pmoaudiocache = { path = "../pmoaudiocache", optional = true }
+
+# Playlist management for FIFO support
+pmoplaylist = { path = "../pmoplaylist", optional = true }
+
+[features]
+default = ["pmoconfig"]
+# Feature for pmoconfig support
+pmoconfig = ["dep:pmoconfig"]
+# Feature for cache support
+cache = ["dep:pmocovers", "dep:pmoaudiocache"]
+# Feature for playlist/FIFO support
+playlist = ["dep:pmoplaylist"]
+# Feature for logging (tracing)
+logging = []
+# Feature for server support (cache registry)
+server = ["pmosource/server", "pmoconfig", "cache", "playlist"]
+# Full feature set
+full = ["server", "logging"]
+
+[dev-dependencies]
+tokio-test = { workspace = true }
+tracing-subscriber = { workspace = true }
+
+[[example]]
+name = "discover_stations"
+path = "examples/discover_stations.rs"
+
+[[example]]
+name = "live_metadata"
+path = "examples/live_metadata.rs"
diff --git a/pmoradiofrance/examples/discover_stations.rs b/pmoradiofrance/examples/discover_stations.rs
new file mode 100644
index 00000000..e47dfe3a
--- /dev/null
+++ b/pmoradiofrance/examples/discover_stations.rs
@@ -0,0 +1,40 @@
+//! Example: Discover all Radio France stations
+//!
+//! Run with: cargo run -p pmoradiofrance --example discover_stations
+
+use pmoradiofrance::RadioFranceClient;
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ // Initialize logging
+ tracing_subscriber::fmt::init();
+
+ println!("Discovering Radio France stations...\n");
+
+ let client = RadioFranceClient::new().await?;
+ let stations = client.discover_all_stations().await?;
+
+ // Count by type
+ 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!("Found {} stations total:\n", stations.len());
+
+ println!("=== Main Stations ({}) ===", main_count);
+ for station in stations.iter().filter(|s| s.is_main()) {
+ println!(" {} ({})", station.name, station.slug);
+ }
+
+ println!("\n=== Webradios ({}) ===", webradio_count);
+ for station in stations.iter().filter(|s| s.is_webradio()) {
+ println!(" {} ({})", station.name, station.slug);
+ }
+
+ println!("\n=== Local Radios ({}) ===", local_count);
+ for station in stations.iter().filter(|s| s.is_local_radio()) {
+ println!(" {} ({})", station.name, station.slug);
+ }
+
+ Ok(())
+}
diff --git a/pmoradiofrance/examples/live_metadata.rs b/pmoradiofrance/examples/live_metadata.rs
new file mode 100644
index 00000000..1a563e3a
--- /dev/null
+++ b/pmoradiofrance/examples/live_metadata.rs
@@ -0,0 +1,107 @@
+//! Example: Get live metadata for Radio France stations
+//!
+//! Run with: cargo run -p pmoradiofrance --example live_metadata
+//! Or with a specific station: cargo run -p pmoradiofrance --example live_metadata -- fip_rock
+
+use pmoradiofrance::RadioFranceClient;
+use std::env;
+
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ // Initialize logging
+ tracing_subscriber::fmt::init();
+
+ // Get station from command line or use default
+ let station = env::args()
+ .nth(1)
+ .unwrap_or_else(|| "franceculture".to_string());
+
+ println!("Fetching live metadata for {}...\n", station);
+
+ let client = RadioFranceClient::new().await?;
+ let metadata = client.live_metadata(&station).await?;
+
+ println!("Station: {}", metadata.station_name);
+ println!("---");
+
+ // Current show
+ println!("Now playing:");
+ println!(" Show: {}", metadata.now.first_line.title_or_default());
+ println!(" Episode: {}", metadata.now.second_line.title_or_default());
+
+ if let Some(producer) = &metadata.now.producer {
+ println!(" Producer: {}", producer);
+ }
+
+ if let Some(intro) = &metadata.now.intro {
+ let short_intro = if intro.len() > 100 {
+ format!("{}...", &intro[..100])
+ } else {
+ intro.clone()
+ };
+ println!(" Description: {}", short_intro);
+ }
+
+ // Song info (for music stations)
+ if let Some(song) = &metadata.now.song {
+ println!("\nSong info:");
+ println!(" Artist: {}", song.artists_display());
+ if let Some(album) = &song.release.title {
+ println!(" Album: {}", album);
+ }
+ if let Some(year) = song.year {
+ println!(" Year: {}", year);
+ }
+ if let Some(label) = &song.release.label {
+ println!(" Label: {}", label);
+ }
+ }
+
+ // Timing
+ println!("\nTiming:");
+ if let Some(start) = metadata.now.start_time {
+ let start_time = chrono::DateTime::from_timestamp(start as i64, 0)
+ .map(|dt| dt.format("%H:%M:%S").to_string())
+ .unwrap_or_else(|| "?".to_string());
+ println!(" Started at: {}", start_time);
+ }
+ if let Some(end) = metadata.now.end_time {
+ let end_time = chrono::DateTime::from_timestamp(end as i64, 0)
+ .map(|dt| dt.format("%H:%M:%S").to_string())
+ .unwrap_or_else(|| "?".to_string());
+ println!(" Ends at: {}", end_time);
+ }
+ println!(
+ " Next refresh in: {} seconds",
+ metadata.delay_to_refresh / 1000
+ );
+
+ // Streams
+ println!("\nAvailable streams:");
+ for source in &metadata.now.media.sources {
+ println!(
+ " {:?} {} {} kbps: {}",
+ source.broadcast_type,
+ source.format.mime_type(),
+ source.bitrate,
+ source.url
+ );
+ }
+
+ // Best HiFi stream
+ if let Some(best) = metadata.now.media.best_hifi_stream() {
+ println!("\nRecommended HiFi stream:");
+ println!(" {}", best.url);
+ }
+
+ // Next show preview
+ if let Some(next) = &metadata.next {
+ println!("\nComing up next:");
+ println!(" {}", next.first_line.title_or_default());
+ if let Some(producer) = &next.producer {
+ println!(" by {}", producer);
+ }
+ }
+
+ Ok(())
+}
diff --git a/pmoradiofrance/src/client.rs b/pmoradiofrance/src/client.rs
new file mode 100644
index 00000000..25fcc489
--- /dev/null
+++ b/pmoradiofrance/src/client.rs
@@ -0,0 +1,1212 @@
+//! HTTP client for Radio France API
+//!
+//! This module provides a client for accessing Radio France's public APIs,
+//! including station discovery, live metadata, and stream URLs.
+//!
+//! # Example
+//!
+//! ```no_run
+//! use pmoradiofrance::RadioFranceClient;
+//!
+//! #[tokio::main]
+//! async fn main() -> Result<(), Box> {
+//! let client = RadioFranceClient::new().await?;
+//!
+//! // Get live metadata for France Culture
+//! let live = client.live_metadata("franceculture").await?;
+//! println!("{} - {}",
+//! live.now.first_line.title_or_default(),
+//! live.now.second_line.title_or_default()
+//! );
+//!
+//! // Get HiFi stream URL
+//! let stream_url = client.get_hifi_stream_url("franceculture").await?;
+//! println!("Stream: {}", stream_url);
+//!
+//! Ok(())
+//! }
+//! ```
+
+use crate::error::{Error, Result};
+use crate::models::{ImageSize, LiveResponse, ShowMetadata, Station, StreamSource};
+use regex::Regex;
+use reqwest::Client;
+use scraper::{Html, Selector};
+use std::collections::HashSet;
+use std::time::Duration;
+use url::Url;
+
+/// Default Radio France base URL
+pub const DEFAULT_BASE_URL: &str = "https://www.radiofrance.fr";
+
+/// Default timeout for HTTP requests (30 seconds)
+pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
+
+/// Default User-Agent
+pub const DEFAULT_USER_AGENT: &str = "PMOMusic/0.3.10 (pmoradiofrance)";
+
+/// Known main stations (fallback if scraping fails)
+pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[
+ ("franceinter", "France Inter"),
+ ("franceinfo", "France Info"),
+ ("franceculture", "France Culture"),
+ ("francemusique", "France Musique"),
+ ("fip", "FIP"),
+ ("mouv", "Mouv'"),
+ ("francebleu", "France Bleu"),
+];
+
+/// Radio France HTTP client
+///
+/// This client provides access to Radio France's public APIs for:
+/// - Station discovery (main stations, webradios, local radios)
+/// - Live metadata (current show, next show, stream URLs)
+/// - Image URL construction (Pikapi)
+///
+/// The client is stateless and does not cache responses internally.
+/// Caching should be handled by higher layers (e.g., config extension).
+#[derive(Debug, Clone)]
+pub struct RadioFranceClient {
+ pub(crate) client: Client,
+ base_url: String,
+ timeout: Duration,
+}
+
+impl RadioFranceClient {
+ /// Create a new client with default settings
+ pub async fn new() -> Result {
+ Self::builder().build().await
+ }
+
+ /// Create a builder for configuring the client
+ pub fn builder() -> ClientBuilder {
+ ClientBuilder::default()
+ }
+
+ /// Create a client with a custom reqwest::Client
+ ///
+ /// Useful for sharing HTTP connection pools or custom proxy settings
+ pub fn with_client(client: Client) -> Self {
+ Self {
+ client,
+ base_url: DEFAULT_BASE_URL.to_string(),
+ timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
+ }
+ }
+
+ /// Get the base URL
+ pub fn base_url(&self) -> &str {
+ &self.base_url
+ }
+
+ /// Get the internal HTTP client
+ pub fn http_client(&self) -> &Client {
+ &self.client
+ }
+
+ // ========================================================================
+ // Station Discovery
+ // ========================================================================
+
+ /// Discover all available stations
+ ///
+ /// This method discovers stations through multiple sources:
+ /// 1. Main stations from the homepage
+ /// 2. Webradios for each main station (FIP, France Musique, etc.)
+ /// 3. Local radios from France Bleu API
+ ///
+ /// # Caching
+ ///
+ /// This method does NOT cache results. For caching, use the config extension
+ /// which stores results with a configurable TTL.
+ ///
+ /// # Performance
+ ///
+ /// This method makes multiple HTTP requests (~10) and may take 3-5 seconds.
+ /// Consider caching the results.
+ pub async fn discover_all_stations(&self) -> Result> {
+ let mut stations = Vec::new();
+
+ // 1. Discover main stations
+ let main_stations = self.discover_main_stations().await?;
+
+ // 2. For each main station, discover webradios
+ // Note: Skip francebleu because its "webradios" are actually local radios
+ // which we get from the API with proper "ICI" names
+ for main_station in &main_stations {
+ stations.push(main_station.clone());
+
+ // Skip francebleu - its local radios are discovered via API below
+ if main_station.slug == "francebleu" {
+ continue;
+ }
+
+ // Try to discover webradios (may return empty for some stations)
+ if let Ok(webradios) = self.discover_station_webradios(&main_station.slug).await {
+ stations.extend(webradios);
+ }
+ }
+
+ // 3. Discover France Bleu local radios via API (with correct "ICI" names)
+ if let Ok(locals) = self.discover_local_radios().await {
+ stations.extend(locals);
+ }
+
+ Ok(stations)
+ }
+
+ /// Discover main stations from the homepage
+ ///
+ /// Scrapes the Radio France homepage to find main station links.
+ /// Falls back to known stations if scraping fails.
+ pub async fn discover_main_stations(&self) -> Result> {
+ let html = self
+ .client
+ .get(&self.base_url)
+ .timeout(self.timeout)
+ .send()
+ .await?
+ .text()
+ .await?;
+
+ let mut slugs = HashSet::new();
+
+ // Method 1: Parse HTML and look for station links
+ let document = Html::parse_document(&html);
+
+ // Look for links to station pages
+ if let Ok(selector) = Selector::parse("a[href]") {
+ for element in document.select(&selector) {
+ if let Some(href) = element.value().attr("href") {
+ // Match patterns like /franceculture, /fip, etc.
+ if let Some(slug) = self.extract_station_slug_from_href(href) {
+ slugs.insert(slug);
+ }
+ }
+ }
+ }
+
+ // Method 2: Regex fallback for station names in JavaScript/JSON
+ let re = Regex::new(
+ r#"["'/](franceinter|franceinfo|franceculture|francemusique|fip|mouv|francebleu)["'/]"#,
+ )?;
+ for cap in re.captures_iter(&html) {
+ slugs.insert(cap[1].to_string());
+ }
+
+ // If we found stations, convert to Station objects
+ if !slugs.is_empty() {
+ return Ok(slugs
+ .into_iter()
+ .map(|slug| {
+ let name = Self::slug_to_display_name(&slug);
+ Station::main(slug, name)
+ })
+ .collect());
+ }
+
+ // Fallback to known stations
+ #[cfg(feature = "logging")]
+ tracing::warn!("Station discovery from HTML failed, using fallback list");
+
+ Ok(KNOWN_MAIN_STATIONS
+ .iter()
+ .map(|(slug, name)| Station::main(*slug, *name))
+ .collect())
+ }
+
+ /// Discover webradios for a given main station
+ ///
+ /// Scrapes the station page to find webradio identifiers.
+ /// Works for FIP, France Musique, and potentially other stations.
+ pub async fn discover_station_webradios(&self, station: &str) -> Result> {
+ let url = format!("{}/{}", self.base_url, station);
+ let html = self
+ .client
+ .get(&url)
+ .timeout(self.timeout)
+ .send()
+ .await?
+ .text()
+ .await?;
+
+ let mut slugs = HashSet::new();
+
+ // Look for webradio identifiers in the HTML
+ // Pattern: {station}_{variant} (e.g., fip_rock, francemusique_jazz)
+ let pattern = format!(r#"["']({}_[a-z_]+)["']"#, regex::escape(station));
+ let re = Regex::new(&pattern)?;
+
+ for cap in re.captures_iter(&html) {
+ let slug = cap[1].to_string();
+ // Exclude the main station itself
+ if slug != station {
+ slugs.insert(slug);
+ }
+ }
+
+ Ok(slugs
+ .into_iter()
+ .map(|slug| {
+ let name = Self::slug_to_display_name(&slug);
+ Station::webradio(slug, name, station)
+ })
+ .collect())
+ }
+
+ /// Discover local France Bleu radios via API
+ ///
+ /// Uses the France Bleu /api/live? endpoint which includes
+ /// a `localRadios` array in the `now` field.
+ pub async fn discover_local_radios(&self) -> Result> {
+ let response = self.live_metadata("francebleu").await?;
+
+ Ok(response
+ .local_radios()
+ .cloned()
+ .unwrap_or_default()
+ .into_iter()
+ .filter(|local| local.is_on_air)
+ .map(|local| {
+ let region = local.title.replace("ICI ", "");
+ Station::local_radio(local.name, local.title, region, local.id)
+ })
+ .collect())
+ }
+
+ /// Extract station slug from a href attribute
+ fn extract_station_slug_from_href(&self, href: &str) -> Option {
+ // Match patterns like:
+ // - /franceculture
+ // - /franceculture/...
+ // - https://www.radiofrance.fr/fip
+ let re = Regex::new(
+ r"^(?:https?://[^/]+)?/(franceinter|franceinfo|franceculture|francemusique|fip|mouv|francebleu)(?:/|$)",
+ )
+ .ok()?;
+
+ re.captures(href)
+ .and_then(|cap| cap.get(1))
+ .map(|m| m.as_str().to_string())
+ }
+
+ /// Convert a station slug to a human-readable display name
+ pub fn slug_to_display_name(slug: &str) -> String {
+ // Handle webradio slugs (e.g., fip_rock -> FIP Rock)
+ let parts: Vec<&str> = slug.split('_').collect();
+
+ if parts.len() == 1 {
+ // Main station
+ match slug {
+ "franceinter" => "France Inter".to_string(),
+ "franceinfo" => "France Info".to_string(),
+ "franceculture" => "France Culture".to_string(),
+ "francemusique" => "France Musique".to_string(),
+ "fip" => "FIP".to_string(),
+ "mouv" => "Mouv'".to_string(),
+ "francebleu" => "France Bleu".to_string(),
+ _ => Self::capitalize_words(slug),
+ }
+ } else {
+ // Webradio: combine parent name + variant
+ let parent = match parts[0] {
+ "fip" => "FIP",
+ "francemusique" => "France Musique",
+ "mouv" => "Mouv'",
+ "francebleu" => "France Bleu",
+ _ => return Self::capitalize_words(&slug.replace('_', " ")),
+ };
+
+ let variant = parts[1..]
+ .iter()
+ .map(|p| Self::capitalize_word(p))
+ .collect::>()
+ .join(" ");
+
+ format!("{} {}", parent, variant)
+ }
+ }
+
+ fn capitalize_words(s: &str) -> String {
+ s.split(|c: char| c == '_' || c == ' ')
+ .map(Self::capitalize_word)
+ .collect::>()
+ .join(" ")
+ }
+
+ fn capitalize_word(s: &str) -> String {
+ let mut chars = s.chars();
+ match chars.next() {
+ None => String::new(),
+ Some(first) => first.to_uppercase().chain(chars).collect(),
+ }
+ }
+
+ // ========================================================================
+ // Live Metadata
+ // ========================================================================
+
+ /// Get live metadata for a station
+ ///
+ /// # Arguments
+ ///
+ /// * `station` - Station slug (e.g., "franceculture", "fip_rock", "francebleu_alsace")
+ ///
+ /// # Webradio Handling
+ ///
+ /// For webradios (e.g., "fip_rock"), the client automatically adds the
+ /// `?webradio=` parameter to the API request.
+ ///
+ /// # Example
+ ///
+ /// ```no_run
+ /// # use pmoradiofrance::RadioFranceClient;
+ /// # async fn example() -> Result<(), Box> {
+ /// let client = RadioFranceClient::new().await?;
+ ///
+ /// // Main station
+ /// let fc = client.live_metadata("franceculture").await?;
+ ///
+ /// // Webradio
+ /// let fip_rock = client.live_metadata("fip_rock").await?;
+ /// # Ok(())
+ /// # }
+ /// ```
+ pub async fn live_metadata(&self, station: &str) -> Result {
+ let (base_station, webradio) = Self::parse_station_slug(station);
+
+ let mut url = Url::parse(&format!("{}/{}/api/live", self.base_url, base_station))?;
+
+ // Add webradio parameter if needed
+ if let Some(wr) = webradio {
+ url.query_pairs_mut().append_pair("webradio", wr);
+ }
+
+ #[cfg(feature = "logging")]
+ tracing::debug!("Fetching live metadata: {}", url);
+
+ let response = self.client.get(url).timeout(self.timeout).send().await?;
+
+ if !response.status().is_success() {
+ return Err(Error::ApiError(format!(
+ "API returned status: {}",
+ response.status()
+ )));
+ }
+
+ let live: LiveResponse = response.json().await?;
+
+ #[cfg(feature = "logging")]
+ tracing::debug!(
+ "Received metadata for {}: {} - {}",
+ station,
+ live.now.first_line.title_or_default(),
+ live.now.second_line.title_or_default()
+ );
+
+ Ok(live)
+ }
+
+ /// Get only the current show metadata
+ pub async fn now_playing(&self, station: &str) -> Result {
+ let response = self.live_metadata(station).await?;
+ Ok(response.now)
+ }
+
+ /// Parse a station slug to extract base station and optional webradio
+ ///
+ /// # Examples
+ ///
+ /// - "fip" → ("fip", None)
+ /// - "fip_rock" → ("fip", Some("fip_rock"))
+ /// - "francemusique_jazz" → ("francemusique", Some("francemusique_jazz"))
+ /// - "francebleu_alsace" → ("francebleu_alsace", None) - local radios don't use webradio param
+ pub fn parse_station_slug(slug: &str) -> (&str, Option<&str>) {
+ // FIP webradios
+ if slug.starts_with("fip_") {
+ return ("fip", Some(slug));
+ }
+
+ // France Musique webradios
+ if slug.starts_with("francemusique_") {
+ return ("francemusique", Some(slug));
+ }
+
+ // Mouv' webradios (if any)
+ if slug.starts_with("mouv_") {
+ return ("mouv", Some(slug));
+ }
+
+ // France Bleu local radios use their slug directly, no webradio param
+ // e.g., francebleu_alsace → francebleu_alsace/api/live
+ if slug.starts_with("francebleu_") {
+ return (slug, None);
+ }
+
+ // Main stations
+ (slug, None)
+ }
+
+ // ========================================================================
+ // Stream URLs
+ // ========================================================================
+
+ /// Get the best HiFi stream URL for a station
+ ///
+ /// Prioritizes AAC 192 kbps, falls back to HLS.
+ ///
+ /// # Example
+ ///
+ /// ```no_run
+ /// # use pmoradiofrance::RadioFranceClient;
+ /// # async fn example() -> Result<(), Box> {
+ /// let client = RadioFranceClient::new().await?;
+ /// let url = client.get_hifi_stream_url("franceculture").await?;
+ /// // Returns: https://icecast.radiofrance.fr/franceculture-hifi.aac?id=radiofrance
+ /// # Ok(())
+ /// # }
+ /// ```
+ pub async fn get_hifi_stream_url(&self, station: &str) -> Result {
+ let metadata = self.live_metadata(station).await?;
+
+ metadata
+ .now
+ .media
+ .best_hifi_stream()
+ .map(|s| s.url.clone())
+ .ok_or_else(|| Error::NoHifiStream(station.to_string()))
+ }
+
+ /// Get all available stream sources for a station
+ pub async fn get_available_streams(&self, station: &str) -> Result> {
+ let metadata = self.live_metadata(station).await?;
+ Ok(metadata.now.media.sources)
+ }
+
+ // ========================================================================
+ // Image URLs (Pikapi)
+ // ========================================================================
+
+ /// Build a Pikapi image URL from a UUID
+ ///
+ /// # Arguments
+ ///
+ /// * `uuid` - Image UUID (e.g., "436430f7-5b2b-43f2-9f3c-28f2ad6cae39")
+ /// * `size` - Desired image size
+ pub fn build_image_url(uuid: &str, size: ImageSize) -> String {
+ size.build_url(uuid)
+ }
+
+ /// Extract UUID from a Pikapi URL
+ ///
+ /// # Example
+ ///
+ /// ```
+ /// use pmoradiofrance::RadioFranceClient;
+ ///
+ /// let url = "https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39/200x200";
+ /// let uuid = RadioFranceClient::extract_image_uuid(url);
+ /// assert_eq!(uuid, Some("436430f7-5b2b-43f2-9f3c-28f2ad6cae39".to_string()));
+ /// ```
+ pub fn extract_image_uuid(url: &str) -> Option {
+ let re = Regex::new(r"/pikapi/images/([a-f0-9-]+)").ok()?;
+ re.captures(url)
+ .and_then(|cap| cap.get(1))
+ .map(|m| m.as_str().to_string())
+ }
+
+ // ========================================================================
+ // Polling Helpers
+ // ========================================================================
+
+ /// Get the recommended delay before the next metadata refresh
+ ///
+ /// Uses the `delayToRefresh` field from the API response.
+ pub fn next_refresh_delay(metadata: &LiveResponse) -> Duration {
+ Duration::from_millis(metadata.delay_to_refresh)
+ }
+
+ /// Calculate the adjusted refresh delay accounting for elapsed time
+ ///
+ /// # Arguments
+ ///
+ /// * `metadata` - The metadata response
+ /// * `fetched_at` - When the metadata was fetched
+ pub fn adjusted_refresh_delay(
+ metadata: &LiveResponse,
+ fetched_at: std::time::SystemTime,
+ ) -> Duration {
+ let base_delay = Duration::from_millis(metadata.delay_to_refresh);
+ let elapsed = fetched_at.elapsed().unwrap_or(Duration::ZERO);
+ base_delay.saturating_sub(elapsed)
+ }
+}
+
+/// Builder for configuring a RadioFranceClient
+#[derive(Debug)]
+pub struct ClientBuilder {
+ client: Option,
+ base_url: String,
+ timeout: Duration,
+ user_agent: String,
+ proxy: Option,
+}
+
+impl Default for ClientBuilder {
+ fn default() -> Self {
+ Self {
+ client: None,
+ base_url: DEFAULT_BASE_URL.to_string(),
+ timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
+ user_agent: DEFAULT_USER_AGENT.to_string(),
+ proxy: None,
+ }
+ }
+}
+
+impl ClientBuilder {
+ /// Create a new builder with default settings
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Set a custom HTTP client
+ pub fn client(mut self, client: Client) -> Self {
+ self.client = Some(client);
+ self
+ }
+
+ /// Set the base URL
+ pub fn base_url(mut self, url: impl Into) -> Self {
+ self.base_url = url.into();
+ self
+ }
+
+ /// Set the request timeout
+ pub fn timeout(mut self, timeout: Duration) -> Self {
+ self.timeout = timeout;
+ self
+ }
+
+ /// Set a custom User-Agent header
+ pub fn user_agent(mut self, user_agent: impl Into) -> Self {
+ self.user_agent = user_agent.into();
+ self
+ }
+
+ /// Set a proxy URL
+ pub fn proxy(mut self, proxy: impl Into) -> Self {
+ self.proxy = Some(proxy.into());
+ self
+ }
+
+ /// Build the client
+ pub async fn build(self) -> Result {
+ let client = if let Some(client) = self.client {
+ client
+ } else {
+ let mut builder = Client::builder()
+ .user_agent(&self.user_agent)
+ .timeout(self.timeout);
+
+ if let Some(proxy_url) = &self.proxy {
+ let proxy = reqwest::Proxy::all(proxy_url)
+ .map_err(|e| Error::other(format!("Invalid proxy: {}", e)))?;
+ builder = builder.proxy(proxy);
+ }
+
+ builder.build()?
+ };
+
+ Ok(RadioFranceClient {
+ client,
+ base_url: self.base_url,
+ timeout: self.timeout,
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // ========================================================================
+ // Unit Tests (no network)
+ // ========================================================================
+
+ #[test]
+ fn test_parse_station_slug() {
+ assert_eq!(RadioFranceClient::parse_station_slug("fip"), ("fip", None));
+ assert_eq!(
+ RadioFranceClient::parse_station_slug("fip_rock"),
+ ("fip", Some("fip_rock"))
+ );
+ assert_eq!(
+ RadioFranceClient::parse_station_slug("francemusique_jazz"),
+ ("francemusique", Some("francemusique_jazz"))
+ );
+ assert_eq!(
+ RadioFranceClient::parse_station_slug("francebleu_alsace"),
+ ("francebleu_alsace", None)
+ );
+ assert_eq!(
+ RadioFranceClient::parse_station_slug("franceculture"),
+ ("franceculture", None)
+ );
+ }
+
+ #[test]
+ fn test_slug_to_display_name() {
+ assert_eq!(
+ RadioFranceClient::slug_to_display_name("franceculture"),
+ "France Culture"
+ );
+ assert_eq!(RadioFranceClient::slug_to_display_name("fip"), "FIP");
+ assert_eq!(
+ RadioFranceClient::slug_to_display_name("fip_rock"),
+ "FIP Rock"
+ );
+ assert_eq!(
+ RadioFranceClient::slug_to_display_name("francemusique_la_jazz"),
+ "France Musique La Jazz"
+ );
+ }
+
+ #[test]
+ fn test_extract_image_uuid() {
+ let url =
+ "https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39/200x200";
+ assert_eq!(
+ RadioFranceClient::extract_image_uuid(url),
+ Some("436430f7-5b2b-43f2-9f3c-28f2ad6cae39".to_string())
+ );
+
+ let url_no_size =
+ "https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39";
+ assert_eq!(
+ RadioFranceClient::extract_image_uuid(url_no_size),
+ Some("436430f7-5b2b-43f2-9f3c-28f2ad6cae39".to_string())
+ );
+ }
+
+ #[test]
+ fn test_builder_defaults() {
+ let builder = ClientBuilder::default();
+ assert_eq!(builder.base_url, DEFAULT_BASE_URL);
+ assert_eq!(
+ builder.timeout,
+ Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS)
+ );
+ }
+
+ // ========================================================================
+ // Integration Tests (real API calls)
+ //
+ // Run with: cargo test -p pmoradiofrance -- --ignored
+ // ========================================================================
+
+ /// Test client creation
+ #[tokio::test]
+ #[ignore = "Integration test - calls real Radio France API"]
+ async fn test_client_creation() {
+ let client = RadioFranceClient::new().await;
+ assert!(
+ client.is_ok(),
+ "Failed to create client: {:?}",
+ client.err()
+ );
+ }
+
+ /// Test live metadata for France Culture (talk radio)
+ #[tokio::test]
+ #[ignore = "Integration test - calls real Radio France API"]
+ async fn test_live_metadata_franceculture() {
+ let client = RadioFranceClient::new()
+ .await
+ .expect("Failed to create client");
+ let metadata = client.live_metadata("franceculture").await;
+
+ assert!(
+ metadata.is_ok(),
+ "Failed to get France Culture metadata: {:?}",
+ metadata.err()
+ );
+
+ let metadata = metadata.unwrap();
+ assert_eq!(metadata.station_name, "franceculture");
+ assert!(
+ metadata.delay_to_refresh > 0,
+ "delay_to_refresh should be positive"
+ );
+
+ // France Culture should have show info
+ assert!(
+ metadata.now.first_line.title.is_some() || metadata.now.second_line.title.is_some(),
+ "Expected at least one title line"
+ );
+
+ // Should have media sources
+ assert!(
+ !metadata.now.media.sources.is_empty(),
+ "Expected media sources"
+ );
+
+ println!(
+ "France Culture - Now: {} - {}",
+ metadata.now.first_line.title_or_default(),
+ metadata.now.second_line.title_or_default()
+ );
+ println!(" Producer: {:?}", metadata.now.producer);
+ println!(" Delay to refresh: {} ms", metadata.delay_to_refresh);
+ println!(" Sources: {} available", metadata.now.media.sources.len());
+ }
+
+ /// Test live metadata for France Inter
+ #[tokio::test]
+ #[ignore = "Integration test - calls real Radio France API"]
+ async fn test_live_metadata_franceinter() {
+ let client = RadioFranceClient::new()
+ .await
+ .expect("Failed to create client");
+ let metadata = client.live_metadata("franceinter").await;
+
+ assert!(
+ metadata.is_ok(),
+ "Failed to get France Inter metadata: {:?}",
+ metadata.err()
+ );
+
+ let metadata = metadata.unwrap();
+ assert_eq!(metadata.station_name, "franceinter");
+ assert!(
+ !metadata.now.media.sources.is_empty(),
+ "Expected media sources"
+ );
+
+ println!(
+ "France Inter - Now: {} - {}",
+ metadata.now.first_line.title_or_default(),
+ metadata.now.second_line.title_or_default()
+ );
+ }
+
+ /// Test live metadata for FIP (music radio - should have song info)
+ #[tokio::test]
+ #[ignore = "Integration test - calls real Radio France API"]
+ async fn test_live_metadata_fip() {
+ let client = RadioFranceClient::new()
+ .await
+ .expect("Failed to create client");
+ let metadata = client.live_metadata("fip").await;
+
+ assert!(
+ metadata.is_ok(),
+ "Failed to get FIP metadata: {:?}",
+ metadata.err()
+ );
+
+ let metadata = metadata.unwrap();
+ assert_eq!(metadata.station_name, "fip");
+
+ // FIP often has song info (but not always during talk segments)
+ if let Some(song) = &metadata.now.song {
+ println!(
+ "FIP - Now playing: {} - {}",
+ song.artists_display(),
+ metadata.now.first_line.title_or_default()
+ );
+ if let Some(album) = &song.release.title {
+ println!(" Album: {}", album);
+ }
+ } else {
+ println!(
+ "FIP - Now: {} (no song info)",
+ metadata.now.first_line.title_or_default()
+ );
+ }
+ }
+
+ /// Test live metadata for FIP Rock webradio
+ #[tokio::test]
+ #[ignore = "Integration test - calls real Radio France API"]
+ async fn test_live_metadata_fip_rock() {
+ let client = RadioFranceClient::new()
+ .await
+ .expect("Failed to create client");
+ let metadata = client.live_metadata("fip_rock").await;
+
+ assert!(
+ metadata.is_ok(),
+ "Failed to get FIP Rock metadata: {:?}",
+ metadata.err()
+ );
+
+ let metadata = metadata.unwrap();
+ // API returns "fip" as station_name even for webradios
+ assert!(
+ metadata.station_name == "fip" || metadata.station_name == "fip_rock",
+ "Unexpected station name: {}",
+ metadata.station_name
+ );
+
+ println!(
+ "FIP Rock - Now: {}",
+ metadata.now.first_line.title_or_default()
+ );
+ }
+
+ /// Test live metadata for France Musique
+ #[tokio::test]
+ #[ignore = "Integration test - calls real Radio France API"]
+ async fn test_live_metadata_francemusique() {
+ let client = RadioFranceClient::new()
+ .await
+ .expect("Failed to create client");
+ let metadata = client.live_metadata("francemusique").await;
+
+ assert!(
+ metadata.is_ok(),
+ "Failed to get France Musique metadata: {:?}",
+ metadata.err()
+ );
+
+ let metadata = metadata.unwrap();
+ assert_eq!(metadata.station_name, "francemusique");
+
+ println!(
+ "France Musique - Now: {} - {}",
+ metadata.now.first_line.title_or_default(),
+ metadata.now.second_line.title_or_default()
+ );
+ }
+
+ /// Test live metadata for France Bleu (should have local radios)
+ #[tokio::test]
+ #[ignore = "Integration test - calls real Radio France API"]
+ async fn test_live_metadata_francebleu() {
+ let client = RadioFranceClient::new()
+ .await
+ .expect("Failed to create client");
+ let metadata = client.live_metadata("francebleu").await;
+
+ assert!(
+ metadata.is_ok(),
+ "Failed to get France Bleu metadata: {:?}",
+ metadata.err()
+ );
+
+ let metadata = metadata.unwrap();
+ assert_eq!(metadata.station_name, "francebleu");
+
+ // France Bleu should have local radios (in now.local_radios)
+ if let Some(locals) = metadata.local_radios() {
+ println!("France Bleu - {} local radios found", locals.len());
+ assert!(!locals.is_empty(), "Expected local radios for France Bleu");
+
+ // Print first 5 local radios
+ for local in locals.iter().take(5) {
+ println!(" - {} ({})", local.title, local.name);
+ }
+ } else {
+ panic!("Expected local_radios field for France Bleu");
+ }
+ }
+
+ /// Test live metadata for Mouv'
+ #[tokio::test]
+ #[ignore = "Integration test - calls real Radio France API"]
+ async fn test_live_metadata_mouv() {
+ let client = RadioFranceClient::new()
+ .await
+ .expect("Failed to create client");
+ let metadata = client.live_metadata("mouv").await;
+
+ assert!(
+ metadata.is_ok(),
+ "Failed to get Mouv' metadata: {:?}",
+ metadata.err()
+ );
+
+ let metadata = metadata.unwrap();
+ assert_eq!(metadata.station_name, "mouv");
+
+ println!(
+ "Mouv' - Now: {} - {}",
+ metadata.now.first_line.title_or_default(),
+ metadata.now.second_line.title_or_default()
+ );
+ }
+
+ /// Test HiFi stream URL retrieval
+ #[tokio::test]
+ #[ignore = "Integration test - calls real Radio France API"]
+ async fn test_get_hifi_stream_url() {
+ let client = RadioFranceClient::new()
+ .await
+ .expect("Failed to create client");
+
+ // Test multiple stations
+ for station in &["franceculture", "franceinter", "fip", "francemusique"] {
+ let url = client.get_hifi_stream_url(station).await;
+ assert!(
+ url.is_ok(),
+ "Failed to get HiFi stream for {}: {:?}",
+ station,
+ url.err()
+ );
+
+ let url = url.unwrap();
+ assert!(
+ url.contains("icecast.radiofrance.fr") || url.contains("stream.radiofrance.fr"),
+ "Unexpected stream URL for {}: {}",
+ station,
+ url
+ );
+
+ println!("{}: {}", station, url);
+ }
+ }
+
+ /// Test available streams listing
+ #[tokio::test]
+ #[ignore = "Integration test - calls real Radio France API"]
+ async fn test_get_available_streams() {
+ let client = RadioFranceClient::new()
+ .await
+ .expect("Failed to create client");
+ let streams = client.get_available_streams("franceculture").await;
+
+ assert!(
+ streams.is_ok(),
+ "Failed to get streams: {:?}",
+ streams.err()
+ );
+
+ let streams = streams.unwrap();
+ assert!(!streams.is_empty(), "Expected at least one stream");
+
+ println!("France Culture streams:");
+ for stream in &streams {
+ println!(
+ " - {} {:?} {} kbps: {}",
+ stream.format.mime_type(),
+ stream.broadcast_type,
+ stream.bitrate,
+ stream.url
+ );
+ }
+
+ // Should have at least AAC and HLS
+ let has_aac = streams
+ .iter()
+ .any(|s| s.format == crate::models::StreamFormat::Aac);
+ let has_hls = streams
+ .iter()
+ .any(|s| s.format == crate::models::StreamFormat::Hls);
+
+ assert!(has_aac || has_hls, "Expected at least AAC or HLS stream");
+ }
+
+ /// Test main station discovery
+ #[tokio::test]
+ #[ignore = "Integration test - calls real Radio France API"]
+ async fn test_discover_main_stations() {
+ let client = RadioFranceClient::new()
+ .await
+ .expect("Failed to create client");
+ let stations = client.discover_main_stations().await;
+
+ assert!(
+ stations.is_ok(),
+ "Failed to discover main stations: {:?}",
+ stations.err()
+ );
+
+ let stations = stations.unwrap();
+ assert!(!stations.is_empty(), "Expected at least one main station");
+
+ println!("Discovered {} main stations:", stations.len());
+ for station in &stations {
+ println!(" - {} ({})", station.name, station.slug);
+ }
+
+ // Should have the core stations
+ let slugs: Vec<&str> = stations.iter().map(|s| s.slug.as_str()).collect();
+ assert!(
+ slugs.contains(&"franceinter")
+ || slugs.contains(&"franceculture")
+ || slugs.contains(&"fip"),
+ "Expected at least one of franceinter, franceculture, or fip"
+ );
+ }
+
+ /// Test FIP webradios discovery
+ #[tokio::test]
+ #[ignore = "Integration test - calls real Radio France API"]
+ async fn test_discover_fip_webradios() {
+ let client = RadioFranceClient::new()
+ .await
+ .expect("Failed to create client");
+ let webradios = client.discover_station_webradios("fip").await;
+
+ assert!(
+ webradios.is_ok(),
+ "Failed to discover FIP webradios: {:?}",
+ webradios.err()
+ );
+
+ let webradios = webradios.unwrap();
+ println!("Discovered {} FIP webradios:", webradios.len());
+ for wr in &webradios {
+ println!(" - {} ({})", wr.name, wr.slug);
+ }
+
+ // FIP should have multiple webradios (rock, jazz, etc.)
+ if !webradios.is_empty() {
+ // At least verify they have the right parent
+ for wr in &webradios {
+ assert!(
+ wr.slug.starts_with("fip_"),
+ "Webradio slug should start with fip_: {}",
+ wr.slug
+ );
+ }
+ }
+ }
+
+ /// Test France Musique webradios discovery
+ #[tokio::test]
+ #[ignore = "Integration test - calls real Radio France API"]
+ async fn test_discover_francemusique_webradios() {
+ let client = RadioFranceClient::new()
+ .await
+ .expect("Failed to create client");
+ let webradios = client.discover_station_webradios("francemusique").await;
+
+ assert!(
+ webradios.is_ok(),
+ "Failed to discover France Musique webradios: {:?}",
+ webradios.err()
+ );
+
+ let webradios = webradios.unwrap();
+ println!("Discovered {} France Musique webradios:", webradios.len());
+ for wr in &webradios {
+ println!(" - {} ({})", wr.name, wr.slug);
+ }
+ }
+
+ /// Test local radios discovery (France Bleu)
+ #[tokio::test]
+ #[ignore = "Integration test - calls real Radio France API"]
+ async fn test_discover_local_radios() {
+ let client = RadioFranceClient::new()
+ .await
+ .expect("Failed to create client");
+ let locals = client.discover_local_radios().await;
+
+ assert!(
+ locals.is_ok(),
+ "Failed to discover local radios: {:?}",
+ locals.err()
+ );
+
+ let locals = locals.unwrap();
+ assert!(!locals.is_empty(), "Expected local radios from France Bleu");
+
+ println!("Discovered {} France Bleu local radios:", locals.len());
+ for local in locals.iter().take(10) {
+ println!(" - {} ({})", local.name, local.slug);
+ }
+
+ // Should have ~40 local radios
+ assert!(
+ locals.len() >= 30,
+ "Expected at least 30 local radios, got {}",
+ locals.len()
+ );
+ }
+
+ /// Test full station discovery
+ #[tokio::test]
+ #[ignore = "Integration test - calls real Radio France API"]
+ async fn test_discover_all_stations() {
+ let client = RadioFranceClient::new()
+ .await
+ .expect("Failed to create client");
+ let stations = client.discover_all_stations().await;
+
+ assert!(
+ stations.is_ok(),
+ "Failed to discover all stations: {:?}",
+ stations.err()
+ );
+
+ let stations = stations.unwrap();
+ assert!(!stations.is_empty(), "Expected stations");
+
+ // Count by type
+ 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());
+ 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!(
+ stations.len() >= 40,
+ "Expected at least 40 total stations, got {}",
+ stations.len()
+ );
+ }
+
+ /// Test that invalid station returns an error
+ #[tokio::test]
+ #[ignore = "Integration test - calls real Radio France API"]
+ async fn test_invalid_station() {
+ let client = RadioFranceClient::new()
+ .await
+ .expect("Failed to create client");
+ let result = client.live_metadata("nonexistent_station_xyz").await;
+
+ // Should fail with API error or similar
+ assert!(result.is_err(), "Expected error for invalid station");
+ println!("Got expected error: {:?}", result.err());
+ }
+
+ /// Test refresh delay calculation
+ #[tokio::test]
+ #[ignore = "Integration test - calls real Radio France API"]
+ async fn test_refresh_delay() {
+ let client = RadioFranceClient::new()
+ .await
+ .expect("Failed to create client");
+ let metadata = client
+ .live_metadata("franceculture")
+ .await
+ .expect("Failed to get metadata");
+
+ let delay = RadioFranceClient::next_refresh_delay(&metadata);
+ assert!(delay.as_millis() > 0, "Expected positive delay");
+
+ println!("Recommended refresh delay: {:?}", delay);
+
+ // Test adjusted delay
+ let fetched_at = std::time::SystemTime::now();
+ std::thread::sleep(std::time::Duration::from_millis(100));
+ let adjusted = RadioFranceClient::adjusted_refresh_delay(&metadata, fetched_at);
+
+ assert!(
+ adjusted < delay,
+ "Adjusted delay should be less than original"
+ );
+ println!("Adjusted delay after 100ms: {:?}", adjusted);
+ }
+}
diff --git a/pmoradiofrance/src/config_ext.rs b/pmoradiofrance/src/config_ext.rs
new file mode 100644
index 00000000..e73a170f
--- /dev/null
+++ b/pmoradiofrance/src/config_ext.rs
@@ -0,0 +1,234 @@
+//! Extension pour intégrer Radio France dans pmoconfig
+//!
+//! Ce module fournit le trait `RadioFranceConfigExt` qui permet d'ajouter
+//! des méthodes de gestion de la configuration Radio France à pmoconfig::Config.
+//!
+//! # Fonctionnalités
+//!
+//! - Activation/désactivation de la source
+//! - Cache de la liste des stations (TTL configurable, défaut 7 jours)
+//! - Configuration minimale (pas de sur-configuration)
+//!
+//! # Exemple
+//!
+//! ```rust,ignore
+//! use pmoconfig::get_config;
+//! use pmoradiofrance::RadioFranceConfigExt;
+//!
+//! let config = get_config();
+//!
+//! // Check if enabled
+//! if !config.get_radiofrance_enabled()? {
+//! println!("Radio France is disabled");
+//! return Ok(());
+//! }
+//!
+//! // Get cached stations (or None if cache expired/empty)
+//! if let Some(cached) = config.get_radiofrance_cached_stations()? {
+//! println!("Found {} cached stations", cached.stations.len());
+//! }
+//! ```
+
+use crate::models::{CachedStationList, Station};
+use anyhow::Result;
+use pmoconfig::Config;
+use serde_yaml::Value;
+
+/// Default TTL for station list cache (7 days in seconds)
+pub const DEFAULT_STATION_CACHE_TTL_SECS: u64 = 7 * 24 * 3600;
+
+/// Trait d'extension pour gérer la configuration Radio France dans pmoconfig
+///
+/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques
+/// à la gestion de Radio France, incluant :
+///
+/// - Activation/désactivation
+/// - Cache de la liste des stations
+///
+/// # Auto-persist des valeurs par défaut
+///
+/// Les getters persistent automatiquement les valeurs par défaut dans la
+/// configuration si elles n'existent pas encore.
+pub trait RadioFranceConfigExt {
+ // ========================================================================
+ // Enable/Disable
+ // ========================================================================
+
+ /// Vérifie si Radio France est activé
+ ///
+ /// # Returns
+ ///
+ /// `true` si la source est activée (default), `false` sinon.
+ fn get_radiofrance_enabled(&self) -> Result;
+
+ /// Active ou désactive Radio France
+ fn set_radiofrance_enabled(&self, enabled: bool) -> Result<()>;
+
+ // ========================================================================
+ // Station Cache
+ // ========================================================================
+
+ /// Récupère la liste des stations en cache
+ ///
+ /// # Returns
+ ///
+ /// - `Some(CachedStationList)` si le cache existe et est valide
+ /// - `None` si le cache n'existe pas ou est expiré
+ ///
+ /// # Cache Validation
+ ///
+ /// Le cache est considéré invalide si :
+ /// - Il n'existe pas
+ /// - Son TTL est dépassé (configurable, défaut 7 jours)
+ /// - Sa version ne correspond pas à la version actuelle de l'algorithme
+ fn get_radiofrance_cached_stations(&self) -> Result>;
+
+ /// Enregistre la liste des stations en cache
+ ///
+ /// # Arguments
+ ///
+ /// * `stations` - Liste des stations découvertes
+ fn set_radiofrance_cached_stations(&self, stations: &[Station]) -> Result<()>;
+
+ /// Récupère le TTL du cache des stations (en secondes)
+ ///
+ /// # Returns
+ ///
+ /// Le TTL en secondes (default: 7 jours)
+ fn get_radiofrance_station_cache_ttl(&self) -> Result;
+
+ /// Définit le TTL du cache des stations (en secondes)
+ 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)
+ 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
+ ///
+ /// ```rust,ignore
+ /// 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
+ /// };
+ /// ```
+ fn get_radiofrance_stations_cached(&self) -> Result>>;
+}
+
+impl RadioFranceConfigExt for Config {
+ fn get_radiofrance_enabled(&self) -> Result {
+ match self.get_value(&["sources", "radiofrance", "enabled"]) {
+ Ok(Value::Bool(b)) => Ok(b),
+ _ => {
+ // Default: enabled
+ self.set_radiofrance_enabled(true)?;
+ Ok(true)
+ }
+ }
+ }
+
+ fn set_radiofrance_enabled(&self, enabled: bool) -> Result<()> {
+ self.set_value(&["sources", "radiofrance", "enabled"], Value::Bool(enabled))
+ }
+
+ fn get_radiofrance_cached_stations(&self) -> Result> {
+ let ttl = self.get_radiofrance_station_cache_ttl()?;
+
+ match self.get_value(&["sources", "radiofrance", "station_cache"]) {
+ Ok(value) => {
+ // Try to deserialize the cached data
+ let cached: CachedStationList = serde_yaml::from_value(value)?;
+
+ // Check validity
+ if cached.is_valid(ttl) {
+ Ok(Some(cached))
+ } else {
+ // Cache expired or version mismatch
+ Ok(None)
+ }
+ }
+ Err(_) => Ok(None),
+ }
+ }
+
+ fn set_radiofrance_cached_stations(&self, stations: &[Station]) -> Result<()> {
+ let cached = CachedStationList::new(stations.to_vec());
+ let value = serde_yaml::to_value(&cached)?;
+ self.set_value(&["sources", "radiofrance", "station_cache"], value)
+ }
+
+ fn get_radiofrance_station_cache_ttl(&self) -> Result {
+ match self.get_value(&["sources", "radiofrance", "station_cache_ttl_secs"]) {
+ Ok(Value::Number(n)) => {
+ if let Some(ttl) = n.as_u64() {
+ Ok(ttl)
+ } else {
+ // Invalid number, use default
+ self.set_radiofrance_station_cache_ttl(DEFAULT_STATION_CACHE_TTL_SECS)?;
+ Ok(DEFAULT_STATION_CACHE_TTL_SECS)
+ }
+ }
+ _ => {
+ // Not set, use default and persist
+ self.set_radiofrance_station_cache_ttl(DEFAULT_STATION_CACHE_TTL_SECS)?;
+ Ok(DEFAULT_STATION_CACHE_TTL_SECS)
+ }
+ }
+ }
+
+ fn set_radiofrance_station_cache_ttl(&self, ttl_secs: u64) -> Result<()> {
+ self.set_value(
+ &["sources", "radiofrance", "station_cache_ttl_secs"],
+ Value::Number(serde_yaml::Number::from(ttl_secs)),
+ )
+ }
+
+ fn is_radiofrance_station_cache_valid(&self) -> bool {
+ self.get_radiofrance_cached_stations()
+ .ok()
+ .flatten()
+ .is_some()
+ }
+
+ fn clear_radiofrance_station_cache(&self) -> Result<()> {
+ // Set to null to clear
+ self.set_value(&["sources", "radiofrance", "station_cache"], Value::Null)
+ }
+
+ fn get_radiofrance_stations_cached(&self) -> Result>> {
+ Ok(self
+ .get_radiofrance_cached_stations()?
+ .map(|cached| cached.stations))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_default_ttl() {
+ // 7 days in seconds
+ assert_eq!(DEFAULT_STATION_CACHE_TTL_SECS, 7 * 24 * 3600);
+ }
+}
diff --git a/pmoradiofrance/src/error.rs b/pmoradiofrance/src/error.rs
new file mode 100644
index 00000000..0fa36d2b
--- /dev/null
+++ b/pmoradiofrance/src/error.rs
@@ -0,0 +1,73 @@
+//! Error types for the Radio France client
+
+/// Result type alias for Radio France operations
+pub type Result = std::result::Result;
+
+/// Errors that can occur when using the Radio France client
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ /// HTTP request failed
+ #[error("HTTP request failed: {0}")]
+ Http(#[from] reqwest::Error),
+
+ /// JSON parsing failed
+ #[error("JSON parsing failed: {0}")]
+ Json(#[from] serde_json::Error),
+
+ /// Invalid URL
+ #[error("Invalid URL: {0}")]
+ InvalidUrl(#[from] url::ParseError),
+
+ /// IO error
+ #[error("IO error: {0}")]
+ Io(#[from] std::io::Error),
+
+ /// API returned an error status
+ #[error("API error: {0}")]
+ ApiError(String),
+
+ /// Station not found
+ #[error("Station not found: {0}")]
+ StationNotFound(String),
+
+ /// No HiFi stream available for station
+ #[error("No HiFi stream found for station: {0}")]
+ NoHifiStream(String),
+
+ /// Scraping failed (HTML parsing error)
+ #[error("Scraping failed: {0}")]
+ ScrapingError(String),
+
+ /// Regex error
+ #[error("Regex error: {0}")]
+ RegexError(#[from] regex::Error),
+
+ /// Invalid station slug format
+ #[error("Invalid station slug: {0}")]
+ InvalidSlug(String),
+
+ /// Timeout error
+ #[error("Request timeout")]
+ Timeout,
+
+ /// Generic error
+ #[error("{0}")]
+ Other(String),
+}
+
+impl Error {
+ /// Create a generic error from a string
+ pub fn other(msg: impl Into) -> Self {
+ Self::Other(msg.into())
+ }
+
+ /// Create an API error
+ pub fn api_error(msg: impl Into) -> Self {
+ Self::ApiError(msg.into())
+ }
+
+ /// Create a scraping error
+ pub fn scraping_error(msg: impl Into) -> Self {
+ Self::ScrapingError(msg.into())
+ }
+}
diff --git a/pmoradiofrance/src/lib.rs b/pmoradiofrance/src/lib.rs
new file mode 100644
index 00000000..64e17ea6
--- /dev/null
+++ b/pmoradiofrance/src/lib.rs
@@ -0,0 +1,103 @@
+//! Radio France client library for PMOMusic
+//!
+//! This crate provides a Rust client for accessing Radio France's public APIs,
+//! including live metadata, station discovery, and stream URLs.
+//!
+//! # Features
+//!
+//! - **Station Discovery**: Discover all Radio France stations dynamically
+//! (main stations, webradios, and local France Bleu radios)
+//! - **Live Metadata**: Get current show information, producers, visuals
+//! - **Stream URLs**: Get HiFi stream URLs (AAC 192 kbps, HLS)
+//! - **Polling Support**: Intelligent refresh delay based on API recommendations
+//! - **Configuration Extension**: Cache station lists with configurable TTL
+//!
+//! # Supported Stations
+//!
+//! - **Main Stations**: France Inter, France Info, France Culture, France Musique,
+//! FIP, Mouv', France Bleu
+//! - **Webradios**: FIP Rock, FIP Jazz, France Musique Baroque, etc.
+//! - **Local Radios**: ~40 France Bleu local stations
+//!
+//! # Example
+//!
+//! ```no_run
+//! use pmoradiofrance::{RadioFranceClient, ImageSize};
+//!
+//! #[tokio::main]
+//! async fn main() -> Result<(), Box> {
+//! let client = RadioFranceClient::new().await?;
+//!
+//! // Discover all stations
+//! let stations = client.discover_all_stations().await?;
+//! println!("Found {} stations", stations.len());
+//!
+//! // Get live metadata
+//! let live = client.live_metadata("franceculture").await?;
+//! println!("Now: {} - {}",
+//! live.now.first_line.title_or_default(),
+//! live.now.second_line.title_or_default()
+//! );
+//!
+//! // Get HiFi stream URL
+//! let stream_url = client.get_hifi_stream_url("franceculture").await?;
+//! println!("Stream: {}", stream_url);
+//!
+//! Ok(())
+//! }
+//! ```
+//!
+//! # Configuration Extension
+//!
+//! When the `pmoconfig` feature is enabled, this crate provides a configuration
+//! extension trait for caching station lists:
+//!
+//! ```rust,ignore
+//! use pmoconfig::get_config;
+//! use pmoradiofrance::RadioFranceConfigExt;
+//!
+//! let config = get_config();
+//!
+//! // Check cached stations (default TTL: 7 days)
+//! if let Some(stations) = config.get_radiofrance_stations_cached()? {
+//! println!("Using {} cached stations", stations.len());
+//! } else {
+//! // Cache miss - need to discover
+//! let client = RadioFranceClient::new().await?;
+//! let stations = client.discover_all_stations().await?;
+//! config.set_radiofrance_cached_stations(&stations)?;
+//! }
+//! ```
+//!
+//! # API Rate Limiting
+//!
+//! Radio France's APIs don't have documented rate limits, but the `delayToRefresh`
+//! field in responses indicates the recommended polling interval. Always use
+//! `RadioFranceClient::next_refresh_delay()` to respect this.
+//!
+//! # Audio Quality
+//!
+//! This client focuses on HiFi quality only:
+//! - **AAC 192 kbps**: Primary format (best quality)
+//! - **HLS**: Adaptive streaming fallback
+//!
+//! Lower quality formats (lofi, midfi) are not prioritized but are available
+//! in the `StreamSource` list if needed.
+
+pub mod client;
+pub mod error;
+pub mod models;
+
+#[cfg(feature = "pmoconfig")]
+pub mod config_ext;
+
+// Re-exports
+pub use client::{ClientBuilder, RadioFranceClient};
+pub use error::{Error, Result};
+pub use models::{
+ BroadcastType, CachedStationList, EmbedImage, ImageSize, Line, LiveResponse, LocalRadio, Media,
+ Release, ShowMetadata, Song, Station, StationType, StreamFormat, StreamSource, Visuals,
+};
+
+#[cfg(feature = "pmoconfig")]
+pub use config_ext::RadioFranceConfigExt;
diff --git a/pmoradiofrance/src/models.rs b/pmoradiofrance/src/models.rs
new file mode 100644
index 00000000..dcf9e099
--- /dev/null
+++ b/pmoradiofrance/src/models.rs
@@ -0,0 +1,518 @@
+//! Data models for Radio France API responses
+//!
+//! This module contains all the structures needed to deserialize
+//! responses from Radio France's public APIs.
+
+use serde::{Deserialize, Serialize};
+
+// ============================================================================
+// Station Discovery Models
+// ============================================================================
+
+/// A discovered Radio France station
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct Station {
+ /// Unique slug identifier (e.g., "franceculture", "fip_rock")
+ pub slug: String,
+ /// Human-readable name (e.g., "France Culture", "FIP Rock")
+ 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 {
+ /// Create a new main station
+ pub fn main(slug: impl Into, name: impl Into) -> Self {
+ Self {
+ slug: slug.into(),
+ name: name.into(),
+ station_type: StationType::Main,
+ }
+ }
+
+ /// Create a new webradio station
+ pub fn webradio(
+ slug: impl Into,
+ name: impl Into,
+ parent: impl Into,
+ ) -> 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,
+ name: impl Into,
+ region: impl Into,
+ 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,
+ }
+ }
+}
+
+// ============================================================================
+// Live API Response Models
+// ============================================================================
+
+/// Response from the /api/live? endpoint
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct LiveResponse {
+ /// Station name (slug)
+ pub station_name: String,
+ /// Recommended delay before next refresh (milliseconds)
+ pub delay_to_refresh: u64,
+ /// Whether station has been migrated to new system
+ #[serde(default)]
+ pub migrated: bool,
+ /// Current show/track metadata
+ pub now: ShowMetadata,
+ /// Next show/track metadata (if available)
+ pub next: Option,
+}
+
+impl LiveResponse {
+ /// Get local radios (France Bleu only) - convenience accessor
+ pub fn local_radios(&self) -> Option<&Vec> {
+ self.now.local_radios.as_ref()
+ }
+}
+
+/// Metadata for a show or track currently playing
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ShowMetadata {
+ /// Whether to display music program info
+ #[serde(default)]
+ pub print_prog_music: bool,
+ /// Start time (Unix timestamp)
+ pub start_time: Option,
+ /// End time (Unix timestamp)
+ pub end_time: Option,
+ /// Producer name
+ pub producer: Option,
+ /// First line (usually show title)
+ #[serde(default)]
+ pub first_line: Line,
+ /// Second line (usually episode/track title)
+ #[serde(default)]
+ pub second_line: Line,
+ /// Third line (optional subtitle)
+ pub third_line: Option,
+ /// Show description/intro
+ pub intro: Option,
+ /// React availability flag
+ #[serde(default)]
+ pub react_available: bool,
+ /// Background visual
+ pub visual_background: Option,
+ /// Song info (for music stations like FIP, France Musique)
+ pub song: Option,
+ /// Available media streams
+ #[serde(default)]
+ pub media: Media,
+ /// Visual assets (card, player)
+ pub visuals: Option,
+ /// Local radios list (France Bleu only)
+ #[serde(default)]
+ pub local_radios: Option>,
+}
+
+/// A line of text with optional link
+#[derive(Debug, Clone, Default, Deserialize)]
+pub struct Line {
+ /// Text content
+ pub title: Option,
+ /// UUID of the referenced object
+ pub id: Option,
+ /// URL path to the referenced page
+ pub path: Option,
+}
+
+impl Line {
+ /// Get the title or an empty string
+ pub fn title_or_default(&self) -> &str {
+ self.title.as_deref().unwrap_or("")
+ }
+}
+
+/// Song information (for music stations)
+#[derive(Debug, Clone, Deserialize)]
+pub struct Song {
+ /// Song UUID
+ pub id: String,
+ /// Release year
+ pub year: Option,
+ /// Artist names
+ #[serde(default)]
+ pub interpreters: Vec,
+ /// Album/release information
+ #[serde(default)]
+ pub release: Release,
+}
+
+impl Song {
+ /// Get artists as a comma-separated string
+ pub fn artists_display(&self) -> String {
+ self.interpreters.join(", ")
+ }
+}
+
+/// Album/release information
+#[derive(Debug, Clone, Default, Deserialize)]
+pub struct Release {
+ /// Record label
+ pub label: Option,
+ /// Album title
+ pub title: Option,
+ /// Catalog reference
+ pub reference: Option,
+}
+
+/// Available media streams
+#[derive(Debug, Clone, Default, Deserialize)]
+pub struct Media {
+ /// List of available stream sources
+ #[serde(default)]
+ pub sources: Vec,
+}
+
+impl Media {
+ /// Find the best HiFi stream (AAC 192 kbps or HLS)
+ pub fn best_hifi_stream(&self) -> Option<&StreamSource> {
+ // Priority: AAC 192 kbps > HLS
+ self.sources
+ .iter()
+ .find(|s| {
+ s.format == StreamFormat::Aac
+ && s.broadcast_type == BroadcastType::Live
+ && s.bitrate == 192
+ })
+ .or_else(|| {
+ self.sources.iter().find(|s| {
+ s.format == StreamFormat::Hls && s.broadcast_type == BroadcastType::Live
+ })
+ })
+ }
+
+ /// Find a stream by format and broadcast type
+ pub fn find_stream(
+ &self,
+ format: StreamFormat,
+ broadcast_type: BroadcastType,
+ ) -> Option<&StreamSource> {
+ self.sources
+ .iter()
+ .find(|s| s.format == format && s.broadcast_type == broadcast_type)
+ }
+
+ /// Get all live streams
+ pub fn live_streams(&self) -> impl Iterator- {
+ self.sources
+ .iter()
+ .filter(|s| s.broadcast_type == BroadcastType::Live)
+ }
+}
+
+/// A stream source with URL and format info
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct StreamSource {
+ /// Stream URL
+ pub url: String,
+ /// Broadcast type (live or timeshift)
+ pub broadcast_type: BroadcastType,
+ /// Stream format
+ pub format: StreamFormat,
+ /// Bitrate in kbps (0 for HLS adaptive)
+ pub bitrate: u32,
+}
+
+/// Type of broadcast
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum BroadcastType {
+ /// Live stream
+ Live,
+ /// Timeshift (replay) stream
+ Timeshift,
+}
+
+/// Stream format
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum StreamFormat {
+ /// MP3 format
+ Mp3,
+ /// AAC format
+ Aac,
+ /// HLS adaptive streaming
+ Hls,
+}
+
+impl StreamFormat {
+ /// Get the MIME type for this format
+ pub fn mime_type(&self) -> &'static str {
+ match self {
+ StreamFormat::Mp3 => "audio/mpeg",
+ StreamFormat::Aac => "audio/aac",
+ StreamFormat::Hls => "application/vnd.apple.mpegurl",
+ }
+ }
+}
+
+/// An embedded image
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct EmbedImage {
+ /// Model type (usually "EmbedImage")
+ #[serde(default)]
+ pub model: String,
+ /// Image URL or path
+ pub src: String,
+ /// Image width
+ pub width: Option
,
+ /// Image height
+ pub height: Option,
+ /// Dominant color (hex)
+ pub dominant: Option,
+ /// Copyright notice
+ pub copyright: Option,
+}
+
+impl EmbedImage {
+ /// Extract the UUID from the image URL
+ ///
+ /// Pikapi URLs are in format: https://www.radiofrance.fr/pikapi/images/{uuid}[/size]
+ pub fn extract_uuid(&self) -> Option {
+ let re = regex::Regex::new(r"/pikapi/images/([a-f0-9-]+)").ok()?;
+ re.captures(&self.src)
+ .and_then(|cap| cap.get(1))
+ .map(|m| m.as_str().to_string())
+ }
+}
+
+/// Visual assets for different display contexts
+#[derive(Debug, Clone, Deserialize)]
+pub struct Visuals {
+ /// Card-sized image
+ pub card: Option,
+ /// Player-sized image
+ pub player: Option,
+}
+
+/// A local France Bleu radio station
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct LocalRadio {
+ /// Internal ID
+ pub id: u32,
+ /// Display title (e.g., "ICI Alsace")
+ pub title: String,
+ /// Technical name (e.g., "francebleu_alsace")
+ pub name: String,
+ /// Whether the station is currently on air
+ #[serde(default)]
+ pub is_on_air: bool,
+}
+
+// ============================================================================
+// Image Size Helpers
+// ============================================================================
+
+/// Available image sizes from Pikapi
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ImageSize {
+ /// 88x88 pixels
+ Tiny,
+ /// 200x200 pixels
+ Small,
+ /// 420x720 pixels (portrait)
+ Medium,
+ /// 560x960 pixels (portrait)
+ Large,
+ /// 1200x680 pixels (landscape)
+ XLarge,
+ /// Original size
+ Raw,
+}
+
+impl ImageSize {
+ /// Get the size string for Pikapi URLs
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ ImageSize::Tiny => "88x88",
+ ImageSize::Small => "200x200",
+ ImageSize::Medium => "420x720",
+ ImageSize::Large => "560x960",
+ ImageSize::XLarge => "1200x680",
+ ImageSize::Raw => "raw",
+ }
+ }
+
+ /// Build a Pikapi image URL
+ pub fn build_url(&self, uuid: &str) -> String {
+ format!(
+ "https://www.radiofrance.fr/pikapi/images/{}/{}",
+ uuid,
+ self.as_str()
+ )
+ }
+}
+
+// ============================================================================
+// 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,
+ /// 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) -> 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)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_station_creation() {
+ let main = Station::main("franceculture", "France Culture");
+ assert!(main.is_main());
+ assert_eq!(main.base_station(), "franceculture");
+
+ 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]
+ fn test_image_size() {
+ let uuid = "436430f7-5b2b-43f2-9f3c-28f2ad6cae39";
+ let url = ImageSize::Small.build_url(uuid);
+ assert_eq!(
+ url,
+ "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
+ }
+}