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.
This commit is contained in:
80
pmoradiofrance/Cargo.toml
Normal file
80
pmoradiofrance/Cargo.toml
Normal file
@@ -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"
|
||||
40
pmoradiofrance/examples/discover_stations.rs
Normal file
40
pmoradiofrance/examples/discover_stations.rs
Normal file
@@ -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<dyn std::error::Error>> {
|
||||
// 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(())
|
||||
}
|
||||
107
pmoradiofrance/examples/live_metadata.rs
Normal file
107
pmoradiofrance/examples/live_metadata.rs
Normal file
@@ -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<dyn std::error::Error>> {
|
||||
// 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(())
|
||||
}
|
||||
1212
pmoradiofrance/src/client.rs
Normal file
1212
pmoradiofrance/src/client.rs
Normal file
File diff suppressed because it is too large
Load Diff
234
pmoradiofrance/src/config_ext.rs
Normal file
234
pmoradiofrance/src/config_ext.rs
Normal file
@@ -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<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(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<Option<CachedStationList>>;
|
||||
|
||||
/// 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<()>;
|
||||
|
||||
/// 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<Option<Vec<Station>>>;
|
||||
}
|
||||
|
||||
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_cached_stations(&self) -> Result<Option<CachedStationList>> {
|
||||
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<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 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<Option<Vec<Station>>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
73
pmoradiofrance/src/error.rs
Normal file
73
pmoradiofrance/src/error.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
//! Error types for the Radio France client
|
||||
|
||||
/// Result type alias for Radio France operations
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// 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<String>) -> Self {
|
||||
Self::Other(msg.into())
|
||||
}
|
||||
|
||||
/// Create an API error
|
||||
pub fn api_error(msg: impl Into<String>) -> Self {
|
||||
Self::ApiError(msg.into())
|
||||
}
|
||||
|
||||
/// Create a scraping error
|
||||
pub fn scraping_error(msg: impl Into<String>) -> Self {
|
||||
Self::ScrapingError(msg.into())
|
||||
}
|
||||
}
|
||||
103
pmoradiofrance/src/lib.rs
Normal file
103
pmoradiofrance/src/lib.rs
Normal file
@@ -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<dyn std::error::Error>> {
|
||||
//! 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;
|
||||
518
pmoradiofrance/src/models.rs
Normal file
518
pmoradiofrance/src/models.rs
Normal file
@@ -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<String>, name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
slug: slug.into(),
|
||||
name: name.into(),
|
||||
station_type: StationType::Main,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new webradio station
|
||||
pub fn webradio(
|
||||
slug: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
parent: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
slug: slug.into(),
|
||||
name: name.into(),
|
||||
station_type: StationType::Webradio {
|
||||
parent_station: parent.into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new local radio station
|
||||
pub fn local_radio(
|
||||
slug: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
region: impl Into<String>,
|
||||
id: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
slug: slug.into(),
|
||||
name: name.into(),
|
||||
station_type: StationType::LocalRadio {
|
||||
region: region.into(),
|
||||
id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a main station
|
||||
pub fn is_main(&self) -> bool {
|
||||
matches!(self.station_type, StationType::Main)
|
||||
}
|
||||
|
||||
/// Check if this is a webradio
|
||||
pub fn is_webradio(&self) -> bool {
|
||||
matches!(self.station_type, StationType::Webradio { .. })
|
||||
}
|
||||
|
||||
/// Check if this is a local radio
|
||||
pub fn is_local_radio(&self) -> bool {
|
||||
matches!(self.station_type, StationType::LocalRadio { .. })
|
||||
}
|
||||
|
||||
/// Get the parent station for webradios, or the station itself for main stations
|
||||
pub fn base_station(&self) -> &str {
|
||||
match &self.station_type {
|
||||
StationType::Webradio { parent_station } => parent_station,
|
||||
_ => &self.slug,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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<ShowMetadata>,
|
||||
}
|
||||
|
||||
impl LiveResponse {
|
||||
/// Get local radios (France Bleu only) - convenience accessor
|
||||
pub fn local_radios(&self) -> Option<&Vec<LocalRadio>> {
|
||||
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<u64>,
|
||||
/// End time (Unix timestamp)
|
||||
pub end_time: Option<u64>,
|
||||
/// Producer name
|
||||
pub producer: Option<String>,
|
||||
/// 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<Line>,
|
||||
/// Show description/intro
|
||||
pub intro: Option<String>,
|
||||
/// React availability flag
|
||||
#[serde(default)]
|
||||
pub react_available: bool,
|
||||
/// Background visual
|
||||
pub visual_background: Option<EmbedImage>,
|
||||
/// Song info (for music stations like FIP, France Musique)
|
||||
pub song: Option<Song>,
|
||||
/// Available media streams
|
||||
#[serde(default)]
|
||||
pub media: Media,
|
||||
/// Visual assets (card, player)
|
||||
pub visuals: Option<Visuals>,
|
||||
/// Local radios list (France Bleu only)
|
||||
#[serde(default)]
|
||||
pub local_radios: Option<Vec<LocalRadio>>,
|
||||
}
|
||||
|
||||
/// A line of text with optional link
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Line {
|
||||
/// Text content
|
||||
pub title: Option<String>,
|
||||
/// UUID of the referenced object
|
||||
pub id: Option<String>,
|
||||
/// URL path to the referenced page
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
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<u32>,
|
||||
/// Artist names
|
||||
#[serde(default)]
|
||||
pub interpreters: Vec<String>,
|
||||
/// 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<String>,
|
||||
/// Album title
|
||||
pub title: Option<String>,
|
||||
/// Catalog reference
|
||||
pub reference: Option<String>,
|
||||
}
|
||||
|
||||
/// Available media streams
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Media {
|
||||
/// List of available stream sources
|
||||
#[serde(default)]
|
||||
pub sources: Vec<StreamSource>,
|
||||
}
|
||||
|
||||
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<Item = &StreamSource> {
|
||||
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<u32>,
|
||||
/// Image height
|
||||
pub height: Option<u32>,
|
||||
/// Dominant color (hex)
|
||||
pub dominant: Option<String>,
|
||||
/// Copyright notice
|
||||
pub copyright: Option<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<EmbedImage>,
|
||||
/// Player-sized image
|
||||
pub player: Option<EmbedImage>,
|
||||
}
|
||||
|
||||
/// 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<Station>,
|
||||
/// Unix timestamp when the list was last updated
|
||||
pub last_updated: u64,
|
||||
/// Version of the discovery algorithm (for invalidation)
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
impl CachedStationList {
|
||||
/// Current version of the discovery algorithm
|
||||
pub const CURRENT_VERSION: u32 = 1;
|
||||
|
||||
/// Default TTL for station list cache (7 days in seconds)
|
||||
pub const DEFAULT_TTL_SECS: u64 = 7 * 24 * 3600;
|
||||
|
||||
/// Create a new cached station list
|
||||
pub fn new(stations: Vec<Station>) -> Self {
|
||||
Self {
|
||||
stations,
|
||||
last_updated: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
version: Self::CURRENT_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the cache is still valid
|
||||
pub fn is_valid(&self, ttl_secs: u64) -> bool {
|
||||
if self.version != Self::CURRENT_VERSION {
|
||||
return false;
|
||||
}
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
now.saturating_sub(self.last_updated) < ttl_secs
|
||||
}
|
||||
|
||||
/// Check if cache is valid with default TTL
|
||||
pub fn is_valid_default(&self) -> bool {
|
||||
self.is_valid(Self::DEFAULT_TTL_SECS)
|
||||
}
|
||||
|
||||
/// Get the age of the cache in seconds
|
||||
pub fn age_secs(&self) -> u64 {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
now.saturating_sub(self.last_updated)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user