//! 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 /// /// Simplifié: juste slug + name, plus de distinction de type #[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, } impl Station { /// Create a new station pub fn new(slug: impl Into, name: impl Into) -> Self { Self { slug: slug.into(), name: name.into(), } } } // ============================================================================ // New livemeta/pull API Models (api.radiofrance.fr) // ============================================================================ /// 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, /// Ordered levels (depth 1 = current show/song) pub levels: Vec, } impl PullResponse { /// Get the current step at depth 1 (the "now playing" item) pub fn current_step(&self) -> Option<&PullStep> { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); // Le niveau liste tous les steps de la session (passés et futurs) dans l'ordre. // On cherche le step dont start <= now < end. let level = self.levels.first()?; level.items.iter().find_map(|step_id| { let step = self.steps.get(step_id)?; match (step.start, step.end) { (Some(start), Some(end)) if start <= now && now < end => Some(step), _ => None, } }) } } /// 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, /// 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, pub end: Option, pub station_id: u32, pub embed_type: Option, // "song", "expression", "concept" pub depth: u32, pub disc_jockey: Option, /// For songs: authors #[serde(default)] pub authors: serde_json::Value, // can be String or Vec #[serde(default)] pub performers: Option, #[serde(default)] pub highlighted_artists: Vec, pub song_id: Option, pub titre_album: Option, pub label: Option, pub annee_edition_musique: Option, pub cover_uuid: Option, /// Direct UUID for visual (new API uses UUID directly, not URL) pub visual: Option, /// For shows: concept title (e.g. "La Science, CQFD") pub title_concept: Option, /// For shows: producers list #[serde(default)] pub producers: Vec, pub path: Option, pub expression_description: Option, pub description: Option, } 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::>() .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 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, } impl LiveResponse { /// Get local radios (France Bleu only) - convenience accessor pub fn local_radios(&self) -> Option<&Vec> { self.now.local_radios.as_ref() } } /// Metadata for a show or track currently playing #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[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, /// End time (Unix timestamp) pub end_time: Option, /// Producer name pub producer: Option, /// 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, /// Show description/intro pub intro: Option, /// React availability flag #[serde(default)] pub react_available: bool, /// Background visual pub visual_background: Option, /// Song info (for music stations like FIP, France Musique) pub song: Option, /// Available media streams #[serde(default)] pub media: Media, /// Visual assets (card, player) pub visuals: Option, /// Local radios list (France Bleu only) #[serde(default)] pub local_radios: Option>, } /// A line of text with optional link #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct Line { /// Text content pub title: Option, /// UUID of the referenced object pub id: Option, /// URL path to the referenced page pub path: Option, } 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, Serialize)] pub struct Song { /// Song UUID pub id: String, /// Release year pub year: Option, /// Artist names #[serde(default)] pub interpreters: Vec, /// 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, Serialize)] pub struct Release { /// Record label pub label: Option, /// Album title pub title: Option, /// Catalog reference pub reference: Option, } /// Available media streams #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct Media { /// List of available stream sources #[serde(default)] pub sources: Vec, } impl Media { /// Find the best HiFi stream (AAC ou MP3, bitrate maximum, jamais HLS) pub fn best_hifi_stream(&self) -> Option<&StreamSource> { // Priority: AAC 192 kbps > AAC autre bitrate > MP3 bitrate max > autre // JAMAIS HLS (incompatible avec beaucoup de lecteurs) self.sources .iter() .filter(|s| s.broadcast_type == BroadcastType::Live && s.format != StreamFormat::Hls) .max_by_key(|s| { // Priorité: format puis bitrate let format_priority = match s.format { StreamFormat::Aac => 1000, StreamFormat::Mp3 => 500, StreamFormat::Hls => 0, // Filtré de toute façon }; format_priority + s.bitrate }) } /// 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 { self.sources .iter() .filter(|s| s.broadcast_type == BroadcastType::Live) } } /// A stream source with URL and format info #[derive(Debug, Clone, Deserialize, Serialize)] #[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, Serialize)] #[serde(rename_all = "lowercase")] pub enum BroadcastType { /// Live stream Live, /// Timeshift (replay) stream Timeshift, } /// Stream format #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[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, Serialize)] #[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, /// Image height pub height: Option, /// Dominant color (hex) pub dominant: Option, /// Copyright notice pub copyright: Option, } 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 { 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, Serialize)] pub struct Visuals { /// Card-sized image pub card: Option, /// Player-sized image pub player: Option, } /// A local France Bleu radio station #[derive(Debug, Clone, Deserialize, Serialize)] #[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() ) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_station_creation() { let station = Station::new("franceculture", "France Culture"); assert_eq!(station.slug, "franceculture"); assert_eq!(station.name, "France Culture"); } #[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" ); } }