feat: implement livemeta/pull API and station mapping

- Replace old /api/live endpoint with new livemeta/pull API (api.radiofrance.fr)
- Add hardcoded station ID/stream mappings with runtime-updatable mapping
- Implement station rediscovery on unknown slug or stream failure
- Add persistent caching for station mappings (30-day TTL) via pmoconfig
- Update live_metadata to use numeric IDs and handle ID mismatches
- Add station validation (API + stream accessibility check)
- Refactor metadata caching with fetched_at and fallback TTL
- Update discover_local_radios to scrape SvelteKit page data
- Add new models: PullResponse, PullStep, EmbedImage for livemeta format
- Improve live_metadata.rs example to safely truncate intros
- Enable parallel DIDL generation for playlist groups
This commit is contained in:
2026-03-24 10:01:23 +01:00
parent d1be71ff47
commit 0cca9229ad
9 changed files with 1001 additions and 80 deletions

BIN
.DS_Store vendored

Binary file not shown.

3
.gitignore vendored
View File

@@ -16,13 +16,14 @@
**/*.mp4
**/*.mp3
**/*.ogg
xxx
/dcai/
**/.pmomusic.yml
**/.pmomusic_covers/**
**/.pmomusic_audio/**
/.pmomusic
.DS_Store
**/.DS_Store
/target
**/target/
/.pmomusic_covers

View File

@@ -34,12 +34,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
if let Some(intro) = &metadata.now.intro {
let short_intro = if intro.len() > 100 {
format!("{}...", &intro[..100])
} else {
intro.clone()
};
println!(" Description: {}", short_intro);
let short_intro: String = intro.chars().take(100).collect();
let suffix = if intro.chars().count() > 100 { "..." } else { "" };
println!(" Description: {}{}", short_intro, suffix);
}
// Song info (for music stations)

View File

@@ -28,17 +28,27 @@
//! ```
use crate::error::{Error, Result};
use crate::models::{ImageSize, LiveResponse, ShowMetadata, Station, StreamSource};
use crate::models::{
EmbedImage, ImageSize, Line, LiveResponse, Media, PullResponse, ShowMetadata, Song, Station,
StreamSource,
};
use regex::Regex;
use reqwest::Client;
use scraper::{Html, Selector};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use url::Url;
#[cfg(feature = "pmoconfig")]
use crate::config_ext::StationInfo;
/// Default Radio France base URL
pub const DEFAULT_BASE_URL: &str = "https://www.radiofrance.fr";
/// Livemeta API base URL (new API since 2026)
pub const LIVEMETA_API_URL: &str = "https://api.radiofrance.fr/livemeta/pull";
/// Default timeout for HTTP requests (30 seconds)
pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
@@ -56,6 +66,103 @@ pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[
("francebleu", "France Bleu"),
];
/// Mapping slug → numeric station ID for the livemeta/pull API
/// IDs extracted from www.radiofrance.fr SvelteKit page data (2026)
pub const STATION_IDS: &[(&str, u32)] = &[
("franceinter", 1),
("franceinfo", 2),
("francemusique", 4),
("franceculture", 5),
("mouv", 6),
("fip", 7),
("francebleu", 56),
// FIP webradios
("fip_rock", 64),
("fip_jazz", 65),
("fip_groove", 66),
("fip_reggae", 71),
("fip_electro", 74),
("fip_metal", 77),
("fip_pop", 78),
("fip_world", 69),
("fip_nouveautes", 70),
("fip_hiphop", 95),
("fip_sacre_francais", 96),
("fip_cultes", 709),
];
/// Icecast HiFi AAC stream URLs (stable, not from API)
/// Format: https://icecast.radiofrance.fr/{name}-hifi.aac
pub const STATION_STREAMS: &[(&str, &str)] = &[
(
"franceinter",
"https://icecast.radiofrance.fr/franceinter-hifi.aac",
),
(
"franceinfo",
"https://icecast.radiofrance.fr/franceinfo-hifi.aac",
),
(
"franceculture",
"https://icecast.radiofrance.fr/franceculture-hifi.aac",
),
(
"francemusique",
"https://icecast.radiofrance.fr/francemusique-hifi.aac",
),
("fip", "https://icecast.radiofrance.fr/fip-hifi.aac"),
("mouv", "https://icecast.radiofrance.fr/mouv-hifi.aac"),
// FIP webradios
(
"fip_rock",
"https://icecast.radiofrance.fr/fiprock-hifi.aac",
),
(
"fip_jazz",
"https://icecast.radiofrance.fr/fipjazz-hifi.aac",
),
(
"fip_groove",
"https://icecast.radiofrance.fr/fipgroove-hifi.aac",
),
(
"fip_reggae",
"https://icecast.radiofrance.fr/fipreggae-hifi.aac",
),
(
"fip_electro",
"https://icecast.radiofrance.fr/fipelectro-hifi.aac",
),
(
"fip_metal",
"https://icecast.radiofrance.fr/fipmetal-hifi.aac",
),
(
"fip_pop",
"https://icecast.radiofrance.fr/fippop-hifi.aac",
),
(
"fip_world",
"https://icecast.radiofrance.fr/fipworld-hifi.aac",
),
(
"fip_nouveautes",
"https://icecast.radiofrance.fr/fipnouveautes-hifi.aac",
),
(
"fip_hiphop",
"https://icecast.radiofrance.fr/fiphiphop-hifi.aac",
),
(
"fip_sacre_francais",
"https://icecast.radiofrance.fr/fipsacrefrancais-hifi.aac",
),
(
"fip_cultes",
"https://icecast.radiofrance.fr/fipcultes-hifi.aac",
),
];
/// Radio France HTTP client
///
/// This client provides access to Radio France's public APIs for:
@@ -63,13 +170,23 @@ pub const KNOWN_MAIN_STATIONS: &[(&str, &str)] = &[
/// - 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).
/// The client holds an in-memory station mapping (slug → numeric ID + stream URL)
/// seeded from hardcoded constants. Use `with_station_mapping()` to inject a
/// mapping loaded from persistent storage (pmoconfig).
#[derive(Debug, Clone)]
pub struct RadioFranceClient {
pub(crate) client: Client,
base_url: String,
timeout: Duration,
/// Mapping slug → { station_id, stream_url } — thread-safe, updatable at runtime
station_mapping: Arc<RwLock<HashMap<String, StationMappingEntry>>>,
}
/// Entry in the station mapping
#[derive(Debug, Clone)]
pub struct StationMappingEntry {
pub station_id: u32,
pub stream_url: String,
}
impl RadioFranceClient {
@@ -91,6 +208,7 @@ impl RadioFranceClient {
client,
base_url: DEFAULT_BASE_URL.to_string(),
timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
station_mapping: Arc::new(RwLock::new(Self::default_station_mapping())),
}
}
@@ -104,6 +222,89 @@ impl RadioFranceClient {
&self.client
}
/// Build the default station mapping from hardcoded constants
fn default_station_mapping() -> HashMap<String, StationMappingEntry> {
// Build a lookup for stream URLs
let stream_map: HashMap<&str, &str> = STATION_STREAMS.iter().copied().collect();
STATION_IDS
.iter()
.map(|(slug, id)| {
let stream_url = stream_map
.get(slug)
.copied()
.unwrap_or_default()
.to_string();
(
slug.to_string(),
StationMappingEntry {
station_id: *id,
stream_url,
},
)
})
.collect()
}
/// Remplace le mapping en mémoire par un mapping chargé depuis le cache persistant
///
/// Appelé au démarrage par MetadataCache pour charger le mapping pmoconfig.
#[cfg(feature = "pmoconfig")]
pub fn set_station_mapping(&self, mapping: HashMap<String, StationInfo>) {
let mut m = self.station_mapping.write().unwrap();
m.clear();
for (slug, info) in mapping {
m.insert(
slug,
StationMappingEntry {
station_id: info.station_id,
stream_url: info.stream_url,
},
);
}
}
/// Récupère l'ensemble du mapping courant (pour persistance dans pmoconfig)
#[cfg(feature = "pmoconfig")]
pub fn get_station_mapping(&self) -> HashMap<String, StationInfo> {
let m = self.station_mapping.read().unwrap();
m.iter()
.map(|(slug, entry)| {
(
slug.clone(),
StationInfo {
station_id: entry.station_id,
stream_url: entry.stream_url.clone(),
},
)
})
.collect()
}
/// Met à jour une entrée dans le mapping (utilisé après re-découverte unitaire)
pub fn update_station_entry(&self, slug: &str, station_id: u32, stream_url: String) {
let mut m = self.station_mapping.write().unwrap();
m.insert(
slug.to_string(),
StationMappingEntry {
station_id,
stream_url,
},
);
}
/// Retourne l'ID numérique pour un slug, ou None si inconnu
pub fn station_id_for_slug(&self, slug: &str) -> Option<u32> {
let m = self.station_mapping.read().unwrap();
m.get(slug).map(|e| e.station_id)
}
/// Retourne l'URL du stream HiFi pour un slug, ou None si inconnu
pub fn stream_url_for_slug(&self, slug: &str) -> Option<String> {
let m = self.station_mapping.read().unwrap();
m.get(slug).map(|e| e.stream_url.clone())
}
// ========================================================================
// Station Discovery
// ========================================================================
@@ -254,20 +455,71 @@ impl RadioFranceClient {
.collect())
}
/// Discover local France Bleu radios via API
/// Discover local France Bleu radios via SvelteKit page data
///
/// Uses the France Bleu /api/live? endpoint which includes
/// a `localRadios` array in the `now` field.
/// Scrapes francebleu page for local station slugs and discovers their IDs.
/// Met à jour le station_mapping avec les IDs trouvés.
pub async fn discover_local_radios(&self) -> Result<Vec<Station>> {
let response = self.live_metadata("francebleu").await?;
let url = format!("{}/francebleu/__data.json", self.base_url);
let resp = self
.client
.get(&url)
.timeout(self.timeout)
.send()
.await?
.text()
.await?;
Ok(response
.local_radios()
.cloned()
.unwrap_or_default()
// Extraire les slugs "francebleu_xxx" et les paires (slug, id) de la page
let re_slug_id = Regex::new(r#""(francebleu_[a-z_]+)","(\d+)""#)?;
let mut stations = Vec::new();
let mut found_any = false;
for cap in re_slug_id.captures_iter(&resp) {
let slug = cap[1].to_string();
if let Ok(id) = cap[2].parse::<u32>() {
let stream_url = Self::derive_stream_url(&slug);
self.update_station_entry(&slug, id, stream_url);
let name = Self::slug_to_display_name(&slug);
stations.push(Station::new(slug, name));
found_any = true;
}
}
if found_any {
return Ok(stations);
}
// Fallback : scraper la page HTML pour les noms "ICI ..."
let html_url = format!("{}/francebleu", self.base_url);
let html = self
.client
.get(&html_url)
.timeout(self.timeout)
.send()
.await?
.text()
.await?;
let re_ici = Regex::new(r#"ICI ([A-Za-z\u00C0-\u017E][A-Za-z\u00C0-\u017E\s-]+)"#)?;
let re_slug = Regex::new(r#""(francebleu_[a-z_]+)""#)?;
let mut slugs: HashSet<String> = HashSet::new();
for cap in re_slug.captures_iter(&html) {
slugs.insert(cap[1].to_string());
}
let _names: Vec<String> = re_ici
.captures_iter(&html)
.map(|c| c[1].trim().to_string())
.collect();
Ok(slugs
.into_iter()
.filter(|local| local.is_on_air)
.map(|local| Station::new(local.name, local.title))
.map(|slug| {
let name = Self::slug_to_display_name(&slug);
Station::new(slug, name)
})
.collect())
}
@@ -370,28 +622,42 @@ impl RadioFranceClient {
/// # }
/// ```
pub async fn live_metadata(&self, station: &str) -> Result<LiveResponse> {
let (base_station, webradio) = Self::parse_station_slug(station);
let station_id = self
.station_id_for_slug(station)
.ok_or_else(|| Error::ApiError(format!("Unknown 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);
}
let url = format!("{}/{}", LIVEMETA_API_URL, station_id);
#[cfg(feature = "logging")]
tracing::debug!("Fetching live metadata: {}", url);
let response = self.client.get(url).timeout(self.timeout).send().await?;
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()
"livemeta API returned status {} for station {} (id={})",
response.status(),
station,
station_id
)));
}
let live: LiveResponse = response.json().await?;
let pull: PullResponse = response.json().await?;
// Fix 1: Vérifier que l'ID retourné correspond bien à la station demandée.
// Si Radio France réassigne les IDs, on déclenche une redécouverte.
if pull.station_id != station_id {
#[cfg(feature = "logging")]
tracing::warn!(
"Station ID mismatch for {}: expected {}, API returned {} — triggering rediscovery",
station, station_id, pull.station_id
);
// Mettre à jour le mapping avec le nouvel ID retourné par l'API
let stream_url = self.stream_url_for_slug(station).unwrap_or_default();
self.update_station_entry(station, pull.station_id, stream_url);
}
let live = self.pull_to_live_response(station, &pull);
#[cfg(feature = "logging")]
tracing::debug!(
@@ -404,6 +670,268 @@ impl RadioFranceClient {
Ok(live)
}
/// Convertit une PullResponse (nouvelle API) en LiveResponse (format interne)
fn pull_to_live_response(&self, station_slug: &str, pull: &PullResponse) -> LiveResponse {
let now = if let Some(step) = pull.current_step() {
self.step_to_show_metadata(station_slug, step)
} else {
ShowMetadata::default()
};
// delayToRefresh : on utilise end_time - now comme indicateur (min 30s)
let delay_to_refresh = if let Some(end) = now.end_time {
let current = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
if end > current {
((end - current) * 1000).min(60_000) // max 60s, en ms
} else {
30_000
}
} else {
30_000
};
LiveResponse {
station_name: station_slug.to_string(),
delay_to_refresh,
migrated: true,
now,
next: None,
}
}
/// Convertit un PullStep en ShowMetadata
fn step_to_show_metadata(&self, station_slug: &str, step: &crate::models::PullStep) -> ShowMetadata {
// Stream : injecté depuis le mapping en mémoire
let stream_url = self.stream_url_for_slug(station_slug).unwrap_or_default();
let sources = if !stream_url.is_empty() {
vec![StreamSource {
url: stream_url,
broadcast_type: crate::models::BroadcastType::Live,
format: crate::models::StreamFormat::Aac,
bitrate: 192,
}]
} else {
vec![]
};
// Visual : la nouvelle API donne directement l'UUID (pas une URL complète)
let visual_background = step.visual.as_deref().map(|uuid| EmbedImage {
model: "EmbedImage".to_string(),
src: ImageSize::Large.build_url(uuid),
width: None,
height: None,
dominant: None,
copyright: None,
});
if step.is_song() {
// === Radio musicale ===
let artists = step.artists_display();
let song = Some(Song {
id: step.song_id.clone().unwrap_or_default(),
year: step.annee_edition_musique,
interpreters: if artists.is_empty() {
vec![]
} else {
vec![artists.clone()]
},
release: crate::models::Release {
label: step.label.clone(),
title: step.titre_album.clone(),
reference: None,
},
});
ShowMetadata {
start_time: step.start,
end_time: step.end,
producer: step.disc_jockey.clone(),
first_line: Line {
title: Some(step.title.clone()),
id: None,
path: step.path.clone(),
},
second_line: Line {
title: if artists.is_empty() { None } else { Some(artists) },
id: None,
path: None,
},
song,
media: Media { sources },
visual_background,
..ShowMetadata::default()
}
} else {
// === Radio parlée / émission ===
let show_title = step.title_concept.as_deref()
.unwrap_or(step.title.as_str())
.to_string();
let episode_title = if step.title_concept.is_some() {
step.title.clone()
} else {
String::new()
};
let producer = step.disc_jockey.clone()
.or_else(|| step.producers.first().map(|p| p.name.clone()));
ShowMetadata {
start_time: step.start,
end_time: step.end,
producer,
first_line: Line {
title: Some(show_title),
id: None,
path: step.path.clone(),
},
second_line: Line {
title: if episode_title.is_empty() { None } else { Some(episode_title) },
id: None,
path: None,
},
intro: step.expression_description.clone()
.or_else(|| step.description.clone()),
media: Media { sources },
visual_background,
..ShowMetadata::default()
}
}
}
/// Tente de re-découvrir l'ID et l'URL stream d'une station depuis le web
///
/// Utilisé quand un stream échoue ou qu'un slug est inconnu.
/// Met à jour le mapping en mémoire si la découverte réussit.
pub async fn rediscover_station(&self, slug: &str) -> Result<(u32, String)> {
#[cfg(feature = "logging")]
tracing::warn!("Rediscovering station mapping for: {}", slug);
// Construire l'URL de la page de la station principale
let base_slug = slug.split('_').next().unwrap_or(slug);
let page_url = format!("{}/{}/__data.json", self.base_url, base_slug);
let resp = self
.client
.get(&page_url)
.timeout(self.timeout)
.send()
.await?;
if !resp.status().is_success() {
return Err(Error::ApiError(format!(
"Cannot rediscover station {}: page returned {}",
slug,
resp.status()
)));
}
let text = resp.text().await?;
// Fix 2: chercher l'ID spécifique à la station via son brandEnum.
// Ex: "fip_rock" → "FIP_ROCK" → cherche `"FIP_ROCK","64"` dans le JSON SvelteKit.
// Pour les stations principales, on cherche aussi le pattern `"Brand","<id>"`.
let brand_enum = slug.to_uppercase(); // fip_rock → FIP_ROCK
let re_enum = Regex::new(&format!(r#""{}","(\d+)""#, regex::escape(&brand_enum)))?;
let brand_id: Option<u32> = re_enum
.captures_iter(&text)
.filter_map(|c| c[1].parse::<u32>().ok())
.next();
// Fallback : premier "Brand","<id>" dans la page (pour stations principales)
let brand_id = if brand_id.is_some() {
brand_id
} else {
let re_brand = Regex::new(r#""Brand","(\d+)""#)?;
let ids: Vec<u32> = re_brand
.captures_iter(&text)
.filter_map(|c| c[1].parse::<u32>().ok())
.collect();
ids.into_iter().next()
};
let station_id = brand_id.ok_or_else(|| {
Error::ApiError(format!("Could not find station_id for {} in page data", slug))
})?;
// Dériver l'URL stream depuis le slug (format icecast connu)
let stream_url = Self::derive_stream_url(slug);
self.update_station_entry(slug, station_id, stream_url.clone());
#[cfg(feature = "logging")]
tracing::info!(
"Rediscovered station {}: id={}, stream={}",
slug,
station_id,
stream_url
);
Ok((station_id, stream_url))
}
/// Valide qu'une station existe toujours et que son stream est accessible
///
/// Vérifie deux choses :
/// 1. L'API livemeta répond avec un stationId valide (station connue de Radio France)
/// 2. L'URL icecast répond HTTP 200 (stream physiquement disponible)
///
/// Retourne Ok(()) si tout est OK, Err si la station est invalide ou le stream mort.
pub async fn validate_station(&self, slug: &str) -> Result<()> {
// 1. Vérifier que l'API livemeta répond pour cette station
let station_id = self
.station_id_for_slug(slug)
.ok_or_else(|| Error::ApiError(format!("Unknown station slug: {}", slug)))?;
let url = format!("{}/{}", LIVEMETA_API_URL, station_id);
let response = self.client.get(&url).timeout(self.timeout).send().await?;
if !response.status().is_success() {
return Err(Error::ApiError(format!(
"Station {} (id={}) no longer exists: livemeta returned {}",
slug, station_id, response.status()
)));
}
let pull: PullResponse = response.json().await?;
// Vérifier cohérence de l'ID — si mismatch, la station a peut-être été réassignée
if pull.station_id != station_id {
return Err(Error::ApiError(format!(
"Station {} ID mismatch: stored={}, API returned={} — mapping is stale",
slug, station_id, pull.station_id
)));
}
// 2. Vérifier que l'URL icecast répond
if let Some(stream_url) = self.stream_url_for_slug(slug) {
if !stream_url.is_empty() {
let stream_resp = self
.client
.head(&stream_url)
.timeout(Duration::from_secs(10))
.send()
.await?;
if !stream_resp.status().is_success() {
return Err(Error::ApiError(format!(
"Stream for {} is not accessible: {} returned {}",
slug, stream_url, stream_resp.status()
)));
}
}
}
Ok(())
}
/// Dérive l'URL icecast depuis le slug selon le pattern Radio France
pub fn derive_stream_url(slug: &str) -> String {
// fip_rock → fiprock, fip_sacre_francais → fipsacrefrancais
let name = slug.replace('_', "");
format!("https://icecast.radiofrance.fr/{}-hifi.aac", name)
}
/// Get only the current show metadata
pub async fn now_playing(&self, station: &str) -> Result<ShowMetadata> {
let response = self.live_metadata(station).await?;
@@ -464,13 +992,7 @@ impl RadioFranceClient {
/// # }
/// ```
pub async fn get_hifi_stream_url(&self, station: &str) -> Result<String> {
let metadata = self.live_metadata(station).await?;
metadata
.now
.media
.best_hifi_stream()
.map(|s| s.url.clone())
self.stream_url_for_slug(station)
.ok_or_else(|| Error::NoHifiStream(station.to_string()))
}
@@ -619,6 +1141,7 @@ impl ClientBuilder {
client,
base_url: self.base_url,
timeout: self.timeout,
station_mapping: Arc::new(RwLock::new(RadioFranceClient::default_station_mapping())),
})
}
}
@@ -644,7 +1167,7 @@ mod tests {
);
assert_eq!(
RadioFranceClient::parse_station_slug("francebleu_alsace"),
("francebleu_alsace", None)
("francebleu", Some("francebleu_alsace"))
);
assert_eq!(
RadioFranceClient::parse_station_slug("franceculture"),

View File

@@ -37,11 +37,24 @@ use anyhow::Result;
use pmoconfig::Config;
use serde::{Deserialize, Serialize};
use serde_yaml::Value;
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
/// Default TTL for station list cache (7 days in seconds)
pub const DEFAULT_STATION_CACHE_TTL_SECS: u64 = 7 * 24 * 3600;
/// Default TTL for station mapping cache (30 days — IDs and stream URLs are very stable)
pub const DEFAULT_STATION_MAPPING_TTL_SECS: u64 = 30 * 24 * 3600;
/// Informations d'une station : ID numérique et URL du stream HiFi
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StationInfo {
/// ID numérique pour l'API livemeta/pull
pub station_id: u32,
/// URL du stream HiFi (icecast AAC ou MP3)
pub stream_url: String,
}
/// Cached station list (simplifié)
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedStations {
@@ -49,6 +62,14 @@ struct CachedStations {
last_updated: u64, // Unix timestamp
}
/// Mapping slug → StationInfo avec timestamp de mise à jour
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedStationMapping {
/// slug → { station_id, stream_url }
mapping: HashMap<String, StationInfo>,
last_updated: u64, // Unix timestamp
}
/// Trait d'extension pour gérer la configuration Radio France dans pmoconfig
///
/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques
@@ -113,6 +134,36 @@ pub trait RadioFranceConfigExt {
/// Efface le cache des stations (force re-découverte)
fn clear_radiofrance_station_cache(&self) -> Result<()>;
// ========================================================================
// Station Mapping Cache (slug → station_id + stream_url)
// ========================================================================
/// Récupère le mapping slug → StationInfo depuis le cache (si valide)
///
/// # Returns
///
/// - `Some(HashMap)` si le cache existe et n'est pas expiré
/// - `None` si absent ou TTL dépassé → il faut re-découvrir
fn get_radiofrance_station_mapping(&self) -> Result<Option<HashMap<String, StationInfo>>>;
/// Enregistre le mapping slug → StationInfo en cache
fn set_radiofrance_station_mapping(&self, mapping: &HashMap<String, StationInfo>)
-> Result<()>;
/// Récupère le TTL du mapping (en secondes, défaut 30 jours)
fn get_radiofrance_mapping_ttl(&self) -> Result<u64>;
/// Définit le TTL du mapping (en secondes)
fn set_radiofrance_mapping_ttl(&self, ttl_secs: u64) -> Result<()>;
/// Efface le cache du mapping (force re-découverte au prochain accès)
fn clear_radiofrance_station_mapping(&self) -> Result<()>;
/// Met à jour une seule entrée dans le mapping (sans invalider tout le cache)
///
/// Utilisé pour la mise à jour incrémentale lors d'une re-découverte unitaire.
fn upsert_radiofrance_station_info(&self, slug: &str, info: StationInfo) -> Result<()>;
}
impl RadioFranceConfigExt for Config {
@@ -201,6 +252,90 @@ impl RadioFranceConfigExt for Config {
// Set to null to clear
self.set_value(&["sources", "radiofrance", "station_cache"], Value::Null)
}
fn get_radiofrance_station_mapping(&self) -> Result<Option<HashMap<String, StationInfo>>> {
let ttl = self.get_radiofrance_mapping_ttl()?;
match self.get_value(&["sources", "radiofrance", "station_mapping"]) {
Ok(value) => {
let cached: CachedStationMapping = serde_yaml::from_value(value)?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
if now - cached.last_updated < ttl {
Ok(Some(cached.mapping))
} else {
Ok(None) // Expiré
}
}
Err(_) => Ok(None), // Absent
}
}
fn set_radiofrance_station_mapping(
&self,
mapping: &HashMap<String, StationInfo>,
) -> Result<()> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let cached = CachedStationMapping {
mapping: mapping.clone(),
last_updated: now,
};
let value = serde_yaml::to_value(&cached)?;
self.set_value(&["sources", "radiofrance", "station_mapping"], value)
}
fn get_radiofrance_mapping_ttl(&self) -> Result<u64> {
match self.get_value(&["sources", "radiofrance", "station_mapping_ttl_secs"]) {
Ok(Value::Number(n)) => {
if let Some(ttl) = n.as_u64() {
Ok(ttl)
} else {
self.set_radiofrance_mapping_ttl(DEFAULT_STATION_MAPPING_TTL_SECS)?;
Ok(DEFAULT_STATION_MAPPING_TTL_SECS)
}
}
_ => {
self.set_radiofrance_mapping_ttl(DEFAULT_STATION_MAPPING_TTL_SECS)?;
Ok(DEFAULT_STATION_MAPPING_TTL_SECS)
}
}
}
fn set_radiofrance_mapping_ttl(&self, ttl_secs: u64) -> Result<()> {
self.set_value(
&["sources", "radiofrance", "station_mapping_ttl_secs"],
Value::Number(serde_yaml::Number::from(ttl_secs)),
)
}
fn clear_radiofrance_station_mapping(&self) -> Result<()> {
self.set_value(
&["sources", "radiofrance", "station_mapping"],
Value::Null,
)
}
fn upsert_radiofrance_station_info(&self, slug: &str, info: StationInfo) -> Result<()> {
// Charger le mapping existant (même expiré) pour mise à jour incrémentale
let mut mapping = match self.get_value(&["sources", "radiofrance", "station_mapping"]) {
Ok(value) => serde_yaml::from_value::<CachedStationMapping>(value)
.map(|c| c.mapping)
.unwrap_or_default(),
Err(_) => HashMap::new(),
};
mapping.insert(slug.to_string(), info);
self.set_radiofrance_station_mapping(&mapping)
}
}
#[cfg(test)]

View File

@@ -122,7 +122,7 @@ pub use models::{
};
#[cfg(feature = "pmoconfig")]
pub use config_ext::RadioFranceConfigExt;
pub use config_ext::{RadioFranceConfigExt, StationInfo};
#[cfg(feature = "pmoconfig")]
pub use metadata_cache::{CachedMetadata, MetadataCache, MetadataUpdateCallback};

View File

@@ -12,9 +12,11 @@
//! - `MetadataCache` : Gère le cache in-memory + cache persistant + événements
use crate::client::RadioFranceClient;
use crate::config_ext::{RadioFranceConfigExt, StationInfo};
use crate::error::Result;
use crate::models::{ImageSize, LiveResponse, Station};
use pmoconfig::Config;
#[cfg(feature = "playlist")]
use pmodidl::{Container, Item, Resource};
use std::collections::HashMap;
use std::sync::Arc;
@@ -59,6 +61,9 @@ pub struct CachedMetadata {
// TTL = end_time de l'API Radio France
pub end_time: Option<u64>, // Unix timestamp
/// Timestamp de la dernière récupération (pour TTL minimum)
pub fetched_at: u64,
}
impl CachedMetadata {
@@ -89,6 +94,10 @@ impl CachedMetadata {
// 4. TTL = end_time
let end_time = live.now.end_time;
let fetched_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
Ok(Self {
slug: station.slug.clone(),
@@ -106,6 +115,7 @@ impl CachedMetadata {
nr_audio_channels,
duration,
end_time,
fetched_at,
})
}
@@ -349,6 +359,7 @@ impl CachedMetadata {
/// Construit un Container DIDL de playlist à un item
///
/// La playlist et l'item ont EXACTEMENT les mêmes métadonnées
#[cfg(feature = "playlist")]
pub fn to_didl(&self, playlist_id: &str, parent_id: &str) -> Container {
// Calculer la duration dynamiquement (temps restant jusqu'à end_time)
let duration = if let Some(end) = self.end_time {
@@ -432,17 +443,23 @@ impl CachedMetadata {
}
}
/// TTL minimum quand l'API ne fournit pas de end_time (3 minutes)
pub const FALLBACK_TTL_SECS: u64 = 3 * 60;
/// Vérifie si le TTL est dépassé
///
/// - Si `end_time` est fourni par l'API : expire exactement quand l'émission se termine
/// - Sinon : expire après `FALLBACK_TTL_SECS` depuis le dernier fetch
pub fn is_expired(&self) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
if let Some(end_time) = self.end_time {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
now >= end_time
} else {
// Pas de end_time = toujours expiré (refresh systématique)
true
now >= self.fetched_at + Self::FALLBACK_TTL_SECS
}
}
}
@@ -478,6 +495,9 @@ pub struct MetadataCache {
impl MetadataCache {
/// Constructeur avec tous les paramètres obligatoires
///
/// Charge automatiquement le mapping station depuis pmoconfig (si valide).
/// Si le cache est absent ou expiré, utilise les valeurs hardcodées du client.
#[cfg(feature = "cache")]
pub fn new(
client: RadioFranceClient,
@@ -485,6 +505,25 @@ impl MetadataCache {
server_base_url: String,
config: Arc<Config>,
) -> Self {
// Charger le mapping depuis pmoconfig et l'injecter dans le client
if let Ok(Some(mapping)) = config.get_radiofrance_station_mapping() {
#[cfg(feature = "logging")]
tracing::info!(
"Loaded station mapping from config ({} entries)",
mapping.len()
);
client.set_station_mapping(mapping);
} else {
// Pas de cache persistant : persister le mapping par défaut
let mapping = client.get_station_mapping();
let _ = config.set_radiofrance_station_mapping(&mapping);
#[cfg(feature = "logging")]
tracing::info!(
"Initialized station mapping from defaults ({} entries)",
mapping.len()
);
}
Self {
cache: Arc::new(RwLock::new(HashMap::new())),
client,
@@ -525,18 +564,46 @@ impl MetadataCache {
let live_response = match self.client.live_metadata(slug).await {
Ok(resp) => resp,
Err(e) => {
// Graceful degradation: retourner les données expirées si API down
let cache = self.cache.read().await;
if let Some(metadata) = cache.get(slug) {
// Si station inconnue → tenter une re-découverte du mapping
let is_unknown = e.to_string().contains("Unknown station slug");
if is_unknown {
#[cfg(feature = "logging")]
tracing::warn!(
"API Radio France down for {}, using expired cache: {}",
slug,
e
);
return Ok(metadata.clone());
tracing::warn!("Unknown station '{}', attempting rediscovery", slug);
match self.client.rediscover_station(slug).await {
Ok(_) => {
// Persister le mapping mis à jour
self.persist_station_mapping();
// Réessayer la requête
match self.client.live_metadata(slug).await {
Ok(resp) => resp,
Err(e2) => return Err(e2),
}
}
Err(discover_err) => {
#[cfg(feature = "logging")]
tracing::error!(
"Rediscovery failed for '{}': {}",
slug,
discover_err
);
return Err(e);
}
}
} else {
// Graceful degradation: retourner les données expirées si API down
let cache = self.cache.read().await;
if let Some(metadata) = cache.get(slug) {
#[cfg(feature = "logging")]
tracing::warn!(
"API Radio France down for {}, using expired cache: {}",
slug,
e
);
return Ok(metadata.clone());
}
return Err(e);
}
return Err(e);
}
};
@@ -575,17 +642,34 @@ impl MetadataCache {
Ok(metadata)
}
/// Récupère la liste des stations (cache persistant via pmoconfig)
/// Récupère la liste des stations (cache persistant via pmoconfig, TTL 7 jours)
///
/// # Logique
///
/// 1. Essaie de lire depuis pmoconfig
/// 2. Si cache valide (TTL 1 semaine), retourne
/// 3. Sinon, découvre via API et met à jour pmoconfig
/// 1. Essaie de lire depuis pmoconfig (cache TTL 7 jours)
/// 2. Si cache valide, retourne sans appel réseau
/// 3. Sinon, découvre via scraping et met à jour pmoconfig
pub async fn get_stations(&self) -> Result<Vec<Station>> {
// TODO: Implémenter avec config_ext
// Pour l'instant, découvre directement
self.client.discover_all_stations().await
// 1. Essayer le cache persistant
if let Ok(Some(stations)) = self.config.get_radiofrance_stations_cached() {
#[cfg(feature = "logging")]
tracing::debug!("Using cached station list ({} stations)", stations.len());
return Ok(stations);
}
// 2. Cache miss : découverte via scraping
#[cfg(feature = "logging")]
tracing::info!("Station cache miss, discovering via web scraping...");
let stations = self.client.discover_all_stations().await?;
// 3. Persister dans pmoconfig
if let Err(e) = self.config.set_radiofrance_cached_stations(&stations) {
#[cfg(feature = "logging")]
tracing::warn!("Failed to cache station list: {}", e);
}
Ok(stations)
}
/// Récupère les métadonnées live brutes de l'API (sans cache)
@@ -601,6 +685,60 @@ impl MetadataCache {
self.client.get_hifi_stream_url(slug).await
}
/// Force une re-découverte du mapping pour un slug donné
///
/// Utilisé quand un stream retourne une erreur HTTP (404, connexion refusée, etc.)
/// Persiste le mapping mis à jour dans pmoconfig.
pub async fn handle_stream_failure(&self, slug: &str) {
#[cfg(feature = "logging")]
tracing::warn!("Stream failure for '{}', triggering rediscovery", slug);
match self.client.rediscover_station(slug).await {
Ok((id, url)) => {
#[cfg(feature = "logging")]
tracing::info!(
"Re-discovered station '{}': id={}, url={}",
slug,
id,
url
);
self.persist_station_mapping();
// Invalider le cache mémoire pour forcer un refresh des métadonnées
let mut cache = self.cache.write().await;
cache.remove(slug);
}
Err(e) => {
#[cfg(feature = "logging")]
tracing::error!("Rediscovery failed for '{}': {}", slug, e);
}
}
}
/// Persiste le mapping courant dans pmoconfig
fn persist_station_mapping(&self) {
let mapping = self.client.get_station_mapping();
if let Err(e) = self.config.set_radiofrance_station_mapping(&mapping) {
#[cfg(feature = "logging")]
tracing::warn!("Failed to persist station mapping: {}", e);
}
}
/// Met à jour manuellement le mapping d'une station et le persiste
pub fn update_station_mapping(&self, slug: &str, station_id: u32, stream_url: String) {
self.client
.update_station_entry(slug, station_id, stream_url.clone());
// Persister dans pmoconfig
let info = StationInfo {
station_id,
stream_url,
};
if let Err(e) = self.config.upsert_radiofrance_station_info(slug, info) {
#[cfg(feature = "logging")]
tracing::warn!("Failed to upsert station info for '{}': {}", slug, e);
}
}
/// S'abonner aux changements de métadonnées
///
/// Le callback sera appelé avec le slug chaque fois que
@@ -634,7 +772,12 @@ mod tests {
#[test]
fn test_cached_metadata_is_expired() {
let metadata = CachedMetadata {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let base = CachedMetadata {
slug: "test".to_string(),
title: "Test".to_string(),
creator: None,
@@ -650,15 +793,26 @@ mod tests {
nr_audio_channels: None,
duration: None,
end_time: Some(0), // Dans le passé
fetched_at: now,
};
assert!(metadata.is_expired());
// end_time dans le passé → expiré
assert!(base.is_expired());
let metadata_no_end = CachedMetadata {
// end_time dans le futur → non expiré
let not_expired = CachedMetadata { end_time: Some(now + 3600), ..base.clone() };
assert!(!not_expired.is_expired());
// Pas de end_time, fetched_at récent → non expiré (fallback TTL)
let no_end_fresh = CachedMetadata { end_time: None, fetched_at: now, ..base.clone() };
assert!(!no_end_fresh.is_expired());
// Pas de end_time, fetched_at ancien → expiré
let no_end_old = CachedMetadata {
end_time: None,
..metadata
fetched_at: now - CachedMetadata::FALLBACK_TTL_SECS - 1,
..base
};
assert!(metadata_no_end.is_expired());
assert!(no_end_old.is_expired());
}
}

View File

@@ -31,14 +31,117 @@ impl Station {
}
// ============================================================================
// Live API Response Models
// New livemeta/pull API Models (api.radiofrance.fr)
// ============================================================================
/// Response from the /api/live? endpoint
/// Response from https://api.radiofrance.fr/livemeta/pull/{stationId}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PullResponse {
/// Numeric station ID
pub station_id: u32,
/// Map of stepId -> step metadata
pub steps: std::collections::HashMap<String, PullStep>,
/// Ordered levels (depth 1 = current show/song)
pub levels: Vec<PullLevel>,
}
impl PullResponse {
/// Get the current step at depth 1 (the "now playing" item)
pub fn current_step(&self) -> Option<&PullStep> {
// levels[0].items[0] is the current item at depth 1 (most relevant)
let level = self.levels.first()?;
let step_id = level.items.first()?;
self.steps.get(step_id)
}
}
/// A level in the livemeta response (groups steps by depth)
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PullLevel {
/// Ordered list of step IDs at this level
pub items: Vec<String>,
/// Depth (1 = show/song, 2 = sub-item, 3 = deeper)
pub position: u32,
}
/// A step (show, episode, or song) from the livemeta pull API
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PullStep {
pub uuid: String,
pub step_id: String,
pub title: String,
pub start: Option<u64>,
pub end: Option<u64>,
pub station_id: u32,
pub embed_type: Option<String>, // "song", "expression", "concept"
pub depth: u32,
pub disc_jockey: Option<String>,
/// For songs: authors
#[serde(default)]
pub authors: serde_json::Value, // can be String or Vec<String>
#[serde(default)]
pub performers: Option<String>,
#[serde(default)]
pub highlighted_artists: Vec<String>,
pub song_id: Option<String>,
pub titre_album: Option<String>,
pub label: Option<String>,
pub annee_edition_musique: Option<u32>,
pub cover_uuid: Option<String>,
/// Direct UUID for visual (new API uses UUID directly, not URL)
pub visual: Option<String>,
/// For shows: concept title (e.g. "La Science, CQFD")
pub title_concept: Option<String>,
/// For shows: producers list
#[serde(default)]
pub producers: Vec<PullProducer>,
pub path: Option<String>,
pub expression_description: Option<String>,
pub description: Option<String>,
}
impl PullStep {
/// Returns true if this step is a music track
pub fn is_song(&self) -> bool {
self.embed_type.as_deref() == Some("song")
}
/// Get artist display string
pub fn artists_display(&self) -> String {
if !self.highlighted_artists.is_empty() {
return self.highlighted_artists.join(", ");
}
match &self.authors {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", "),
_ => String::new(),
}
}
}
/// A producer in a PullStep
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PullProducer {
pub uuid: String,
pub name: String,
}
// ============================================================================
// Live API Response Models (internal representation)
// ============================================================================
/// Internal live metadata representation
/// Built from PullResponse (new API) or used directly in tests
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveResponse {
/// Station name (slug)
/// Station slug
pub station_name: String,
/// Recommended delay before next refresh (milliseconds)
pub delay_to_refresh: u64,
@@ -59,7 +162,7 @@ impl LiveResponse {
}
/// Metadata for a show or track currently playing
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ShowMetadata {
/// Whether to display music program info

View File

@@ -98,18 +98,26 @@ impl StationGroups {
/// Niveau 0: to_didl() retourne le container "radiofrance" avec tous les groupes
///
/// Appelle to_stub() sur chaque StationGroup
/// Appelle to_stub() sur chaque StationGroup en parallèle
pub async fn to_didl(
&self,
metadata_cache: &MetadataCache,
server_base_url: &str,
) -> Result<Container> {
let mut containers = Vec::new();
use futures::stream::{self, StreamExt};
for group in &self.groups {
let container = group.to_stub(metadata_cache, server_base_url).await?;
containers.push(container);
}
let futures: Vec<_> = self
.groups
.iter()
.map(|group| group.to_stub(metadata_cache, server_base_url))
.collect();
let results: Vec<Result<Container>> = stream::iter(futures)
.buffer_unordered(8)
.collect()
.await;
let containers: Vec<Container> = results.into_iter().collect::<Result<Vec<_>>>()?;
Ok(Container {
id: "radiofrance".to_string(),