Files
pmomusic/pmoradiofrance/src/config_ext.rs
Eric Coissac 0cca9229ad 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
2026-03-24 10:01:47 +01:00

351 lines
12 KiB
Rust

//! 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
//!
//! ```no_run
//! use pmoconfig::get_config;
//! use pmoradiofrance::RadioFranceConfigExt;
//!
//! # fn main() -> anyhow::Result<()> {
//! 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());
//! }
//! # Ok(())
//! # }
//! ```
use crate::models::Station;
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 {
stations: Vec<Station>,
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
/// à 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<bool>;
/// 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(Vec<Station>)` 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)
fn get_radiofrance_stations_cached(&self) -> Result<Option<Vec<Station>>>;
/// 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<u64>;
/// Définit le TTL du cache des stations (en secondes)
fn set_radiofrance_station_cache_ttl(&self, ttl_secs: u64) -> Result<()>;
/// 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 {
fn get_radiofrance_enabled(&self) -> Result<bool> {
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_stations_cached(&self) -> Result<Option<Vec<Station>>> {
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: CachedStations = serde_yaml::from_value(value)?;
// Check validity (TTL)
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
if now - cached.last_updated < ttl {
Ok(Some(cached.stations))
} else {
// Cache expired
Ok(None)
}
}
Err(_) => Ok(None),
}
}
fn set_radiofrance_cached_stations(&self, stations: &[Station]) -> Result<()> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let cached = CachedStations {
stations: stations.to_vec(),
last_updated: now,
};
let value = serde_yaml::to_value(&cached)?;
self.set_value(&["sources", "radiofrance", "station_cache"], value)
}
fn get_radiofrance_station_cache_ttl(&self) -> Result<u64> {
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 clear_radiofrance_station_cache(&self) -> Result<()> {
// 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)]
mod tests {
use super::*;
#[test]
fn test_default_ttl() {
// 7 days in seconds
assert_eq!(DEFAULT_STATION_CACHE_TTL_SECS, 7 * 24 * 3600);
}
}