Files
pmomusic/Blackboard/ToThinkAbout/client_radiofrance_architecture.md
Eric Coissac e10f217385 feat(pmoradiofrance): implémentation complète du client Radio France
Cette mise à jour implémente complètement le client Radio France avec :

- Découverte dynamique des stations (principales, webradios et locales)
- Accès aux métadonnées live via l'API publique
- Gestion des flux audio HiFi (AAC 192 kbps, HLS)
- Support du cache des stations avec TTL configurable
- Extension de configuration pour pmoconfig
- Exemples d'utilisation et tests d'intégration

Les stations découvertes incluent : France Inter, France Info, France Culture, France Musique, FIP, Mouv', France Bleu (locales), et leurs variantes webradios respectives.

Les fonctionnalités incluent :
- Récupération des métadonnées live (émission en cours, producteur, visuels)
- Accès aux flux audio HiFi
- Gestion intelligente des rafraîchissements via delayToRefresh
- Cache des listes de stations avec TTL configurable (7 jours par défaut)

Les tests d'intégration couvrent : découverte des stations, métadonnées live, flux audio, cache, et gestion des erreurs.
2026-01-22 22:00:40 +01:00

832 lines
23 KiB
Markdown

# 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<reqwest::Client>,
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<ShowMetadata>,
pub local_radios: Option<Vec<LocalRadio>>, // 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<u64>,
pub end_time: Option<u64>,
pub producer: Option<String>,
pub first_line: Line, // Titre émission
pub second_line: Line, // Titre épisode/chronique
pub third_line: Option<Line>, // Sous-titre
pub intro: Option<String>, // Description
pub song: Option<Song>, // Pour radios musicales (FIP, France Musique)
pub media: Media, // Flux audio disponibles
pub visual_background: Option<EmbedImage>,
pub visuals: Option<Visuals>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Line {
pub title: Option<String>,
pub id: Option<String>,
pub path: Option<String>,
}
```
### 4. Morceau musical (FIP, France Musique)
```rust
#[derive(Debug, Clone, Deserialize)]
pub struct Song {
pub id: String,
pub year: Option<u32>,
pub interpreters: Vec<String>,
pub release: Release,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Release {
pub label: Option<String>,
pub title: Option<String>,
pub reference: Option<String>,
}
```
### 5. Flux audio
```rust
#[derive(Debug, Clone, Deserialize)]
pub struct Media {
pub sources: Vec<StreamSource>,
}
#[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<u32>,
pub height: Option<u32>,
pub dominant: Option<String>,
pub copyright: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Visuals {
pub card: Option<EmbedImage>,
pub player: Option<EmbedImage>,
}
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> {
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<Vec<Station>> {
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<Vec<Station>> {
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<Vec<Station>> {
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<Vec<Station>> {
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::<String>() + c.as_str(),
}
})
.collect::<Vec<_>>()
.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<LiveResponse> {
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<ShowMetadata> {
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<String> {
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<Vec<StreamSource>> {
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<String> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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**