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:
2026-01-22 19:04:44 +01:00
parent b6a10a1250
commit e10f217385
17 changed files with 5482 additions and 8 deletions

80
pmoradiofrance/Cargo.toml Normal file
View 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"

View 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(())
}

View 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

File diff suppressed because it is too large Load Diff

View 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);
}
}

View 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
View 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;

View 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
}
}