------------ pmoparadise/src/channels.rs ---------- //! Radio Paradise channel definitions //! //! This module defines the available Radio Paradise channels and their metadata. use std::str::FromStr; /// Logical identifier for a Radio Paradise channel. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ParadiseChannelKind { Main, Mellow, Rock, Eclectic, } impl ParadiseChannelKind { pub const fn id(self) -> u8 { match self { Self::Main => 0, Self::Mellow => 1, Self::Rock => 2, Self::Eclectic => 3, } } pub const fn slug(self) -> &'static str { match self { Self::Main => "main", Self::Mellow => "mellow", Self::Rock => "rock", Self::Eclectic => "eclectic", } } pub const fn display_name(self) -> &'static str { match self { Self::Main => "Main Mix", Self::Mellow => "Mellow Mix", Self::Rock => "Rock Mix", Self::Eclectic => "Eclectic Mix", } } pub const fn description(self) -> &'static str { match self { Self::Main => "Eclectic mix of rock, world, electronica, and more", Self::Mellow => "Mellower, less aggressive music", Self::Rock => "Heavier, more guitar-driven music", Self::Eclectic => "Curated worldwide selection", } } } impl FromStr for ParadiseChannelKind { type Err = anyhow::Error; fn from_str(s: &str) -> std::result::Result { match s.to_ascii_lowercase().as_str() { "main" | "0" => Ok(Self::Main), "mellow" | "1" => Ok(Self::Mellow), "rock" | "2" => Ok(Self::Rock), "eclectic" | "3" => Ok(Self::Eclectic), other => Err(anyhow::anyhow!("Unknown Radio Paradise channel: {}", other)), } } } /// Metadata descriptor for a channel. #[derive(Debug, Clone, Copy)] pub struct ChannelDescriptor { pub kind: ParadiseChannelKind, pub id: u8, pub slug: &'static str, pub display_name: &'static str, pub description: &'static str, } impl ChannelDescriptor { pub const fn new(kind: ParadiseChannelKind) -> Self { Self { id: kind.id(), slug: kind.slug(), display_name: kind.display_name(), description: kind.description(), kind, } } } /// All available Radio Paradise channels pub const ALL_CHANNELS: [ChannelDescriptor; 4] = [ ChannelDescriptor::new(ParadiseChannelKind::Main), ChannelDescriptor::new(ParadiseChannelKind::Mellow), ChannelDescriptor::new(ParadiseChannelKind::Rock), ChannelDescriptor::new(ParadiseChannelKind::Eclectic), ]; /// Returns the maximum valid channel ID pub const fn max_channel_id() -> u8 { (ALL_CHANNELS.len() - 1) as u8 } /// Default maximum number of tracks to keep in history /// /// This is used as the default if not configured via pmoconfig. /// Value: 100 tracks - represents ~5-8 hours of playback history pub const HISTORY_DEFAULT_MAX_TRACKS: usize = 100; #[cfg(test)] mod tests { use super::*; #[test] fn test_channel_ids() { assert_eq!(ParadiseChannelKind::Main.id(), 0); assert_eq!(ParadiseChannelKind::Mellow.id(), 1); assert_eq!(ParadiseChannelKind::Rock.id(), 2); assert_eq!(ParadiseChannelKind::Eclectic.id(), 3); } #[test] fn test_max_channel_id() { assert_eq!(max_channel_id(), 3); } #[test] fn test_all_channels_length() { assert_eq!(ALL_CHANNELS.len(), 4); } #[test] fn test_channel_from_str() { assert!(matches!( "main".parse::(), Ok(ParadiseChannelKind::Main) )); assert!(matches!( "0".parse::(), Ok(ParadiseChannelKind::Main) )); assert!("invalid".parse::().is_err()); } } -------End of pmoparadise/src/channels.rs --------- ------------ pmoparadise/src/client.rs ---------- //! HTTP client for Radio Paradise API use crate::error::{Error, Result}; use crate::models::{Block, EventId, NowPlaying}; use reqwest::Client; use std::time::Duration; use url::Url; /// Default Radio Paradise API base URL pub const DEFAULT_API_BASE: &str = "https://api.radioparadise.com/api"; /// Default block base URL (channel is appended) pub const DEFAULT_BLOCK_BASE: &str = "https://apps.radioparadise.com/blocks/chan"; /// Default image base URL pub const DEFAULT_IMAGE_BASE: &str = "https://img.radioparadise.com/"; /// Default timeout for metadata HTTP requests pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30; /// Default timeout for large block downloads/streams /// IMPORTANT: Radio Paradise blocks can be ~20 minutes long, and with backpressure /// from the audio pipeline, the HTTP stream must stay open for the entire duration. /// Setting this to 2 hours to safely handle even the longest blocks. pub const DEFAULT_BLOCK_TIMEOUT_SECS: u64 = 7200; // 2 hours /// Default User-Agent pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0"; /// Default channel (0 = main mix) pub const DEFAULT_CHANNEL: u8 = 0; /// Radio Paradise HTTP client /// /// This client provides access to Radio Paradise's streaming API, /// including metadata retrieval and block streaming. /// /// # Example /// /// ```no_run /// use pmoparadise::RadioParadiseClient; /// /// #[tokio::main] /// async fn main() -> Result<(), Box> { /// let client = RadioParadiseClient::new().await?; /// let now_playing = client.now_playing().await?; /// println!("Now playing: {} - {}", /// now_playing.current_song.as_ref().unwrap().artist, /// now_playing.current_song.as_ref().unwrap().title); /// Ok(()) /// } /// ``` #[derive(Debug, Clone)] pub struct RadioParadiseClient { pub(crate) client: Client, api_base: String, channel: u8, pub(crate) request_timeout: Duration, pub(crate) block_timeout: Duration, next_block_url: Option, } impl RadioParadiseClient { /// Create a new client with default settings /// /// Uses FLAC quality and channel 0 (main mix) pub async fn new() -> Result { Self::builder().build().await } /// Create a builder for configuring the client pub fn builder() -> ClientBuilder { ClientBuilder::default() } /// Create a client with a custom reqwest::Client /// /// Useful for sharing HTTP connection pools or custom proxy settings /// /// Note: Uses default settings (channel 0, default timeouts). /// For more control, use `ClientBuilder::default().client(client).build()`. pub fn with_client(client: Client) -> Self { Self { client, api_base: DEFAULT_API_BASE.to_string(), channel: DEFAULT_CHANNEL, request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS), next_block_url: None, } } /// Get the current channel (0 = main mix) pub fn channel(&self) -> u8 { self.channel } /// Get the block base URL for this client's channel pub fn block_base(&self) -> String { format!("{}/{}", DEFAULT_BLOCK_BASE, self.channel) } /// Clone the client with a different channel while preserving other settings. pub fn clone_with_channel(&self, channel: u8) -> Self { let mut cloned = self.clone(); cloned.channel = channel; cloned.next_block_url = None; cloned } /// Get a block by event ID /// /// If `event` is None, returns the current block. /// /// # Arguments /// /// * `event` - Optional event ID to fetch a specific block /// /// # Example /// /// ```no_run /// # use pmoparadise::RadioParadiseClient; /// # #[tokio::main] /// # async fn main() -> Result<(), Box> { /// let client = RadioParadiseClient::new().await?; /// /// // Get current block /// let current = client.get_block(None).await?; /// println!("Current block: {} songs", current.song_count()); /// /// // Get next block /// let next = client.get_block(Some(current.end_event)).await?; /// println!("Next block: {} songs", next.song_count()); /// # Ok(()) /// # } /// ``` pub async fn get_block(&self, event: Option) -> Result { let mut url = Url::parse(&format!("{}/get_block", self.api_base))?; url.query_pairs_mut() .append_pair("bitrate", "4") // FLAC lossless .append_pair("info", "true") // RP API expects `chan` rather than `channel` for channel selection. .append_pair("chan", &self.channel.to_string()); if let Some(event_id) = event { url.query_pairs_mut() .append_pair("event", &event_id.to_string()); } #[cfg(feature = "logging")] tracing::debug!("Fetching block: {}", url); let response = self .client .get(url) .timeout(self.request_timeout) .send() .await?; if !response.status().is_success() { return Err(Error::other(format!( "API returned error status: {}", response.status() ))); } let mut block: Block = response.json().await?; // Normalize protocol-relative URLs from API (//img.radioparadise.com/) if let Some(ref base) = block.image_base { if base.starts_with("//") { block.image_base = Some(format!("https:{}", base)); } } else { // Fallback if API doesn't provide image_base (should never happen) block.image_base = Some(DEFAULT_IMAGE_BASE.to_string()); } #[cfg(feature = "logging")] tracing::debug!( "Received block: event={}, songs={}", block.event, block.song_count() ); Ok(block) } /// Get the currently playing block and song /// /// Returns a `NowPlaying` struct with the current block and /// an estimate of which song is currently playing (first song). /// /// Note: Without real-time synchronization, we assume playback /// starts from the beginning of the block. pub async fn now_playing(&self) -> Result { let block = self.get_block(None).await?; Ok(NowPlaying::from_block(block)) } /// Prefetch metadata for the next block /// /// Stores the next block URL internally for seamless transitions. /// Call this before the current block finishes playing. /// /// # Arguments /// /// * `current` - The currently playing block pub async fn prefetch_next(&mut self, current: &Block) -> Result<()> { let next_block = self.get_block(Some(current.end_event)).await?; self.next_block_url = Some(next_block.url.clone()); #[cfg(feature = "logging")] tracing::debug!( "Prefetched next block: {} -> {}", current.end_event, next_block.event ); Ok(()) } /// Get the prefetched next block URL pub fn next_block_url(&self) -> Option<&str> { self.next_block_url.as_deref() } /// Clear the prefetched next block URL pub fn clear_next_block(&mut self) { self.next_block_url = None; } /// Get the internal HTTP client pub fn http_client(&self) -> &Client { &self.client } } /// Builder for configuring a RadioParadiseClient #[derive(Debug)] pub struct ClientBuilder { client: Option, api_base: String, channel: u8, request_timeout: Duration, block_timeout: Duration, user_agent: String, proxy: Option, } impl Default for ClientBuilder { fn default() -> Self { Self { client: None, api_base: DEFAULT_API_BASE.to_string(), channel: DEFAULT_CHANNEL, request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS), user_agent: DEFAULT_USER_AGENT.to_string(), proxy: None, } } } impl ClientBuilder { /// Create a new builder with default settings pub fn new() -> Self { Self::default() } /// Set a custom HTTP client pub fn client(mut self, client: Client) -> Self { self.client = Some(client); self } /// Set the API base URL pub fn api_base(mut self, url: impl Into) -> Self { self.api_base = url.into(); self } /// Set the channel (0 = main mix, 1 = mellow, 2 = rock, 3 = world/etc) pub fn channel(mut self, channel: u8) -> Self { self.channel = channel; self } /// Set the request timeout pub fn timeout(mut self, timeout: Duration) -> Self { self.request_timeout = timeout; self } /// Set the timeout specifically for block downloads/streams pub fn block_timeout(mut self, timeout: Duration) -> Self { self.block_timeout = timeout; self } /// Set a custom User-Agent header pub fn user_agent(mut self, user_agent: impl Into) -> Self { self.user_agent = user_agent.into(); self } /// Set a proxy URL pub fn proxy(mut self, proxy: impl Into) -> Self { self.proxy = Some(proxy.into()); self } /// Build the client pub async fn build(self) -> Result { let client = if let Some(client) = self.client { client } else { let mut builder = Client::builder() .user_agent(&self.user_agent) .timeout(self.request_timeout); if let Some(proxy_url) = &self.proxy { let proxy = reqwest::Proxy::all(proxy_url) .map_err(|e| Error::other(format!("Invalid proxy: {}", e)))?; builder = builder.proxy(proxy); } builder.build()? }; Ok(RadioParadiseClient { client, api_base: self.api_base, channel: self.channel, request_timeout: self.request_timeout, block_timeout: self.block_timeout, next_block_url: None, }) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_builder_defaults() { let builder = ClientBuilder::default(); assert_eq!(builder.api_base, DEFAULT_API_BASE); assert_eq!(builder.channel, DEFAULT_CHANNEL); } } -------End of pmoparadise/src/client.rs --------- ------------ pmoparadise/src/config_ext.rs ---------- //! Extension pour intégrer Radio Paradise dans pmoconfig //! //! Ce module fournit le trait `RadioParadiseConfigExt` qui permet d'ajouter facilement //! des méthodes de gestion de la configuration Radio Paradise à pmoconfig::Config. //! //! La configuration est minimale - seulement ce qui doit vraiment être configurable : //! - Activation/désactivation de la source //! //! # Exemple //! //! ```rust,ignore //! use pmoconfig::get_config; //! use pmoparadise::RadioParadiseConfigExt; //! //! let config = get_config(); //! //! // Check if enabled //! if !config.get_paradise_enabled()? { //! println!("Radio Paradise is disabled"); //! return Ok(()); //! } //! ``` use crate::{channels::ParadiseChannelKind, client::DEFAULT_CHANNEL}; use anyhow::Result; use pmoconfig::Config; use serde_yaml::Value; /// Trait d'extension pour gérer la configuration Radio Paradise dans pmoconfig /// /// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques /// à la configuration minimale de Radio Paradise. /// /// # Auto-persist des valeurs par défaut /// /// Le getter persiste automatiquement la valeur par défaut dans la /// configuration si elle n'existe pas encore. Cela permet à l'utilisateur de /// voir la configuration effective dans le fichier YAML et de la modifier facilement. /// /// # Exemple /// /// ```rust,ignore /// use pmoconfig::get_config; /// use pmoparadise::RadioParadiseConfigExt; /// /// let config = get_config(); /// /// // Premier appel : persiste "enabled: true" dans la config et retourne true /// let enabled = config.get_paradise_enabled()?; /// /// // L'utilisateur peut maintenant éditer cette valeur dans le fichier YAML /// ``` pub trait RadioParadiseConfigExt { /// Vérifie si Radio Paradise est activé /// /// # Returns /// /// `true` si la source est activée (default), `false` sinon. /// /// Si la valeur n'existe pas dans la configuration, elle est automatiquement /// définie à `true` (activé par défaut) et persistée. /// /// # Exemple /// /// ```rust,ignore /// if config.get_paradise_enabled()? { /// // Initialize Radio Paradise... /// } /// ``` fn get_paradise_enabled(&self) -> Result; /// Active ou désactive Radio Paradise /// /// # Arguments /// /// * `enabled` - `true` pour activer, `false` pour désactiver /// /// # Exemple /// /// ```rust,ignore /// // Disable Radio Paradise /// config.set_paradise_enabled(false)?; /// ``` fn set_paradise_enabled(&self, enabled: bool) -> Result<()>; /// Récupère le channel par défaut /// /// # Returns /// /// Le channel par défaut (0 = Main Mix par défaut). /// /// Si la valeur n'existe pas dans la configuration, elle est automatiquement /// définie à "main" et persistée. /// /// # Channels disponibles /// /// Peut être configuré comme chaîne de caractères ou nombre : /// - "main" ou 0 = Main Mix (eclectic, diverse mix) /// - "mellow" ou 1 = Mellow Mix (smooth, chilled music) /// - "rock" ou 2 = Rock Mix (classic & modern rock) /// - "eclectic" ou 3 = Eclectic Mix (global sounds) /// /// # Exemple de configuration YAML /// /// ```yaml /// sources: /// radio_paradise: /// default_channel: mellow # or 1 /// ``` /// /// # Exemple d'utilisation /// /// ```rust,ignore /// let channel = config.get_paradise_default_channel()?; /// let client = RadioParadiseClient::builder().channel(channel).build().await?; /// ``` fn get_paradise_default_channel(&self) -> Result; /// Définit le channel par défaut /// /// # Arguments /// /// * `channel` - Le channel (0-3) /// /// La valeur est stockée sous forme de nom convivial ("main", "mellow", etc.) /// dans le fichier de configuration. /// /// # Exemple /// /// ```rust,ignore /// use pmoparadise::channels::ParadiseChannelKind; /// /// // Use Mellow Mix by default /// config.set_paradise_default_channel(ParadiseChannelKind::Mellow.id())?; /// // Or simply: /// config.set_paradise_default_channel(1)?; /// ``` fn set_paradise_default_channel(&self, channel: u8) -> Result<()>; } impl RadioParadiseConfigExt for Config { fn get_paradise_enabled(&self) -> Result { match self.get_value(&["sources", "radio_paradise", "enabled"]) { Ok(Value::Bool(b)) => Ok(b), _ => { // Use default (enabled) and persist it self.set_paradise_enabled(true)?; Ok(true) } } } fn set_paradise_enabled(&self, enabled: bool) -> Result<()> { self.set_value( &["sources", "radio_paradise", "enabled"], Value::Bool(enabled), ) } fn get_paradise_default_channel(&self) -> Result { match self.get_value(&["sources", "radio_paradise", "default_channel"]) { Ok(Value::String(s)) => { // Try to parse as channel name (e.g., "main", "mellow", etc.) match s.parse::() { Ok(kind) => Ok(kind.id()), Err(_) => { // Invalid channel name, use default self.set_paradise_default_channel(DEFAULT_CHANNEL)?; Ok(DEFAULT_CHANNEL) } } } Ok(Value::Number(n)) => { // Accept numeric channel ID (0-3) if let Some(ch) = n.as_u64() { if ch <= 3 { Ok(ch as u8) } else { // Invalid channel number, use default self.set_paradise_default_channel(DEFAULT_CHANNEL)?; Ok(DEFAULT_CHANNEL) } } else { // Not a valid number, use default self.set_paradise_default_channel(DEFAULT_CHANNEL)?; Ok(DEFAULT_CHANNEL) } } _ => { // Use default and persist it as "main" (user-friendly) self.set_value( &["sources", "radio_paradise", "default_channel"], Value::String("main".to_string()), )?; Ok(DEFAULT_CHANNEL) } } } fn set_paradise_default_channel(&self, channel: u8) -> Result<()> { // Convert channel ID to user-friendly string name let channel_name = match channel { 0 => "main", 1 => "mellow", 2 => "rock", 3 => "eclectic", _ => return Err(anyhow::anyhow!("Invalid channel ID: {}", channel)), }; self.set_value( &["sources", "radio_paradise", "default_channel"], Value::String(channel_name.to_string()), ) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_trait_exists() { // Simple test to ensure the trait compiles } } -------End of pmoparadise/src/config_ext.rs --------- ------------ pmoparadise/src/error.rs ---------- //! Error types for the Radio Paradise client /// Result type alias for Radio Paradise operations pub type Result = std::result::Result; /// Errors that can occur when using the Radio Paradise 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), /// Invalid track index #[error("Invalid track index: {0} (block has {1} tracks)")] InvalidIndex(usize, usize), /// Invalid bitrate #[error("Invalid bitrate value: {0} (must be 0-4)")] InvalidBitrate(u8), /// Invalid event ID #[error("Invalid event ID: {0}")] InvalidEvent(String), /// Track not found in block #[error("Track not found at index {0}")] TrackNotFound(usize), /// Invalid elapsed time #[error("Invalid elapsed time: {0}ms (exceeds block length)")] InvalidElapsed(u64), /// 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) -> Self { Self::Other(msg.into()) } } -------End of pmoparadise/src/error.rs --------- ------------ pmoparadise/src/lib.rs ---------- //! # pmoparadise - Radio Paradise Client for Rust //! //! `pmoparadise` is an idiomatic Rust client library for accessing Radio Paradise's //! streaming API. It provides metadata retrieval, block streaming, and optional //! per-track extraction from FLAC blocks. //! //! ## Features //! //! - **Metadata Access**: Get current and historical block metadata with song information //! - **Block Streaming**: Stream continuous FLAC blocks with automatic prefetching //! - **FLAC Quality**: Lossless CD quality or better //! - **Per-Track Extraction** (optional): Extract individual tracks from FLAC blocks //! - **Async/Await**: Built on tokio for efficient async I/O //! - **Type-Safe**: Strongly typed API with comprehensive error handling //! //! ## Quick Start //! //! ```no_run //! use pmoparadise::RadioParadiseClient; //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { //! // Create a client //! let client = RadioParadiseClient::new().await?; //! //! // Get what's currently playing //! let now_playing = client.now_playing().await?; //! //! if let Some(song) = &now_playing.current_song { //! println!("Now Playing: {} - {}", song.artist, song.title); //! if let Some(album) = &song.album { //! println!("Album: {}", album); //! } //! } //! //! // Get all songs in the current block //! for (index, song) in now_playing.block.songs_ordered() { //! println!(" {}. {} - {} ({}s)", //! index, //! song.artist, //! song.title, //! song.duration / 1000); //! } //! //! Ok(()) //! } //! ``` //! //! ## Streaming Blocks //! //! Radio Paradise broadcasts music in continuous "blocks" - each block is a single //! FLAC file containing multiple songs with metadata indicating timing offsets. //! //! ```no_run //! use pmoparadise::RadioParadiseClient; //! use futures::StreamExt; //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { //! let client = RadioParadiseClient::new().await?; //! let block = client.get_block(None).await?; //! //! // Stream the block //! let mut stream = client.stream_block_from_metadata(&block).await?; //! //! while let Some(chunk) = stream.next().await { //! let bytes = chunk?; //! // Feed to audio player, write to file, etc. //! } //! //! Ok(()) //! } //! ``` //! //! ## Per-Track Extraction (Feature: `per-track`) //! //! **Important**: This is an advanced feature with significant tradeoffs. //! See the [`track`] module documentation for details. //! //! Most applications should stream blocks and use player-based seeking instead. //! //! ```no_run //! # #[cfg(feature = "per-track")] //! # { //! use pmoparadise::RadioParadiseClient; //! use std::path::Path; //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { //! let client = RadioParadiseClient::new().await?; //! let block = client.get_block(None).await?; //! //! // Extract first track to WAV //! let mut track = client.open_track_stream(&block, 0).await?; //! track.export_wav(Path::new("track.wav"))?; //! //! // Or get position for player-based seeking (recommended) //! let (start, duration) = client.track_position_seconds(&block, 0)?; //! println!("Play with: mpv --start={} --length={} {}", start, duration, block.url); //! //! Ok(()) //! } //! # } //! ``` //! //! ## Architecture //! //! The API is organized into several modules: //! //! - [`client`]: Main HTTP client for API access //! - [`models`]: Data structures for blocks, songs, and metadata //! - [`stream`]: Block streaming functionality //! - [`track`]: Per-track extraction (feature-gated) //! - [`error`]: Error types and result aliases //! //! ## Radio Paradise Block Format //! //! Radio Paradise streams use a block-based format: //! //! - Each block is a single FLAC audio file //! - Blocks contain multiple songs (typically 10-15 minutes total) //! - Metadata includes timing offsets (`song[i].elapsed` in ms) for each song //! - Block URLs follow the pattern: `https://apps.radioparadise.com/blocks/chan/0/4/-.flac` //! - The `end_event` of one block is the `event` of the next, enabling seamless transitions //! //! ## Best Practices //! //! ### For Continuous Playback //! //! 1. Get current block with `get_block(None)` //! 2. Stream block with `stream_block_from_metadata()` //! 3. Use `prefetch_next()` to prepare the next block //! 4. When current block ends, stream the next block seamlessly //! //! ### For Per-Song Seeking //! //! **Recommended approach** (efficient): //! ```bash //! # Use your audio player's seek capability //! mpv --start=123.5 --length=234.0 //! ``` //! //! **Alternative** (resource-intensive, requires `per-track` feature): //! - Download and decode block //! - Extract specific track to PCM/WAV //! //! ## Error Handling //! //! All operations return `Result` with detailed error types: //! //! ```no_run //! use pmoparadise::{RadioParadiseClient, Error}; //! //! #[tokio::main] //! async fn main() { //! let client = RadioParadiseClient::new().await.unwrap(); //! //! match client.get_block(Some(99999999)).await { //! Ok(block) => println!("Got block: {}", block.event), //! Err(Error::Http(e)) => eprintln!("Network error: {}", e), //! Err(Error::Json(e)) => eprintln!("Parse error: {}", e), //! Err(e) => eprintln!("Other error: {}", e), //! } //! } //! ``` //! //! ## Audio Streaming (Feature: `pmoaudio`) //! //! For direct audio streaming and integration with pmoaudio pipelines, //! use `RadioParadiseStreamSource`: //! //! ```no_run //! # #[cfg(feature = "pmoaudio")] //! # { //! use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource}; //! use pmoaudio::pipeline::Node; //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { //! let client = RadioParadiseClient::new().await?; //! let stream_source = RadioParadiseStreamSource::new(client, None).await?; //! //! // Create audio node from stream source //! let node = Node::from_logic(stream_source); //! //! // Use in pmoaudio pipeline... //! //! Ok(()) //! } //! # } //! ``` //! //! **RadioParadiseStreamSource**: //! - Downloads and decodes FLAC blocks in real-time //! - Automatically detects bit depth (16/24/32-bit) //! - Inserts track boundaries with metadata //! - Integrates seamlessly with pmoaudio pipelines //! //! ## Cargo Features //! //! - `default`: Standard metadata and streaming (no FLAC decoding) //! - `per-track`: Enable FLAC decoding and per-track extraction (adds `claxon`, `hound`, `tempfile`) //! - `pmoserver`: Enable REST API extension for pmoserver integration (adds `utoipa`, `axum`) //! - `pmoaudio`: Enable RadioParadiseStreamSource for pmoaudio integration //! - `pmoconfig`: Enable configuration integration with pmoconfig //! - `server`: Enable RadioParadiseSource for UPnP ContentDirectory integration //! //! ## See Also //! //! - [Radio Paradise](https://radioparadise.com) - Official website //! - [Radio Paradise API](https://api.radioparadise.com) - API documentation pub mod channels; pub mod client; pub mod error; pub mod models; pub mod source; #[cfg(feature = "pmoaudio")] pub mod node_stats; #[cfg(feature = "pmoserver")] pub mod pmoserver_ext; #[cfg(feature = "pmoconfig")] pub mod config_ext; #[cfg(feature = "pmoaudio")] pub mod radio_paradise_stream_source; #[cfg(feature = "pmoaudio")] pub mod stream_channel; #[cfg(feature = "pmoaudio")] pub mod playlist_feeder; // Re-exports for convenience pub use client::{ClientBuilder, RadioParadiseClient}; pub use error::{Error, Result}; pub use models::{Block, DurationMs, EventId, NowPlaying, Song}; pub use source::RadioParadiseSource; #[cfg(feature = "pmoaudio")] pub use radio_paradise_stream_source::RadioParadiseStreamSource; #[cfg(feature = "pmoaudio")] pub use playlist_feeder::{RadioParadisePlaylistFeeder, END_OF_BLOCKS_SIGNAL}; #[cfg(feature = "pmoaudio")] pub use stream_channel::{ HistoryFlacStream, HistoryOggStream, HistoryStreamError, ParadiseChannelManager, ParadiseHistoryBuilder, ParadiseHistoryOptions, ParadiseStreamChannel, ParadiseStreamChannelConfig, }; #[cfg(feature = "pmoserver")] pub use pmoserver_ext::{ create_api_router, RadioParadiseApiDoc, RadioParadiseExt, RadioParadiseState, }; #[cfg(feature = "pmoconfig")] pub use config_ext::RadioParadiseConfigExt; // Version information pub const VERSION: &str = env!("CARGO_PKG_VERSION"); #[cfg(test)] mod tests { use super::*; #[test] fn test_version() { assert!(!VERSION.is_empty()); } } -------End of pmoparadise/src/lib.rs --------- ------------ pmoparadise/src/models.rs ---------- //! Data models for Radio Paradise API responses use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Number; use std::collections::HashMap; use url::Url; /// Deserialize a string or number into a u64 fn deserialize_string_or_u64<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { use serde::de::Error; #[derive(Deserialize)] #[serde(untagged)] enum StringOrU64 { String(String), Number(u64), } match StringOrU64::deserialize(deserializer)? { StringOrU64::String(s) => s.parse::().map_err(D::Error::custom), StringOrU64::Number(n) => Ok(n), } } /// Deserialize a string or number into a f64, then convert to u64 milliseconds fn deserialize_length<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { use serde::de::Error; #[derive(Deserialize)] #[serde(untagged)] enum StringOrNumber { String(String), Number(Number), } fn to_milliseconds(value: f64) -> u64 { if value >= 100_000.0 { value.round() as u64 } else { (value * 1000.0).round() as u64 } } match StringOrNumber::deserialize(deserializer)? { StringOrNumber::String(s) => { let value = s.parse::().map_err(D::Error::custom)?; Ok(to_milliseconds(value)) } StringOrNumber::Number(n) => { if let Some(int_value) = n.as_u64() { Ok(to_milliseconds(int_value as f64)) } else if let Some(float_value) = n.as_f64() { Ok(to_milliseconds(float_value)) } else { Err(D::Error::custom("Invalid number for block length")) } } } } /// Deserialize an optional string or number into Option fn deserialize_optional_string_or_u32<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { use serde::de::Error; #[derive(Deserialize)] #[serde(untagged)] enum StringOrU32 { String(String), Number(u32), } let opt = Option::::deserialize(deserializer)?; match opt { None => Ok(None), Some(StringOrU32::String(s)) => { if s.is_empty() { Ok(None) } else { s.parse::().map(Some).map_err(D::Error::custom) } } Some(StringOrU32::Number(n)) => Ok(Some(n)), } } /// Deserialize an optional string or number into Option fn deserialize_optional_string_or_f32<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { use serde::de::Error; #[derive(Deserialize)] #[serde(untagged)] enum StringOrF32 { String(String), Float(f32), Int(i32), } let opt = Option::::deserialize(deserializer)?; match opt { None => Ok(None), Some(StringOrF32::String(s)) => { if s.is_empty() { Ok(None) } else { s.parse::().map(Some).map_err(D::Error::custom) } } Some(StringOrF32::Float(f)) => Ok(Some(f)), Some(StringOrF32::Int(i)) => Ok(Some(i as f32)), } } /// Duration in milliseconds pub type DurationMs = u64; /// Event ID for block identification pub type EventId = u64; /// Information about a song/track within a block #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Song { /// Artist name pub artist: String, /// Song title pub title: String, /// Album name (may be missing for promos/announcements) #[serde(default)] pub album: Option, /// Year of release /// Note: API returns this as a string, we deserialize to u32 #[serde(default, deserialize_with = "deserialize_optional_string_or_u32")] pub year: Option, /// Elapsed time from start of block in milliseconds pub elapsed: DurationMs, /// Duration of the track in milliseconds pub duration: DurationMs, /// Cover image filename/path #[serde(default)] pub cover: Option, /// Rating (0-10) /// Note: API returns this as a string, we deserialize to f32 #[serde(default, deserialize_with = "deserialize_optional_string_or_f32")] pub rating: Option, /// Gapless URL for individual song FLAC /// This URL points to a FLAC file containing only this song #[serde(default)] pub gapless_url: Option, /// Scheduled playback time on Radio Paradise (Unix timestamp in milliseconds, UTC) #[serde(default)] pub sched_time_millis: Option, /// Radio Paradise song ID (unique identifier) #[serde(default)] pub song_id: Option, /// Radio Paradise artist ID (for building artist URLs) #[serde(default)] pub artist_id: Option, /// Large cover image path (best quality) #[serde(default)] pub cover_large: Option, /// Additional metadata #[serde(flatten)] pub extra: HashMap, } impl Song { /// Get the end time of this song in the block (elapsed + duration) pub fn end_time_ms(&self) -> DurationMs { self.elapsed + self.duration } /// Check if a given timestamp (ms) falls within this song pub fn contains_timestamp(&self, timestamp_ms: DurationMs) -> bool { timestamp_ms >= self.elapsed && timestamp_ms < self.end_time_ms() } /// Calcule le timestamp de fin de diffusion (sched_time + duration) pub fn sched_end_time_ms(&self) -> Option { self.sched_time_millis.map(|start| start + self.duration) } /// Vérifie si la chanson est encore en lecture ou à venir pub fn is_still_playing(&self, now_ms: u64) -> bool { self.sched_end_time_ms() .map(|end| end >= now_ms) .unwrap_or(false) } } /// Image information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageInfo { /// Base URL for images pub base: String, } /// A block of songs from Radio Paradise /// /// Radio Paradise streams music in "blocks" - continuous FLAC files /// containing multiple songs. Each block contains metadata about all /// songs within it and timing information for seeking. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Block { /// Event ID for this block (start event) /// Note: API returns this as a string, we deserialize to u64 #[serde(deserialize_with = "deserialize_string_or_u64")] pub event: EventId, /// Event ID for the next block (end event) /// Note: API returns this as a string, we deserialize to u64 #[serde(deserialize_with = "deserialize_string_or_u64")] pub end_event: EventId, /// Total length of the block in milliseconds /// Note: API returns this as a string in seconds (e.g., "1715.54"), we convert to ms #[serde(deserialize_with = "deserialize_length")] pub length: DurationMs, /// URL to stream this block pub url: String, /// Base URL for cover images #[serde(default)] pub image_base: Option, /// Scheduled start time for this block (Unix timestamp in milliseconds, UTC) #[serde(default)] pub sched_time_millis: Option, /// Map of song index (as string) to Song metadata /// Keys are "0", "1", "2", etc. #[serde(default)] pub song: HashMap, /// Additional metadata #[serde(flatten)] pub extra: HashMap, } impl Block { /// Scheduled start time in milliseconds if available. pub fn start_time_millis(&self) -> Option { if let Some(ts) = self.sched_time_millis { return Some(ts); } self.songs_ordered() .into_iter() .find_map(|(_, song)| song.sched_time_millis) } /// Get songs in order by index pub fn songs_ordered(&self) -> Vec<(usize, &Song)> { let mut songs: Vec<_> = self .song .iter() .filter_map(|(k, v)| k.parse::().ok().map(|idx| (idx, v))) .collect(); songs.sort_by_key(|(idx, _)| *idx); songs } /// Get a song by index pub fn get_song(&self, index: usize) -> Option<&Song> { self.song.get(&index.to_string()) } /// Get the number of songs in this block pub fn song_count(&self) -> usize { self.song.len() } /// Get the full URL for a cover image pub fn cover_url(&self, cover_path: &str) -> Option { let base = self.image_base.as_ref()?; let base_url = Url::parse(base).ok()?; base_url.join(cover_path).ok().map(|url| url.to_string()) } /// Find which song is playing at a given timestamp (ms from block start) pub fn song_at_timestamp(&self, timestamp_ms: DurationMs) -> Option<(usize, &Song)> { self.songs_ordered() .into_iter() .find(|(_, song)| song.contains_timestamp(timestamp_ms)) } /// Parse the block URL to get start and end event IDs /// /// Block URLs follow the pattern: /// `https://apps.radioparadise.com/blocks/chan/0/4/-.flac` pub fn parse_url_events(&self) -> Option<(EventId, EventId)> { let url_path = self.url.split('/').last()?; let filename = url_path.strip_suffix(".flac")?; let mut parts = filename.split('-'); let start = parts.next()?.parse::().ok()?; let end = parts.next()?.parse::().ok()?; Some((start, end)) } } /// Currently playing information #[derive(Debug, Clone)] pub struct NowPlaying { /// The current block pub block: Block, /// Current song index (if determinable) pub current_song_index: Option, /// Current song pub current_song: Option, /// Approximate elapsed time in current block (ms) /// Note: This is estimated and may not be perfectly accurate pub block_elapsed_ms: Option, } impl NowPlaying { /// Create from a block (assumes starting from beginning) pub fn from_block(block: Block) -> Self { let (current_song_index, current_song) = block .get_song(0) .map(|s| (Some(0), Some(s.clone()))) .unwrap_or((None, None)); Self { block, current_song_index, current_song, block_elapsed_ms: Some(0), } } /// Get URL for the current block stream pub fn stream_url(&self) -> &str { &self.block.url } } #[cfg(test)] mod tests { use super::*; #[test] fn test_song_timing() { let song = Song { artist: "Test Artist".to_string(), title: "Test Song".to_string(), album: Some("Test Album".to_string()), year: Some(2024), elapsed: 1000, duration: 5000, cover: None, rating: None, extra: HashMap::new(), gapless_url: Some("http://example.com/song.flac".into()), sched_time_millis: Some(1_700_000_000_000), song_id: Some("song-id".into()), artist_id: Some("artist-id".into()), cover_large: Some("cover-large.jpg".into()), }; assert_eq!(song.end_time_ms(), 6000); assert!(song.contains_timestamp(3000)); assert!(!song.contains_timestamp(7000)); assert!(!song.contains_timestamp(500)); } #[test] fn test_block_parse() { let json = r#"{ "event": 1234, "end_event": 5678, "length": 900000, "url": "https://apps.radioparadise.com/blocks/chan/0/4/1234-5678.flac", "image_base": "https://img.radioparadise.com/covers/l/", "song": { "0": { "artist": "Miles Davis", "title": "So What", "album": "Kind of Blue", "year": 1959, "elapsed": 0, "duration": 540000, "cover": "B00000I0JF.jpg" }, "1": { "artist": "John Coltrane", "title": "Giant Steps", "album": "Giant Steps", "year": 1960, "elapsed": 540000, "duration": 360000, "cover": "B000002I4U.jpg" } } }"#; let block: Block = serde_json::from_str(json).unwrap(); assert_eq!(block.event, 1234); assert_eq!(block.end_event, 5678); assert_eq!(block.song_count(), 2); let songs = block.songs_ordered(); assert_eq!(songs.len(), 2); assert_eq!(songs[0].1.title, "So What"); assert_eq!(songs[1].1.title, "Giant Steps"); let (start, end) = block.parse_url_events().unwrap(); assert_eq!(start, 1234); assert_eq!(end, 5678); let (idx, song) = block.song_at_timestamp(600000).unwrap(); assert_eq!(idx, 1); assert_eq!(song.title, "Giant Steps"); } #[test] fn test_block_length_from_seconds_string() { let json = serde_json::json!({ "event": 1, "end_event": 2, "length": "1715.54", "url": "https://example.com/block.flac", "song": {} }); let block: Block = serde_json::from_value(json).unwrap(); assert_eq!(block.length, 1_715_540); } #[test] fn test_block_length_from_seconds_integer() { let json = serde_json::json!({ "event": 1, "end_event": 2, "length": 1800, "url": "https://example.com/block.flac", "song": {} }); let block: Block = serde_json::from_value(json).unwrap(); assert_eq!(block.length, 1_800_000); } #[test] fn test_block_length_from_milliseconds_integer() { let json = serde_json::json!({ "event": 1, "end_event": 2, "length": 900_000, "url": "https://example.com/block.flac", "song": {} }); let block: Block = serde_json::from_value(json).unwrap(); assert_eq!(block.length, 900_000); } #[test] fn test_block_length_from_milliseconds_float() { let json = serde_json::json!({ "event": 1, "end_event": 2, "length": 900_000.0, "url": "https://example.com/block.flac", "song": {} }); let block: Block = serde_json::from_value(json).unwrap(); assert_eq!(block.length, 900_000); } } -------End of pmoparadise/src/models.rs --------- ------------ pmoparadise/src/node_stats.rs ---------- //! Node statistics tracking //! //! Provides detailed statistics for pipeline nodes to understand //! data flow, backpressure behavior, and timing. use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Instant; /// Statistics pour un node audio #[derive(Debug)] pub struct NodeStats { /// Nom du node pour identification pub name: String, /// Instant de démarrage du node pub start_time: Instant, /// Nombre total de segments reçus pub segments_received: AtomicUsize, /// Nombre total de segments envoyés pub segments_sent: AtomicUsize, /// Nombre total de bytes traités pub bytes_processed: AtomicU64, /// Nombre de fois où l'envoi a été bloqué (backpressure) pub backpressure_blocks: AtomicUsize, /// Temps total passé bloqué en millisecondes pub backpressure_time_ms: AtomicU64, /// Timestamp du premier segment (secondes) pub first_segment_timestamp: AtomicU64, // Stocké comme u64 * 1000 pour précision /// Timestamp du dernier segment (secondes) pub last_segment_timestamp: AtomicU64, // Stocké comme u64 * 1000 pour précision } impl NodeStats { pub fn new(name: impl Into) -> Arc { Arc::new(Self { name: name.into(), start_time: Instant::now(), segments_received: AtomicUsize::new(0), segments_sent: AtomicUsize::new(0), bytes_processed: AtomicU64::new(0), backpressure_blocks: AtomicUsize::new(0), backpressure_time_ms: AtomicU64::new(0), first_segment_timestamp: AtomicU64::new(u64::MAX), last_segment_timestamp: AtomicU64::new(0), }) } /// Enregistre la réception d'un segment pub fn record_segment_received(&self, timestamp_sec: f64) { self.segments_received.fetch_add(1, Ordering::Relaxed); let ts_millis = (timestamp_sec * 1000.0) as u64; // Update first timestamp (atomic min) let mut current = self.first_segment_timestamp.load(Ordering::Relaxed); while current > ts_millis { match self.first_segment_timestamp.compare_exchange_weak( current, ts_millis, Ordering::Relaxed, Ordering::Relaxed, ) { Ok(_) => break, Err(x) => current = x, } } // Update last timestamp (atomic max) let mut current = self.last_segment_timestamp.load(Ordering::Relaxed); while current < ts_millis { match self.last_segment_timestamp.compare_exchange_weak( current, ts_millis, Ordering::Relaxed, Ordering::Relaxed, ) { Ok(_) => break, Err(x) => current = x, } } } /// Enregistre l'envoi d'un segment pub fn record_segment_sent(&self, bytes: usize) { self.segments_sent.fetch_add(1, Ordering::Relaxed); self.bytes_processed .fetch_add(bytes as u64, Ordering::Relaxed); } /// Enregistre un événement de backpressure pub fn record_backpressure(&self, duration_ms: u64) { self.backpressure_blocks.fetch_add(1, Ordering::Relaxed); self.backpressure_time_ms .fetch_add(duration_ms, Ordering::Relaxed); } /// Retourne un rapport formaté des statistiques pub fn report(&self) -> String { let elapsed = self.start_time.elapsed().as_secs_f64(); let received = self.segments_received.load(Ordering::Relaxed); let sent = self.segments_sent.load(Ordering::Relaxed); let bytes = self.bytes_processed.load(Ordering::Relaxed); let bp_blocks = self.backpressure_blocks.load(Ordering::Relaxed); let bp_time_ms = self.backpressure_time_ms.load(Ordering::Relaxed); let first_ts = self.first_segment_timestamp.load(Ordering::Relaxed); let last_ts = self.last_segment_timestamp.load(Ordering::Relaxed); let first_ts_sec = if first_ts == u64::MAX { 0.0 } else { first_ts as f64 / 1000.0 }; let last_ts_sec = last_ts as f64 / 1000.0; let audio_duration = last_ts_sec - first_ts_sec; let mb = bytes as f64 / 1_048_576.0; let throughput_mbps = if elapsed > 0.0 { mb / elapsed } else { 0.0 }; format!( "[{}]\n\ Elapsed: {:.1}s | Received: {} | Sent: {} | Lost: {}\n\ Data: {:.1} MB | Throughput: {:.2} MB/s\n\ Audio: {:.1}s (first: {:.1}s, last: {:.1}s) | Real-time ratio: {:.1}%\n\ Backpressure: {} blocks, {:.2}s total ({:.1}% of time)", self.name, elapsed, received, sent, received.saturating_sub(sent), mb, throughput_mbps, audio_duration, first_ts_sec, last_ts_sec, if audio_duration > 0.0 { (elapsed / audio_duration) * 100.0 } else { 0.0 }, bp_blocks, bp_time_ms as f64 / 1000.0, if elapsed > 0.0 { (bp_time_ms as f64 / 1000.0 / elapsed) * 100.0 } else { 0.0 } ) } } -------End of pmoparadise/src/node_stats.rs --------- ------------ pmoparadise/src/playlist_feeder.rs ---------- //! RadioParadisePlaylistFeeder - Télécharge et alimente une playlist à partir des blocs RP //! //! Architecture simplifiée utilisant les URLs gapless individuelles au lieu du bloc FLAC entier. use crate::{client::RadioParadiseClient, models::EventId}; use anyhow::Result; use pmoaudiocache::Cache as AudioCache; use pmocovers::Cache as CoversCache; use pmoplaylist::{PlaylistManager, ReadHandle, WriteHandle}; use std::{ collections::{HashMap, VecDeque}, sync::Arc, time::{Duration, SystemTime, UNIX_EPOCH}, }; use tokio::sync::Notify; /// Signal de fin de blocs pub const END_OF_BLOCKS_SIGNAL: EventId = EventId::MAX; const RECENT_BLOCKS_CACHE_SIZE: usize = 10; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum BlockStatus { Pending, InProgress, Done, } struct RecentBlocks { states: HashMap, order: VecDeque, capacity: usize, } impl RecentBlocks { fn new(capacity: usize) -> Self { Self { states: HashMap::new(), order: VecDeque::new(), capacity, } } fn try_enqueue(&mut self, event_id: EventId) -> bool { match self.states.get(&event_id) { Some(_) => false, None => { self.order.push_back(event_id); self.states.insert(event_id, BlockStatus::Pending); self.evict_old_done(); true } } } fn mark_in_progress(&mut self, event_id: EventId) { if let Some(state) = self.states.get_mut(&event_id) { *state = BlockStatus::InProgress; } else { self.order.push_back(event_id); self.states.insert(event_id, BlockStatus::InProgress); } self.evict_old_done(); } fn mark_done(&mut self, event_id: EventId) { if let Some(state) = self.states.get_mut(&event_id) { *state = BlockStatus::Done; } else { self.order.push_back(event_id); self.states.insert(event_id, BlockStatus::Done); } self.evict_old_done(); } fn purge(&mut self, event_id: EventId) { self.states.remove(&event_id); } fn evict_old_done(&mut self) { while self.order.len() > self.capacity { let Some(front) = self.order.front().copied() else { break; }; match self.states.get(&front) { Some(BlockStatus::Done) | None => { self.order.pop_front(); self.states.remove(&front); } Some(_) => break, } } } } /// Feeder qui télécharge les blocs RP et alimente une playlist pub struct RadioParadisePlaylistFeeder { client: RadioParadiseClient, audio_cache: Arc, covers_cache: Arc, playlist_handle: Arc, block_queue: Arc>>, notify: Arc, collection: Option, recent_blocks: tokio::sync::Mutex, } impl RadioParadisePlaylistFeeder { /// Crée un nouveau feeder et retourne (feeder, read_handle) pub async fn new( client: RadioParadiseClient, audio_cache: Arc, covers_cache: Arc, playlist_id: String, collection: Option, ) -> Result<(Self, ReadHandle)> { let manager = PlaylistManager::get(); let write_handle = manager .create_persistent_playlist(playlist_id.clone()) .await?; let read_handle = manager.get_read_handle(&playlist_id).await?; Ok(( Self { client, audio_cache, covers_cache, playlist_handle: Arc::new(write_handle), block_queue: Arc::new(tokio::sync::Mutex::new(VecDeque::new())), notify: Arc::new(Notify::new()), collection, recent_blocks: tokio::sync::Mutex::new(RecentBlocks::new(RECENT_BLOCKS_CACHE_SIZE)), }, read_handle, )) } /// Enqueue un bloc pour traitement pub async fn push_block_id(&self, event_id: EventId) { { let mut recent = self.recent_blocks.lock().await; if !recent.try_enqueue(event_id) { tracing::debug!( "RadioParadisePlaylistFeeder: Ignoring duplicate enqueue for block {}", event_id ); return; } } { let mut queue = self.block_queue.lock().await; queue.push_back(event_id); } self.notify.notify_one(); } async fn mark_in_progress(&self, event_id: EventId) { let mut recent = self.recent_blocks.lock().await; recent.mark_in_progress(event_id); } async fn mark_done(&self, event_id: EventId) { let mut recent = self.recent_blocks.lock().await; recent.mark_done(event_id); } async fn purge_block_state(&self, event_id: EventId) { let mut recent = self.recent_blocks.lock().await; recent.purge(event_id); } pub(crate) async fn retry_block(&self, event_id: EventId) { self.purge_block_state(event_id).await; self.push_block_id(event_id).await; } /// Boucle principale de traitement (à exécuter dans une tâche tokio) pub async fn run(self: Arc) -> Result<()> { loop { // Attendre un bloc let event_id = loop { { let mut queue = self.block_queue.lock().await; if let Some(id) = queue.pop_front() { if id == END_OF_BLOCKS_SIGNAL { tracing::info!( "RadioParadisePlaylistFeeder: END_OF_BLOCKS_SIGNAL received" ); return Ok(()); } break id; } } self.notify.notified().await; }; self.mark_in_progress(event_id).await; // Traiter le bloc if let Err(e) = self.process_block(event_id).await { tracing::error!( "RadioParadisePlaylistFeeder: Failed to process block {}: {}", event_id, e ); self.purge_block_state(event_id).await; tracing::debug!( "RadioParadisePlaylistFeeder: Cleared block {} state after error", event_id ); } else { self.mark_done(event_id).await; } } } /// Traite un bloc : fetch, filtre, download, push playlist async fn process_block(&self, event_id: EventId) -> Result<()> { tracing::info!("RadioParadisePlaylistFeeder: Processing block {}", event_id); // 1. Fetch le bloc let block = self.client.get_block(Some(event_id)).await?; // 2. Timestamp actuel let now_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64; // 3. Filtrer les chansons encore en lecture ou à venir let songs = block.songs_ordered(); let mut processed = 0; for (idx, song) in songs { if !song.is_still_playing(now_ms) { tracing::debug!( "RadioParadisePlaylistFeeder: Skipping finished song {} - {} (ended at {})", idx, song.title, song.sched_end_time_ms().unwrap_or(0) ); continue; } // 4. Télécharger la chanson let gapless_url = song .gapless_url .as_ref() .ok_or_else(|| anyhow::anyhow!("Missing gapless_url for song {}", idx))?; tracing::info!( "RadioParadisePlaylistFeeder: Downloading song {} - {} by {}", idx, song.title, song.artist ); let pk = self .audio_cache .add_from_url(gapless_url, self.collection.as_deref()) .await?; // 5. Sauvegarder les métadonnées self.save_metadata(&pk, song, &block).await?; // 6. Calculer le TTL let sched_end = song .sched_end_time_ms() .ok_or_else(|| anyhow::anyhow!("Cannot calculate TTL without sched_time_millis"))?; let ttl_ms = sched_end.saturating_sub(now_ms); let ttl = Duration::from_millis(ttl_ms); // 7. Push dans la playlist avec TTL self.playlist_handle.push_with_ttl(pk.clone(), ttl).await?; tracing::info!( "RadioParadisePlaylistFeeder: Added {} to playlist (pk={}, ttl={}s)", song.title, pk, ttl.as_secs() ); processed += 1; } tracing::info!( "RadioParadisePlaylistFeeder: Processed block {} - added {} songs to playlist", event_id, processed ); Ok(()) } /// Sauvegarde les métadonnées dans le cache audio async fn save_metadata( &self, pk: &str, song: &crate::models::Song, block: &crate::models::Block, ) -> Result<()> { use pmoaudiocache::AudioTrackMetadataExt; let metadata = self.audio_cache.track_metadata(pk); let mut meta = metadata.write().await; // Métadonnées de base meta.set_title(Some(song.title.clone())).await?; meta.set_artist(Some(song.artist.clone())).await?; if let Some(ref album) = song.album { meta.set_album(Some(album.clone())).await?; } if let Some(year) = song.year { meta.set_year(Some(year)).await?; } // Cover if let Some(ref cover_large) = song.cover_large { if let Some(cover_url) = block.cover_url(cover_large) { meta.set_cover_url(Some(cover_url.clone())).await?; // Télécharger la cover match self .covers_cache .add_from_url(&cover_url, self.collection.as_deref()) .await { Ok(cover_pk) => { meta.set_cover_pk(Some(cover_pk)).await?; tracing::debug!( "RadioParadisePlaylistFeeder: Cached cover for {}", song.title ); } Err(e) => { tracing::warn!("RadioParadisePlaylistFeeder: Failed to cache cover: {}", e); } } } } Ok(()) } } -------End of pmoparadise/src/playlist_feeder.rs --------- ------------ pmoparadise/src/pmoserver_ext.rs ---------- //! Extension pmoserver pour Radio Paradise //! //! Ce module fournit un trait d'extension pour ajouter facilement l'API Radio Paradise //! à un serveur pmoserver. use crate::channels::{max_channel_id, ChannelDescriptor, ALL_CHANNELS}; use crate::{Block, NowPlaying, RadioParadiseClient}; use async_trait::async_trait; use axum::{ extract::{Path, Query, State}, http::StatusCode, routing::get, Json, Router, }; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; use utoipa::{OpenApi, ToSchema}; /// État partagé pour l'API Radio Paradise #[derive(Clone)] pub struct RadioParadiseState { client: Arc>, } #[derive(Debug, Default, Deserialize)] #[serde(default)] struct ParadiseQuery { channel: Option, } impl RadioParadiseState { pub async fn new() -> anyhow::Result { let client = RadioParadiseClient::new() .await .map_err(|e| anyhow::anyhow!("Failed to create RadioParadise client: {}", e))?; Ok(Self { client: Arc::new(RwLock::new(client)), }) } async fn client_for_params( &self, params: &ParadiseQuery, ) -> Result { let base_client = { let client_guard = self.client.read().await; client_guard.clone() }; let mut client = base_client; if let Some(channel) = params.channel { if channel > max_channel_id() { tracing::warn!("Invalid Radio Paradise channel requested: {}", channel); return Err(StatusCode::BAD_REQUEST); } client = client.clone_with_channel(channel); } Ok(client) } } /// Information sur un canal Radio Paradise #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ChannelInfo { /// ID du canal (0-3) pub id: u8, /// Nom du canal pub name: String, /// Description pub description: String, } impl From<&ChannelDescriptor> for ChannelInfo { fn from(descriptor: &ChannelDescriptor) -> Self { Self { id: descriptor.id, name: descriptor.display_name.to_string(), description: descriptor.description.to_string(), } } } /// Réponse avec informations étendues sur le morceau en cours #[derive(Debug, Clone, Serialize, ToSchema)] pub struct NowPlayingResponse { /// Event ID du block actuel pub event: u64, /// Event ID du prochain block pub end_event: u64, /// URL de streaming du block pub stream_url: String, /// Durée totale du block en ms pub block_length_ms: u64, /// Index du morceau actuel pub current_song_index: Option, /// Morceau actuel pub current_song: Option, /// Tous les morceaux du block pub songs: Vec, } /// Information sur un morceau #[derive(Debug, Clone, Serialize, ToSchema)] pub struct SongInfo { /// Index dans le block pub index: usize, /// Artiste pub artist: String, /// Titre pub title: String, /// Album pub album: String, /// Année pub year: Option, /// Temps écoulé depuis le début du block (ms) pub elapsed_ms: u64, /// Durée du morceau (ms) pub duration_ms: u64, /// URL de la pochette pub cover_url: Option, /// Note (0-10) pub rating: Option, } /// Réponse pour un block #[derive(Debug, Clone, Serialize, ToSchema)] pub struct BlockResponse { /// Event ID du block pub event: u64, /// Event ID du prochain block pub end_event: u64, /// URL de streaming pub url: String, /// Durée totale (ms) pub length_ms: u64, /// Morceaux du block pub songs: Vec, } /// Réponse pour l'URL de streaming #[derive(Debug, Clone, Serialize, ToSchema)] pub struct StreamUrlResponse { /// Event ID du block #[schema(example = 1234567)] pub event: u64, /// URL de streaming FLAC #[schema(example = "https://apps.radioparadise.com/blocks/chan/0/4/1234567-1234580.flac")] pub stream_url: String, /// Durée totale (ms) #[schema(example = 900000)] pub length_ms: u64, } /// Réponse pour l'URL de pochette #[derive(Debug, Clone, Serialize, ToSchema)] pub struct CoverUrlResponse { /// Event ID du block #[schema(example = 1234567)] pub event: u64, /// Index du morceau #[schema(example = 0)] pub song_index: usize, /// URL de la pochette (résolution complète) #[schema(example = "https://img.radioparadise.com/covers/l/B00000I0JF.jpg")] pub cover_url: Option, /// Type de pochette: "cover" (petite) ou "cover_large" (grande) #[schema(example = "cover_large")] pub cover_type: String, } impl From for BlockResponse { fn from(block: Block) -> Self { let songs = block .songs_ordered() .into_iter() .map(|(index, song)| SongInfo { index, artist: song.artist.clone(), title: song.title.clone(), album: song.album.clone().unwrap_or_default(), year: song.year, elapsed_ms: song.elapsed, duration_ms: song.duration, cover_url: song.cover.as_ref().and_then(|c| block.cover_url(c)), rating: song.rating, }) .collect(); Self { event: block.event, end_event: block.end_event, url: block.url, length_ms: block.length, songs, } } } impl From for NowPlayingResponse { fn from(np: NowPlaying) -> Self { let songs: Vec = np .block .songs_ordered() .into_iter() .map(|(index, song)| SongInfo { index, artist: song.artist.clone(), title: song.title.clone(), album: song.album.clone().unwrap_or_default(), year: song.year, elapsed_ms: song.elapsed, duration_ms: song.duration, cover_url: song.cover.as_ref().and_then(|c| np.block.cover_url(c)), rating: song.rating, }) .collect(); let current_song = np.current_song.as_ref().and_then(|song| { let index = np.current_song_index?; Some(SongInfo { index, artist: song.artist.clone(), title: song.title.clone(), album: song.album.clone().unwrap_or_default(), year: song.year, elapsed_ms: song.elapsed, duration_ms: song.duration, cover_url: song.cover.as_ref().and_then(|c| np.block.cover_url(c)), rating: song.rating, }) }); Self { event: np.block.event, end_event: np.block.end_event, stream_url: np.block.url, block_length_ms: np.block.length, current_song_index: np.current_song_index, current_song, songs, } } } /// GET /now-playing - Récupère le morceau en cours #[utoipa::path( get, path = "/now-playing", params( ("channel" = Option, Query, description = "Channel ID (0-3)") ), responses( (status = 200, description = "Morceau en cours", body = NowPlayingResponse), (status = 500, description = "Erreur serveur") ), tag = "Radio Paradise" )] async fn get_now_playing( State(state): State, Query(params): Query, ) -> Result, StatusCode> { let client = state.client_for_params(¶ms).await?; let now_playing = client.now_playing().await.map_err(|e| { tracing::error!("Failed to fetch now playing from Radio Paradise: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(now_playing.into())) } /// GET /block/current - Récupère le block actuel #[utoipa::path( get, path = "/block/current", params( ("channel" = Option, Query, description = "Channel ID (0-3)") ), responses( (status = 200, description = "Block actuel", body = BlockResponse), (status = 500, description = "Erreur serveur") ), tag = "Radio Paradise" )] async fn get_current_block( State(state): State, Query(params): Query, ) -> Result, StatusCode> { let client = state.client_for_params(¶ms).await?; let block = client.get_block(None).await.map_err(|e| { tracing::error!("Failed to fetch current block from Radio Paradise: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(block.into())) } /// GET /block/{event_id} - Récupère un block spécifique #[utoipa::path( get, path = "/block/{event_id}", params( ("event_id" = u64, Path, description = "Event ID du block"), ("channel" = Option, Query, description = "Channel ID (0-3)") ), responses( (status = 200, description = "Block demandé", body = BlockResponse), (status = 500, description = "Erreur serveur") ), tag = "Radio Paradise" )] async fn get_block_by_id( State(state): State, Path(event_id): Path, Query(params): Query, ) -> Result, StatusCode> { let client = state.client_for_params(¶ms).await?; let block = client.get_block(Some(event_id)).await.map_err(|e| { tracing::error!( "Failed to fetch block {} from Radio Paradise: {}", event_id, e ); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(block.into())) } /// GET /channels - Liste les canaux disponibles #[utoipa::path( get, path = "/channels", responses( (status = 200, description = "Liste des canaux", body = Vec) ), tag = "Radio Paradise" )] async fn get_channels() -> Json> { let channels: Vec = ALL_CHANNELS.iter().map(Into::into).collect(); Json(channels) } /// GET /block/{event_id}/song/{index} - Récupère un morceau spécifique d'un block #[utoipa::path( get, path = "/block/{event_id}/song/{index}", params( ("event_id" = u64, Path, description = "Event ID du block"), ("index" = usize, Path, description = "Index du morceau (0-based)"), ("channel" = Option, Query, description = "Channel ID (0-3)") ), responses( (status = 200, description = "Morceau demandé", body = SongInfo), (status = 404, description = "Morceau non trouvé"), (status = 500, description = "Erreur serveur") ), tag = "Radio Paradise" )] async fn get_song_by_index( State(state): State, Path((event_id, index)): Path<(u64, usize)>, Query(params): Query, ) -> Result, StatusCode> { let client = state.client_for_params(¶ms).await?; let block = client.get_block(Some(event_id)).await.map_err(|e| { tracing::error!( "Failed to fetch block {} from Radio Paradise: {}", event_id, e ); StatusCode::INTERNAL_SERVER_ERROR })?; let song = block.get_song(index).ok_or_else(|| { tracing::warn!("Song index {} not found in block {}", index, event_id); StatusCode::NOT_FOUND })?; let song_info = SongInfo { index, artist: song.artist.clone(), title: song.title.clone(), album: song.album.clone().unwrap_or_default(), year: song.year, elapsed_ms: song.elapsed, duration_ms: song.duration, cover_url: song.cover.as_ref().and_then(|c| block.cover_url(c)), rating: song.rating, }; Ok(Json(song_info)) } /// GET /cover-url/{event_id}/{song_index} - Récupère l'URL de la pochette d'un morceau /// /// Utilise automatiquement cover_large si disponible, sinon cover en fallback #[utoipa::path( get, path = "/cover-url/{event_id}/{song_index}", params( ("event_id" = u64, Path, description = "Event ID du block"), ("song_index" = usize, Path, description = "Index du morceau (0-based)"), ("channel" = Option, Query, description = "Channel ID (0-3)") ), responses( (status = 200, description = "URL de la pochette avec fallback automatique", body = CoverUrlResponse), (status = 404, description = "Morceau non trouvé"), (status = 500, description = "Erreur serveur") ), tag = "Radio Paradise" )] async fn get_cover_url( State(state): State, Path((event_id, song_index)): Path<(u64, usize)>, Query(params): Query, ) -> Result, StatusCode> { let client = state.client_for_params(¶ms).await?; let block = client.get_block(Some(event_id)).await.map_err(|e| { tracing::error!( "Failed to fetch block {} from Radio Paradise: {}", event_id, e ); StatusCode::INTERNAL_SERVER_ERROR })?; let song = block.get_song(song_index).ok_or_else(|| { tracing::warn!("Song index {} not found in block {}", song_index, event_id); StatusCode::NOT_FOUND })?; // Fallback: cover_large → cover → none let (cover_url, cover_type) = if let Some(ref cover_large) = song.cover_large { (block.cover_url(cover_large), "cover_large") } else if let Some(ref cover) = song.cover { (block.cover_url(cover), "cover") } else { (None, "none") }; Ok(Json(CoverUrlResponse { event: event_id, song_index, cover_url, cover_type: cover_type.to_string(), })) } /// GET /stream-url/{event_id} - Récupère l'URL de streaming direct d'un block #[utoipa::path( get, path = "/stream-url/{event_id}", params( ("event_id" = u64, Path, description = "Event ID du block (None pour le block actuel)"), ("channel" = Option, Query, description = "Channel ID (0-3)") ), responses( (status = 200, description = "URL de streaming", body = StreamUrlResponse), (status = 500, description = "Erreur serveur") ), tag = "Radio Paradise" )] async fn get_stream_url( State(state): State, Path(event_id): Path, Query(params): Query, ) -> Result, StatusCode> { let client = state.client_for_params(¶ms).await?; let block = client.get_block(Some(event_id)).await.map_err(|e| { tracing::error!( "Failed to fetch block {} from Radio Paradise: {}", event_id, e ); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(StreamUrlResponse { event: block.event, stream_url: block.url, length_ms: block.length, })) } /// Documentation OpenAPI pour l'API Radio Paradise #[derive(OpenApi)] #[openapi( info( title = "Radio Paradise API", version = "1.0.0", description = r#" # API REST pour Radio Paradise Cette API permet d'accéder aux métadonnées et flux de Radio Paradise. ## Fonctionnalités - **Métadonnées en temps réel** : Récupération du morceau en cours et des blocks - **Multi-canaux** : Support des 4 canaux Radio Paradise (Main, Mellow, Rock, Eclectic) - **Streaming FLAC** : Accès direct aux URLs de streaming haute qualité - **Pochettes d'albums** : URLs complètes des couvertures (petite et grande taille) - **Historique** : Accès aux blocks passés via event_id ## Canaux disponibles - **0: Main Mix** - Eclectic mix of rock, world, electronica, and more - **1: Mellow Mix** - Mellower, less aggressive music - **2: Rock Mix** - Heavier, more guitar-driven music - **3: Eclectic Mix** - Curated worldwide selection ## Format des données ### Blocks Les blocks sont des fichiers FLAC continus contenant plusieurs morceaux. Chaque block a un `event` (ID de début) et `end_event` (ID du prochain block). ### Timing - Tous les temps sont en millisecondes (ms) - `elapsed_ms` : temps écoulé depuis le début du block - `duration_ms` : durée du morceau ## Exemples d'utilisation ### Récupérer le morceau en cours ``` GET /api/radioparadise/now-playing?channel=0 ``` ### Récupérer un block spécifique ``` GET /api/radioparadise/block/1234567?channel=0 ``` ### Récupérer la pochette d'un morceau (avec fallback automatique) ``` GET /api/radioparadise/cover-url/1234567/0?channel=0 ``` "# ), paths( get_now_playing, get_current_block, get_block_by_id, get_channels, get_song_by_index, get_cover_url, get_stream_url ), components(schemas( NowPlayingResponse, BlockResponse, SongInfo, ChannelInfo, StreamUrlResponse, CoverUrlResponse )), tags( (name = "Radio Paradise", description = "Endpoints pour Radio Paradise") ) )] pub struct RadioParadiseApiDoc; /// Crée le router pour l'API Radio Paradise pub fn create_api_router(state: RadioParadiseState) -> Router { Router::new() .route("/now-playing", get(get_now_playing)) .route("/block/current", get(get_current_block)) .route("/block/{event_id}", get(get_block_by_id)) .route("/block/{event_id}/song/{index}", get(get_song_by_index)) .route("/cover-url/{event_id}/{song_index}", get(get_cover_url)) .route("/stream-url/{event_id}", get(get_stream_url)) .route("/channels", get(get_channels)) .with_state(state) } /// Trait d'extension pour pmoserver::Server /// /// Permet d'initialiser Radio Paradise avec routes HTTP complètes #[cfg(feature = "pmoserver")] #[async_trait] pub trait RadioParadiseExt { /// Initialise l'API Radio Paradise /// /// # Routes créées /// /// - API: `/api/radioparadise/*` /// - `/now-playing` /// - `/block/*` /// - `/channels` /// - Swagger: `/swagger-ui/radioparadise` async fn init_radioparadise(&mut self) -> anyhow::Result; } #[cfg(feature = "pmoserver")] #[async_trait] impl RadioParadiseExt for pmoserver::Server { async fn init_radioparadise(&mut self) -> anyhow::Result { let state = RadioParadiseState::new().await?; // Créer le router API let api_router = create_api_router(state.clone()); // L'enregistrer avec OpenAPI self.add_openapi(api_router, RadioParadiseApiDoc::openapi(), "radioparadise") .await; Ok(state) } } -------End of pmoparadise/src/pmoserver_ext.rs --------- ------------ pmoparadise/src/radio_paradise_stream_source.rs ---------- //! RadioParadiseStreamSource - Node audio pmoaudio pour Radio Paradise //! //! Ce node télécharge et décode les blocs FLAC de Radio Paradise en streaming, //! avec insertion automatique des TrackBoundary au bon timing. use crate::{ client::RadioParadiseClient, models::{Block, EventId, Song}, node_stats::NodeStats, }; use futures_util::StreamExt; use pmoaudio::{ nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS}, pipeline::{send_to_children, send_to_children_with_timing, Node, NodeLogic}, type_constraints::TypeRequirement, AudioPipelineNode, AudioSegment, SyncMarker, I24, }; use pmoflac::decode_audio_stream; use pmometadata::{MemoryTrackMetadata, TrackMetadata}; use std::{ collections::VecDeque, sync::{Arc, Mutex}, time::{Duration, Instant}, }; use tokio::io::AsyncReadExt; use tokio::sync::{mpsc, Notify, RwLock}; use tokio_util::{io::StreamReader, sync::CancellationToken}; /// Signal spécial pour indiquer qu'il n'y aura plus de blocs /// Quand ce blockid est poussé dans la queue, le source termine proprement /// après avoir fini de traiter le bloc en cours pub const END_OF_BLOCKS_SIGNAL: EventId = EventId::MAX; /// Nombre de blocs récents à mémoriser pour éviter les re-téléchargements const RECENT_BLOCKS_CACHE_SIZE: usize = 10; /// Handle pour alimenter la queue de blocs pendant que la source tourne. #[derive(Clone, Default)] pub struct BlockQueueHandle { queue: Arc>>, notify: Arc, } impl BlockQueueHandle { fn new() -> Self { Self { queue: Arc::new(Mutex::new(VecDeque::new())), notify: Arc::new(Notify::new()), } } /// Enfile un block pour traitement. pub fn enqueue(&self, event_id: EventId) { { let mut queue = self.queue.lock().expect("block queue poisoned"); queue.push_back(event_id); } self.notify.notify_one(); } /// Retire le prochain block s'il existe. fn pop(&self) -> Option { let mut queue = self.queue.lock().expect("block queue poisoned"); queue.pop_front() } /// Nombre d'éléments en attente. pub fn len(&self) -> usize { let queue = self.queue.lock().expect("block queue poisoned"); queue.len() } fn snapshot(&self) -> Vec { let queue = self.queue.lock().expect("block queue poisoned"); queue.iter().copied().collect() } fn front(&self) -> Option { let queue = self.queue.lock().expect("block queue poisoned"); queue.front().copied() } fn back(&self) -> Option { let queue = self.queue.lock().expect("block queue poisoned"); queue.back().copied() } } // ═══════════════════════════════════════════════════════════════════════════ // RadioParadiseStreamSourceLogic - Logique métier pure // ═══════════════════════════════════════════════════════════════════════════ /// Logique pure de téléchargement et décodage des blocs Radio Paradise pub struct RadioParadiseStreamSourceLogic { client: RadioParadiseClient, chunk_frames: usize, recent_blocks: VecDeque, block_queue: BlockQueueHandle, stats: Arc, } impl RadioParadiseStreamSourceLogic { pub fn new(client: RadioParadiseClient, chunk_duration_ms: u32) -> Self { let handle = BlockQueueHandle::new(); Self::with_queue(client, chunk_duration_ms, handle) } fn with_queue( client: RadioParadiseClient, chunk_duration_ms: u32, block_queue: BlockQueueHandle, ) -> Self { // Calculer chunk_frames pour la durée cible (on suppose 44.1kHz) let chunk_frames = ((chunk_duration_ms as f64 / 1000.0) * 44100.0) as usize; Self { client, chunk_frames, recent_blocks: VecDeque::with_capacity(RECENT_BLOCKS_CACHE_SIZE), block_queue, stats: NodeStats::new("RadioParadiseStreamSource"), } } /// Ajoute un block ID à la file d'attente pub fn push_block_id(&self, event_id: EventId) { self.block_queue.enqueue(event_id); } /// Vérifie si un bloc a été téléchargé récemment fn is_recent_block(&self, event_id: EventId) -> bool { self.recent_blocks.contains(&event_id) } /// Marque un bloc comme récemment téléchargé (FIFO) fn mark_block_downloaded(&mut self, event_id: EventId) { // Retirer tous les éléments excédentaires (garantit <= CACHE_SIZE) while self.recent_blocks.len() >= RECENT_BLOCKS_CACHE_SIZE { self.recent_blocks.pop_front(); } // Puis ajouter le nouveau bloc self.recent_blocks.push_back(event_id); } /// Télécharge et décode un bloc FLAC /// Retourne (timestamp_final, instant_debut) pour permettre le timing correct async fn download_and_decode_block( &mut self, block: &Block, output: &[mpsc::Sender>], stop_token: &CancellationToken, order: &mut u64, ) -> Result<(f64, Instant), AudioError> { // Télécharger le FLAC tracing::info!( "Sending HTTP GET request for block FLAC (expected duration: {:.1}min, url: {})", block.length as f64 / 60000.0, block.url ); let response = self .client .client .get(&block.url) .timeout(self.client.block_timeout) .send() .await .map_err(|e| AudioError::ProcessingError(format!("Block download failed: {}", e)))?; tracing::debug!("HTTP response received, status={}", response.status()); if !response.status().is_success() { return Err(AudioError::ProcessingError(format!( "Block download returned status {}", response.status() ))); } // Vérifier la taille du contenu si disponible if let Some(content_length) = response.content_length() { tracing::info!( "HTTP Content-Length: {} bytes ({:.1} MB)", content_length, content_length as f64 / 1_048_576.0 ); } else { tracing::warn!("HTTP response has no Content-Length header"); } // Créer un stream reader tracing::debug!("Creating byte stream reader"); let byte_stream = response .bytes_stream() .map(|result| result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))); let stream_reader = StreamReader::new(byte_stream); tracing::debug!("Stream reader created"); // Décoder le FLAC tracing::debug!("Decoding FLAC stream..."); let mut decoder = decode_audio_stream(stream_reader) .await .map_err(|e| AudioError::ProcessingError(format!("FLAC decode failed: {}", e)))?; let stream_info = decoder.info().clone(); let sample_rate = stream_info.sample_rate; let bits_per_sample = stream_info.bits_per_sample; tracing::debug!( "FLAC decoder initialized: {}Hz, {} bits/sample", sample_rate, bits_per_sample ); // Préparer les songs ordonnées pour tracking let songs = block.songs_ordered(); let mut song_index = 0; let mut total_samples = 0u64; tracing::debug!("Block has {} songs", songs.len()); // Noter l'instant de début AVANT d'envoyer TopZeroSync // Ceci permet de synchroniser la durée réelle du bloc let start_instant = Instant::now(); // Envoyer TopZeroSync au début du bloc tracing::debug!("Sending TopZeroSync to {} outputs", output.len()); let top_zero = Arc::new(AudioSegment { order: *order, timestamp_sec: 0.0, segment: pmoaudio::_AudioSegment::Sync(Arc::new(SyncMarker::TopZeroSync)), }); self.send_to_children(output, top_zero).await?; tracing::debug!("TopZeroSync sent"); // Envoyer TrackBoundary pour la première song AVANT le premier chunk audio // Même si son elapsed > 0, cela garantit que FlacCacheSink a des métadonnées // dès le début (sinon il attendrait indéfiniment un TrackBoundary) let mut next_song: Option<(usize, &Song)> = if let Some((idx, song)) = songs.get(0).copied() { tracing::debug!( "Sending TrackBoundary for first song (idx={}, elapsed={}ms) at timestamp 0", idx, song.elapsed ); let metadata = song_to_metadata(song, block).await; let track_boundary = AudioSegment::new_track_boundary( *order, 0.0, // timestamp = 0 au début du stream metadata, ); self.send_to_children(output, track_boundary).await?; song_index = 1; // Le prochain TrackBoundary sera pour la deuxième song quand elapsed_ms >= song.elapsed songs.get(1).copied() } else { None }; tracing::debug!("Starting audio chunk loop"); // Buffer pour lecture let bytes_per_sample = (bits_per_sample / 8) as usize; let frame_bytes = bytes_per_sample * 2; // stereo let chunk_frames = self.chunk_frames; let chunk_byte_len = chunk_frames * frame_bytes; let mut read_buf = vec![0u8; chunk_byte_len * 2]; let mut pending: Vec = Vec::with_capacity(chunk_byte_len * 2); // Traiter les chunks audio let mut chunk_count = 0; let mut total_bytes_decoded = 0u64; let expected_duration_sec = block.length as f64 / 1000.0; let mut stats_last_log = Instant::now(); loop { // Vérifier stop_token if stop_token.is_cancelled() { // Retourner le timestamp actuel et start_instant si on est interrompu let current_timestamp = total_samples as f64 / sample_rate as f64; tracing::warn!( "Block decode CANCELLED: sent {} chunks, {:.2}s duration ({:.1}% of expected {:.2}s), decoded {} bytes", chunk_count, current_timestamp, (current_timestamp / expected_duration_sec) * 100.0, expected_duration_sec, total_bytes_decoded ); return Ok((current_timestamp, start_instant)); } // Remplir le buffer if pending.len() < chunk_byte_len { let read = decoder .read(&mut read_buf) .await .map_err(|e| AudioError::ProcessingError(format!("Read error: {}", e)))?; if read == 0 { let actual_duration = total_samples as f64 / sample_rate as f64; let percentage = (actual_duration / expected_duration_sec) * 100.0; if percentage < 95.0 { tracing::error!( "FLAC decode EOF PREMATURE: sent {} chunks, {:.2}s actual vs {:.2}s expected ({:.1}%), decoded {} bytes", chunk_count, actual_duration, expected_duration_sec, percentage, total_bytes_decoded ); } else { tracing::info!( "FLAC decode EOF reached: sent {} chunks, {:.2}s duration ({:.1}% of expected), decoded {} bytes", chunk_count, actual_duration, percentage, total_bytes_decoded ); } break; // EOF } total_bytes_decoded += read as u64; pending.extend_from_slice(&read_buf[..read]); } if pending.is_empty() { break; } // Extraire un chunk let frames_in_pending = pending.len() / frame_bytes; let frames_to_emit = frames_in_pending.min(chunk_frames); let take_bytes = frames_to_emit * frame_bytes; let pcm_data = pending.drain(..take_bytes).collect::>(); // Calculer le nombre de frames (samples par canal) let bytes_per_sample = (bits_per_sample / 8) as usize; let chunk_len = (pcm_data.len() / (bytes_per_sample * 2)) as u64; // 2 = stereo // Vérifier si on doit insérer un TrackBoundary avant ce chunk if let Some((idx, song)) = next_song { let elapsed_ms = (total_samples * 1000) / sample_rate as u64; if elapsed_ms >= song.elapsed { // Envoyer TrackBoundary AVANT le chunk (avec le même order) tracing::debug!( "Sending TrackBoundary for song {} at elapsed_ms={} (song.elapsed={}, timestamp_sec={:.2})", idx, elapsed_ms, song.elapsed, (total_samples as f64 / sample_rate as f64) ); let metadata = song_to_metadata(song, block).await; let timestamp_sec = total_samples as f64 / sample_rate as f64; let track_boundary = AudioSegment::new_track_boundary(*order, timestamp_sec, metadata); self.send_to_children(output, track_boundary).await?; // Passer à la song suivante song_index += 1; next_song = songs.get(song_index).copied(); tracing::debug!( "Moved to next song, song_index={}, next_song present={}", song_index, next_song.is_some() ); } } // Envoyer le chunk audio let timestamp_sec = total_samples as f64 / sample_rate as f64; if stats_last_log.elapsed() >= Duration::from_secs(1) { let real_elapsed = start_instant.elapsed().as_secs_f64(); tracing::debug!( "RP timing: chunk={} ts={:.3}s real_elapsed={:.3}s delta={:.3}s chunk_len={} frames", chunk_count, timestamp_sec, real_elapsed, timestamp_sec - real_elapsed, chunk_len ); stats_last_log = Instant::now(); } let audio_segment = pcm_to_audio_segment( &pcm_data, *order, timestamp_sec, sample_rate, bits_per_sample, )?; self.send_to_children(output, audio_segment).await?; *order += 1; total_samples += chunk_len; chunk_count += 1; } // Retourner le timestamp du dernier chunk (durée totale du bloc) et l'instant de début let final_timestamp = total_samples as f64 / sample_rate as f64; tracing::debug!( "Block decode complete: {} samples, {:.2}s duration", total_samples, final_timestamp ); Ok((final_timestamp, start_instant)) } /// Envoie un segment à tous les enfants async fn send_to_children( &self, output: &[mpsc::Sender>], segment: Arc, ) -> Result<(), AudioError> { let segment_ts = segment.timestamp_sec; self.stats.record_segment_received(segment_ts); let segment_bytes = match &segment.segment { pmoaudio::_AudioSegment::Chunk(chunk) => chunk.len() * 2 * 4, _ => 0, }; send_to_children_with_timing( std::any::type_name::(), output, segment, |i, send_duration, capacity_before| { tracing::trace!( "send_to_children: Sending to child {} (channel capacity={}, timestamp={:.3}s)", i, capacity_before, segment_ts ); if send_duration.as_millis() > 10 { let duration_ms = send_duration.as_millis() as u64; self.stats.record_backpressure(duration_ms); tracing::trace!( "send_to_children: Send to child {} BLOCKED for {:.3}s (channel capacity before send={}, timestamp={:.3}s)", i, send_duration.as_secs_f64(), capacity_before, segment_ts ); } self.stats.record_segment_sent(segment_bytes); }, ) .await?; Ok(()) } } /// Convertit PCM bytes en AudioSegment fn pcm_to_audio_segment( pcm_data: &[u8], order: u64, timestamp_sec: f64, sample_rate: u32, bits_per_sample: u8, ) -> Result, AudioError> { use pmoaudio::{AudioChunk, AudioChunkData, _AudioSegment}; let bytes_per_sample = (bits_per_sample / 8) as usize; let channels = 2; // Stereo let frame_bytes = bytes_per_sample * channels; let frames = pcm_data.len() / frame_bytes; // Valider que la taille des données est correcte if pcm_data.len() % frame_bytes != 0 { return Err(AudioError::ProcessingError(format!( "Invalid PCM data size: {} bytes is not a multiple of frame size {} ({}bit, {} channels)", pcm_data.len(), frame_bytes, bits_per_sample, channels ))); } let chunk = match bits_per_sample { 16 => { // Type I16 let mut stereo = Vec::with_capacity(frames); for frame_idx in 0..frames { let base = frame_idx * frame_bytes; let left = i16::from_le_bytes([pcm_data[base], pcm_data[base + 1]]); let right = i16::from_le_bytes([pcm_data[base + 2], pcm_data[base + 3]]); stereo.push([left, right]); } let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0); AudioChunk::I16(chunk_data) } 24 => { // Type I24 avec sign extension correcte let mut stereo = Vec::with_capacity(frames); for frame_idx in 0..frames { let base = frame_idx * frame_bytes; // Left channel (bytes 0,1,2) avec sign extension let left_i32 = { let mut buf = [0u8; 4]; buf[..3].copy_from_slice(&pcm_data[base..base + 3]); // Sign extend si négatif if pcm_data[base + 2] & 0x80 != 0 { buf[3] = 0xFF; } i32::from_le_bytes(buf) }; let left = I24::new(left_i32).ok_or_else(|| { AudioError::ProcessingError(format!("Invalid I24 value: {}", left_i32)) })?; // Right channel (bytes 3,4,5) avec sign extension let right_i32 = { let mut buf = [0u8; 4]; buf[..3].copy_from_slice(&pcm_data[base + 3..base + 6]); // Sign extend si négatif if pcm_data[base + 5] & 0x80 != 0 { buf[3] = 0xFF; } i32::from_le_bytes(buf) }; let right = I24::new(right_i32).ok_or_else(|| { AudioError::ProcessingError(format!("Invalid I24 value: {}", right_i32)) })?; stereo.push([left, right]); } let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0); AudioChunk::I24(chunk_data) } 32 => { // Type I32 let mut stereo = Vec::with_capacity(frames); for frame_idx in 0..frames { let base = frame_idx * frame_bytes; let left = i32::from_le_bytes([ pcm_data[base], pcm_data[base + 1], pcm_data[base + 2], pcm_data[base + 3], ]); let right = i32::from_le_bytes([ pcm_data[base + 4], pcm_data[base + 5], pcm_data[base + 6], pcm_data[base + 7], ]); stereo.push([left, right]); } let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0); AudioChunk::I32(chunk_data) } _ => { return Err(AudioError::ProcessingError(format!( "Unsupported bit depth: {}", bits_per_sample ))) } }; Ok(Arc::new(AudioSegment { order, timestamp_sec, segment: _AudioSegment::Chunk(Arc::new(chunk)), })) } /// Convertit Song en TrackMetadata /// /// Configure toutes les métadonnées de manière asynchrone et attend que la configuration /// soit terminée avant de retourner, garantissant que les métadonnées (y compris cover_url) /// sont disponibles immédiatement pour les nodes suivants async fn song_to_metadata(song: &Song, block: &Block) -> Arc> { let metadata = MemoryTrackMetadata::new(); let metadata_arc = Arc::new(RwLock::new(metadata)) as Arc>; // Cloner les données let title = song.title.clone(); let artist = song.artist.clone(); let album = song.album.clone(); let year = song.year; let cover_url = song.cover.as_ref().and_then(|cover| block.cover_url(cover)); // Configurer les métadonnées de manière synchrone (mais async await) { let mut meta = metadata_arc.write().await; // Ces méthodes peuvent échouer (retournent Result), donc on log les erreurs if let Err(e) = meta.set_title(Some(title)).await { tracing::warn!("Failed to set title: {}", e); } if let Err(e) = meta.set_artist(Some(artist)).await { tracing::warn!("Failed to set artist: {}", e); } if let Some(album) = album { if let Err(e) = meta.set_album(Some(album)).await { tracing::warn!("Failed to set album: {}", e); } } if let Some(year) = year { if let Err(e) = meta.set_year(Some(year)).await { tracing::warn!("Failed to set year: {}", e); } } if let Some(ref url) = cover_url { tracing::debug!("RadioParadiseStreamSource: Setting cover_url to: {}", url); if let Err(e) = meta.set_cover_url(Some(url.clone())).await { tracing::warn!("Failed to set cover_url: {}", e); } else { tracing::debug!("RadioParadiseStreamSource: Successfully set cover_url"); } } else { tracing::debug!("RadioParadiseStreamSource: No cover URL available for song"); } } metadata_arc } #[async_trait::async_trait] impl NodeLogic for RadioParadiseStreamSourceLogic { async fn process( &mut self, _input: Option>>, output: Vec>>, stop_token: CancellationToken, ) -> Result<(), AudioError> { tracing::debug!( "RadioParadiseStreamSource::process() started, block_queue has {} items", self.block_queue.len() ); for (i, event_id) in self.block_queue.snapshot().iter().enumerate() { tracing::debug!(" block_queue[{}] = {}", i, event_id); } let mut order = 0u64; let mut last_timestamp = 0.0; let mut last_start_instant: Option = None; loop { // Attendre un block ID depuis la queue (pas de timeout - mode idle) tracing::debug!("Waiting for block_id from queue (idle mode, no timeout)..."); let event_id = loop { // Vérifier d'abord le stop_token if stop_token.is_cancelled() { tracing::info!("Stop token cancelled while waiting for block_id"); break None; } // Essayer de pop un event_id if let Some(id) = self.block_queue.pop() { tracing::debug!("Got event_id {} from queue", id); // Vérifier si c'est le signal de fin if id == END_OF_BLOCKS_SIGNAL { tracing::info!( "Received END_OF_BLOCKS_SIGNAL, finishing after current block" ); break None; } break Some(id); } tracing::trace!("block_queue is empty, waiting for new events..."); tokio::select! { _ = stop_token.cancelled() => break None, _ = self.block_queue.notify.notified() => {}, _ = tokio::time::sleep(Duration::from_millis(100)) => {} }; }; // Si on n'a pas d'event_id, on termine let event_id = match event_id { Some(id) => id, None => { tracing::info!("No more blocks to process, exiting loop"); break; } }; // Vérifier si déjà téléchargé récemment if self.is_recent_block(event_id) { tracing::debug!("Block {} was recently downloaded, skipping", event_id); continue; } // Récupérer les métadonnées du bloc tracing::debug!("Fetching block metadata for event_id {}...", event_id); let block = self.client.get_block(Some(event_id)).await.map_err(|e| { AudioError::ProcessingError(format!("Failed to get block: {}", e)) })?; tracing::debug!("Block metadata received: url={}", block.url); // Marquer comme téléchargé self.mark_block_downloaded(event_id); // Télécharger et décoder le bloc tracing::info!("Starting download and decode for block {}...", event_id); let (block_duration, start_instant) = self .download_and_decode_block(&block, &output, &stop_token, &mut order) .await?; last_timestamp = block_duration; last_start_instant = Some(start_instant); tracing::info!( "Finished download and decode for block {} (duration: {:.2}s)", event_id, block_duration ); } // Envoyer EndOfStream avec le timestamp du dernier chunk tracing::info!( "Sending EndOfStream with timestamp {:.2}s to {} outputs", last_timestamp, output.len() ); let eos = AudioSegment::new_end_of_stream(order, last_timestamp); send_to_children(std::any::type_name::(), &output, eos).await?; // IMPORTANT: Attendre que tous les channels soient fermés par les enfants // Cela garantit que tous les chunks (y compris ceux en attente dans les buffers MPSC) // ont été traités avant que nous ne fermions notre bout tracing::info!("Waiting for all child nodes to close their channels..."); for (i, tx) in output.iter().enumerate() { tracing::debug!("Waiting for child {} to close channel...", i); tx.closed().await; tracing::debug!("Child {} channel closed", i); } tracing::info!("All child channels closed, pipeline complete"); if let Some(start_instant) = last_start_instant { let total_elapsed = start_instant.elapsed().as_secs_f64(); tracing::info!( "Block processing complete: duration={:.2}s, total_elapsed={:.2}s ({:.1}% of real-time)", last_timestamp, total_elapsed, (total_elapsed / last_timestamp) * 100.0 ); } // Log des statistiques finales tracing::info!("\n{}", self.stats.report()); Ok(()) } } // ═══════════════════════════════════════════════════════════════════════════ // RadioParadiseStreamSource - Wrapper utilisant Node // ═══════════════════════════════════════════════════════════════════════════ pub struct RadioParadiseStreamSource { inner: Node, block_handle: BlockQueueHandle, } impl RadioParadiseStreamSource { /// Crée une nouvelle source Radio Paradise avec durée de chunk par défaut pub fn new(client: RadioParadiseClient) -> Self { Self::with_chunk_duration(client, DEFAULT_CHUNK_DURATION_MS as u32) } /// Crée une nouvelle source avec durée de chunk personnalisée pub fn with_chunk_duration(client: RadioParadiseClient, chunk_duration_ms: u32) -> Self { let handle = BlockQueueHandle::new(); let logic = RadioParadiseStreamSourceLogic::with_queue(client, chunk_duration_ms, handle.clone()); Self { inner: Node::new_source(logic), block_handle: handle, } } /// Ajoute un block ID à la file d'attente de téléchargement pub fn push_block_id(&self, event_id: EventId) { self.block_handle.enqueue(event_id); } /// Retourne un handle permettant d'enfiler des blocks dynamiquement. pub fn block_handle(&self) -> BlockQueueHandle { self.block_handle.clone() } } #[async_trait::async_trait] impl AudioPipelineNode for RadioParadiseStreamSource { fn get_tx(&self) -> Option>> { self.inner.get_tx() } fn register(&mut self, child: Box) { self.inner.register(child); } async fn run(self: Box, stop_token: CancellationToken) -> Result<(), AudioError> { Box::new(self.inner).run(stop_token).await } } impl TypedAudioNode for RadioParadiseStreamSource { fn input_type(&self) -> Option { None // Source node } fn output_type(&self) -> Option { // Radio Paradise FLAC peut être 16-bit, 24-bit, ou 32-bit // La profondeur est détectée automatiquement depuis le header FLAC Some(TypeRequirement::any_integer()) } } #[cfg(test)] mod tests { use super::*; fn create_test_client() -> RadioParadiseClient { RadioParadiseClient::with_client(reqwest::Client::new()) } #[test] fn test_cache_fifo_basic() { let client = create_test_client(); let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); // Ajouter 5 blocs for i in 1..=5 { logic.mark_block_downloaded(i); } // Vérifier que tous sont dans le cache for i in 1..=5 { assert!(logic.is_recent_block(i), "Block {} should be in cache", i); } assert_eq!(logic.recent_blocks.len(), 5); } #[test] fn test_cache_fifo_exactly_10_elements() { let client = create_test_client(); let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); // Ajouter exactement 10 blocs for i in 1..=10 { logic.mark_block_downloaded(i); } // Vérifier qu'on a exactement 10 éléments assert_eq!( logic.recent_blocks.len(), 10, "Cache should have exactly 10 elements" ); // Tous devraient être dans le cache for i in 1..=10 { assert!(logic.is_recent_block(i), "Block {} should be in cache", i); } } #[test] fn test_cache_fifo_eviction_oldest() { let client = create_test_client(); let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); // Remplir le cache avec 10 éléments (1..=10) for i in 1..=10 { logic.mark_block_downloaded(i); } // Ajouter un 11ème élément logic.mark_block_downloaded(11); // Le cache doit toujours avoir 10 éléments assert_eq!( logic.recent_blocks.len(), 10, "Cache should still have 10 elements" ); // Le premier (plus ancien) doit avoir été évincé assert!( !logic.is_recent_block(1), "Oldest block (1) should be evicted" ); // Les éléments 2..=11 doivent être présents for i in 2..=11 { assert!(logic.is_recent_block(i), "Block {} should be in cache", i); } } #[test] fn test_cache_fifo_multiple_evictions() { let client = create_test_client(); let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); // Remplir avec 10 éléments for i in 1..=10 { logic.mark_block_downloaded(i); } // Ajouter 5 éléments supplémentaires for i in 11..=15 { logic.mark_block_downloaded(i); } // Toujours 10 éléments assert_eq!( logic.recent_blocks.len(), 10, "Cache should have 10 elements" ); // Les 5 premiers doivent avoir été évincés for i in 1..=5 { assert!(!logic.is_recent_block(i), "Block {} should be evicted", i); } // Les éléments 6..=15 doivent être présents for i in 6..=15 { assert!(logic.is_recent_block(i), "Block {} should be in cache", i); } } #[test] fn test_cache_never_exceeds_capacity() { let client = create_test_client(); let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); // Vérifier la capacité pré-allouée assert_eq!(logic.recent_blocks.capacity(), RECENT_BLOCKS_CACHE_SIZE); // Ajouter beaucoup d'éléments for i in 1..=100 { logic.mark_block_downloaded(i); // À chaque itération, vérifier qu'on ne dépasse jamais 10 assert!( logic.recent_blocks.len() <= RECENT_BLOCKS_CACHE_SIZE, "Cache size {} exceeded max {}", logic.recent_blocks.len(), RECENT_BLOCKS_CACHE_SIZE ); } // Finalement, on doit avoir exactement 10 éléments assert_eq!(logic.recent_blocks.len(), 10); // Ce doivent être les 10 derniers (91..=100) for i in 91..=100 { assert!(logic.is_recent_block(i), "Block {} should be in cache", i); } } #[test] fn test_cache_fifo_order_preserved() { let client = create_test_client(); let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); // Ajouter 10 éléments for i in 1..=10 { logic.mark_block_downloaded(i); } // Vérifier l'ordre dans la VecDeque (le front devrait être le plus ancien) let front = logic.recent_blocks.front().copied(); assert_eq!(front, Some(1), "Front should be the oldest element"); let back = logic.recent_blocks.back().copied(); assert_eq!(back, Some(10), "Back should be the newest element"); } #[test] fn test_block_queue_push() { let client = create_test_client(); let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32); // Tester push_block_id logic.push_block_id(100); logic.push_block_id(200); logic.push_block_id(300); assert_eq!(logic.block_queue.len(), 3); assert_eq!(logic.block_queue.front(), Some(100)); assert_eq!(logic.block_queue.back(), Some(300)); } } -------End of pmoparadise/src/radio_paradise_stream_source.rs --------- ------------ pmoparadise/src/source.rs ---------- //! RadioParadiseSource - Implementation of MusicSource for Radio Paradise //! //! This module provides a UPnP ContentDirectory source for Radio Paradise, //! exposing live streams and historical playlists for all 4 channels. use crate::channels::{ChannelDescriptor, ALL_CHANNELS}; use pmosource::pmodidl::{Container, Item, Resource}; use pmosource::{ async_trait, AudioFormat, BrowseResult, MusicSource, MusicSourceError, Result, SourceCapabilities, }; use std::fmt; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime}; use tokio::sync::RwLock; /// Default Radio Paradise image (embedded in binary) const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); #[cfg(feature = "playlist")] const LIVE_PLAYLIST_MIN_READY_ITEMS: usize = 5; #[cfg(feature = "playlist")] const LIVE_PLAYLIST_READY_TIMEOUT: Duration = Duration::from_secs(10); #[cfg(feature = "playlist")] const LIVE_PLAYLIST_READY_POLL: Duration = Duration::from_millis(200); /// RadioParadiseSource - UPnP ContentDirectory source for Radio Paradise /// /// Provides access to: /// - Live FLAC streams for all 4 channels (Main, Mellow, Rock, Eclectic) /// - Historical playlists (FIFO) for each channel /// /// # Object ID Schema /// /// - Root: `radio-paradise` /// - Channel container: `radio-paradise:channel:{slug}` /// - Live stream item: `radio-paradise:channel:{slug}:live` /// - Live playlist container: `radio-paradise:channel:{slug}:liveplaylist` /// - Live playlist track: `radio-paradise:channel:{slug}:liveplaylist:track:{pk}` /// - History container: `radio-paradise:channel:{slug}:history` /// - History track: `radio-paradise:channel:{slug}:history:track:{pk}` #[derive(Clone)] pub struct RadioParadiseSource { /// Base URL for streaming server (e.g., "http://localhost:8080") base_url: String, /// Update counter for change notifications update_counter: Arc>, /// Last change timestamp last_change: Arc>, /// Tokens des callbacks enregistrés auprès du PlaylistManager callback_tokens: Arc>>, /// Notifier optionnel pour signaler les mises à jour de conteneurs au ContentDirectory container_notifier: Option>, } impl fmt::Debug for RadioParadiseSource { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("RadioParadiseSource") .field("base_url", &self.base_url) .finish_non_exhaustive() } } impl RadioParadiseSource { /// Create a new RadioParadiseSource /// /// # Arguments /// /// * `base_url` - Base URL for streaming server (e.g., "http://localhost:8080") /// /// # Note /// /// With the "playlist" feature enabled, this source will use the global PlaylistManager /// singleton to access history playlists. pub fn new(base_url: impl Into) -> Self { Self { base_url: base_url.into(), update_counter: Arc::new(RwLock::new(0)), last_change: Arc::new(RwLock::new(SystemTime::now())), callback_tokens: Arc::new(std::sync::Mutex::new(Vec::new())), container_notifier: None, } } /// Injecte un notifier pour propager les changements de playlists vers le ContentDirectory pub fn with_container_notifier( mut self, notifier: Arc, ) -> Self { self.container_notifier = Some(notifier); self } /// Build a live stream URL for a channel fn build_live_url(&self, slug: &str) -> String { format!("{}/radioparadise/stream/{}/flac", self.base_url, slug) } /// Build an OGG-FLAC live stream URL for clients that support it fn build_live_ogg_url(&self, slug: &str) -> String { format!("{}/radioparadise/stream/{}/ogg", self.base_url, slug) } /// Incrémente l'update_counter et met à jour last_change async fn bump_update_counter(&self) { { let mut c = self.update_counter.write().await; *c = c.wrapping_add(1).max(1); } let mut lc = self.last_change.write().await; *lc = SystemTime::now(); } /// Enregistre des callbacks sur les playlists live/historique pour notifier les changements pub fn attach_playlist_callbacks(self: &Arc) { use pmoplaylist::PlaylistManager; // Préparer les IDs de playlists à surveiller (live + history pour chaque canal) let ids: Vec = ALL_CHANNELS .iter() .flat_map(|ch| { vec![ Self::live_playlist_id(ch.slug), Self::history_playlist_id(ch.slug), ] }) .collect(); let mgr = PlaylistManager(); let mut tokens = self.callback_tokens.lock().unwrap(); for pid in ids { let weak = Arc::downgrade(self); let pid_clone = pid.clone(); let token = mgr.register_callback(move |event| { let pid = pid_clone.clone(); if event.playlist_id == pid { // On ne réagit qu'aux mises à jour structurelles (ajout/suppression) if !matches!(event.kind, pmoplaylist::PlaylistEventKind::Updated) { return; } if let Some(strong) = weak.upgrade() { tokio::spawn(async move { strong.bump_update_counter().await; // Notifier ContentDirectory des conteneurs concernés let containers: Vec = if pid.contains("history") { // history playlist -> container history ALL_CHANNELS .iter() .find(|ch| pid.ends_with(ch.slug)) .map(|ch| { vec![format!("radio-paradise:channel:{}:history", ch.slug)] }) .unwrap_or_default() } else { // live playlist -> container liveplaylist ALL_CHANNELS .iter() .find(|ch| pid.ends_with(ch.slug)) .map(|ch| { vec![format!( "radio-paradise:channel:{}:liveplaylist", ch.slug )] }) .unwrap_or_default() }; if !containers.is_empty() { if let Some(notifier) = strong.container_notifier.as_ref() { notifier(&containers); } } }); } } }); tokens.push(token); } } /// URL de fallback pour l'image par défaut de la source fn default_cover_url(&self) -> String { format!("{}/api/sources/{}/image", self.base_url, self.id()) } /// Fetch current metadata from the live stream async fn fetch_live_metadata(&self, slug: &str) -> Result> { let metadata_url = format!("{}/radioparadise/metadata/{}", self.base_url, slug); // Try to fetch metadata via HTTP match reqwest::get(&metadata_url).await { Ok(response) if response.status().is_success() => { match response.json::().await { Ok(json) => { // Parse metadata from JSON and create an Item let title = json["title"] .as_str() .unwrap_or("Unknown Title") .to_string(); let artist = json["artist"].as_str().map(|s| s.to_string()); let album = json["album"].as_str().map(|s| s.to_string()); let year = json["year"].as_u64().map(|y| y as u32); // Préférer l'URL de cache si cover_pk est fourni par le pipeline let cover_pk = json["cover_pk"].as_str().map(|s| s.to_string()); let cover_url = cover_pk .as_ref() .map(|pk| format!("{}/covers/jpeg/{}", self.base_url, pk)) .or_else(|| json["cover_url"].as_str().map(|s| s.to_string())) .or_else(|| Some(self.default_cover_url())); // Parse duration from JSON (in seconds as a float) let duration = json["duration"] .as_object() .and_then(|d| d.get("secs")) .and_then(|s| s.as_f64()) .or_else(|| json["duration"].as_f64()) .map(|secs| { let total_secs = secs as u64; format!( "{}:{:02}:{:02}", total_secs / 3600, (total_secs % 3600) / 60, total_secs % 60 ) }); // Create the item with current metadata let item = Item { id: format!("radio-paradise:channel:{}:live", slug), parent_id: format!("radio-paradise:channel:{}", slug), restricted: Some("1".to_string()), title, creator: artist.clone(), class: "object.item.audioItem.audioBroadcast".to_string(), artist, album, genre: Some("Radio".to_string()), album_art: cover_url, album_art_pk: cover_pk, date: year.map(|y| y.to_string()), original_track_number: None, resources: vec![Resource { protocol_info: "http-get:*:audio/flac:*".to_string(), bits_per_sample: None, sample_frequency: None, nr_audio_channels: Some("2".to_string()), duration, url: self.build_live_url(slug), }], descriptions: vec![], }; Ok(Some(item)) } Err(_) => Ok(None), } } _ => Ok(None), } } /// Get the playlist ID for a channel's history #[cfg(feature = "playlist")] fn history_playlist_id(slug: &str) -> String { // Must match the prefix used in ParadiseHistoryBuilder format!("radio-paradise-history-{}", slug) } /// Live playlist id for a channel fn live_playlist_id(slug: &str) -> String { format!("radio-paradise-live-{}", slug) } #[cfg(feature = "playlist")] async fn wait_for_live_playlist_ready(&self, slug: &str) -> Result<()> { let playlist_id = Self::live_playlist_id(slug); let manager = pmoplaylist::PlaylistManager(); let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { MusicSourceError::BrowseError(format!( "Failed to get live playlist {}: {}", playlist_id, e )) })?; let start = Instant::now(); loop { match reader.remaining().await { Ok(count) if count >= LIVE_PLAYLIST_MIN_READY_ITEMS => return Ok(()), Ok(_) => {} Err(e) => { return Err(MusicSourceError::BrowseError(format!( "Failed to inspect live playlist {}: {}", playlist_id, e ))); } } if start.elapsed() >= LIVE_PLAYLIST_READY_TIMEOUT { tracing::warn!( "Timeout waiting for live playlist {} to reach {} items", playlist_id, LIVE_PLAYLIST_MIN_READY_ITEMS ); return Ok(()); } tokio::time::sleep(LIVE_PLAYLIST_READY_POLL).await; } } /// Get channel descriptor by slug fn get_channel_by_slug(slug: &str) -> Option<&'static ChannelDescriptor> { ALL_CHANNELS.iter().find(|ch| ch.slug == slug) } /// Parse an object ID into its components fn parse_object_id(id: &str) -> ObjectIdType { let parts: Vec<&str> = id.split(':').collect(); match parts.as_slice() { ["radio-paradise"] => ObjectIdType::Root, ["radio-paradise", "channel", slug] => ObjectIdType::Channel { slug: (*slug).to_string(), }, ["radio-paradise", "channel", slug, "live"] => ObjectIdType::LiveStream { slug: (*slug).to_string(), }, ["radio-paradise", "channel", slug, "liveplaylist"] => ObjectIdType::LivePlaylist { slug: (*slug).to_string(), }, ["radio-paradise", "channel", slug, "liveplaylist", "track", pk] => { ObjectIdType::LivePlaylistTrack { slug: (*slug).to_string(), pk: (*pk).to_string(), } } ["radio-paradise", "channel", slug, "history"] => ObjectIdType::History { slug: (*slug).to_string(), }, ["radio-paradise", "channel", slug, "history", "track", pk] => { ObjectIdType::HistoryTrack { slug: (*slug).to_string(), pk: (*pk).to_string(), } } _ => ObjectIdType::Unknown, } } /// Build a channel container fn build_channel_container(&self, descriptor: &ChannelDescriptor) -> Container { Container { id: format!("radio-paradise:channel:{}", descriptor.slug), parent_id: "radio-paradise".to_string(), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: descriptor.display_name.to_string(), class: "object.container".to_string(), containers: vec![], items: vec![], } } /// Build the live playlist container for a channel fn build_live_playlist_container(&self, descriptor: &ChannelDescriptor) -> Container { Container { id: format!("radio-paradise:channel:{}:liveplaylist", descriptor.slug), parent_id: format!("radio-paradise:channel:{}", descriptor.slug), restricted: Some("1".to_string()), child_count: None, searchable: Some("0".to_string()), title: format!("{} - Live Playlist", descriptor.display_name), class: "object.container.playlistContainer".to_string(), containers: vec![], items: vec![], } } /// Build a live stream item for a channel fn build_live_stream_item(&self, descriptor: &ChannelDescriptor) -> Item { let stream_url = self.build_live_url(descriptor.slug); Item { id: format!("radio-paradise:channel:{}:live", descriptor.slug), parent_id: format!("radio-paradise:channel:{}", descriptor.slug), restricted: Some("1".to_string()), title: format!("{} - Live Stream", descriptor.display_name), creator: Some("Radio Paradise".to_string()), class: "object.item.audioItem.audioBroadcast".to_string(), artist: Some("Radio Paradise".to_string()), album: Some(descriptor.display_name.to_string()), genre: Some("Radio".to_string()), album_art: Some(self.default_cover_url()), album_art_pk: None, date: None, original_track_number: None, resources: vec![ Resource { protocol_info: "http-get:*:audio/flac:*".to_string(), bits_per_sample: Some("16".to_string()), sample_frequency: Some("44100".to_string()), nr_audio_channels: Some("2".to_string()), duration: None, url: stream_url.clone(), }, Resource { protocol_info: "http-get:*:audio/ogg:*".to_string(), bits_per_sample: Some("16".to_string()), sample_frequency: Some("44100".to_string()), nr_audio_channels: Some("2".to_string()), duration: None, url: self.build_live_ogg_url(descriptor.slug), }, ], descriptions: vec![], } } /// Build a history container for a channel fn build_history_container(&self, descriptor: &ChannelDescriptor) -> Container { Container { id: format!("radio-paradise:channel:{}:history", descriptor.slug), parent_id: format!("radio-paradise:channel:{}", descriptor.slug), restricted: Some("1".to_string()), child_count: None, searchable: Some("1".to_string()), title: format!("{} - History", descriptor.display_name), // Expose l'historique comme une playlist jouable class: "object.container.playlistContainer".to_string(), containers: vec![], items: vec![], } } /// Build a history container with accurate child count from playlist #[cfg(feature = "playlist")] async fn build_history_container_with_count( &self, descriptor: &ChannelDescriptor, ) -> Container { let mut container = self.build_history_container(descriptor); // Try to get actual count from playlist let playlist_id = Self::history_playlist_id(descriptor.slug); let manager = pmoplaylist::PlaylistManager(); if let Ok(reader) = manager.get_read_handle(&playlist_id).await { if let Ok(count) = reader.remaining().await { container.child_count = Some(count.to_string()); } } container } /// Get items from history playlist #[cfg(feature = "playlist")] async fn get_history_items( &self, slug: &str, _offset: usize, count: usize, ) -> Result> { let playlist_id = Self::history_playlist_id(slug); // Get read handle for the playlist from the singleton let manager = pmoplaylist::PlaylistManager(); let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { MusicSourceError::BrowseError(format!("Failed to get playlist {}: {}", playlist_id, e)) })?; // Get items from playlist (to_items starts from cursor position) let mut items = reader.to_items(count).await.map_err(|e| { MusicSourceError::BrowseError(format!("Failed to read playlist entries: {}", e)) })?; // Transform item IDs, parent_ids, and resource URLs to match Radio Paradise schema // Expected: radio-paradise:channel:{slug}:history:track:{pk} // Parent: radio-paradise:channel:{slug}:history for item in items.iter_mut() { // Extract cache_pk from the resource URL (last segment) if let Some(resource) = item.resources.first_mut() { if let Some(pk) = resource.url.split('/').last() { // Update item ID and parent ID item.id = format!("radio-paradise:channel:{}:history:track:{}", slug, pk); item.parent_id = format!("radio-paradise:channel:{}:history", slug); // Convert relative URL to absolute URL // From: /audio/flac/pk // To: http://base_url/audio/flac/pk if resource.url.starts_with('/') { resource.url = format!("{}{}", self.base_url, resource.url); } } } // Fix: Ajouter un genre par défaut si absent // Certains clients UPnP (comme gupnp-av-cp) requièrent le champ // pour parser correctement les items de classe musicTrack, même si ce champ // est optionnel selon la spec UPnP ContentDirectory. if item.genre.is_none() { item.genre = Some("Radio Paradise".to_string()); } // Normaliser l'albumArtURI : rendre absolu si chemin relatif, sinon fallback par défaut if let Some(art) = item.album_art.as_mut() { if art.starts_with('/') { *art = format!("{}{}", self.base_url, art); } } else { item.album_art = Some(self.default_cover_url()); } } Ok(items) } /// Get items from live playlist (current stream queue) #[cfg(feature = "playlist")] async fn get_live_playlist_items( &self, slug: &str, _offset: usize, count: usize, ) -> Result> { #[cfg(all(feature = "playlist", feature = "pmoaudio"))] if let Some(descriptor) = Self::get_channel_by_slug(slug) { if let Some(manager) = crate::stream_channel::get_global_channel_manager() { if let Err(e) = manager.prefetch_until_horizon(descriptor.id).await { tracing::warn!( "Failed to prefetch live playlist for {}: {}", descriptor.slug, e ); } } } #[cfg(feature = "playlist")] if let Err(e) = self.wait_for_live_playlist_ready(slug).await { tracing::warn!( "Failed to wait for live playlist readiness on {}: {}", slug, e ); } let playlist_id = Self::live_playlist_id(slug); let manager = pmoplaylist::PlaylistManager(); let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { MusicSourceError::BrowseError(format!( "Failed to get live playlist {}: {}", playlist_id, e )) })?; let mut items = reader.to_items(count).await.map_err(|e| { MusicSourceError::BrowseError(format!("Failed to read live playlist entries: {}", e)) })?; for item in items.iter_mut() { // Ajuster id/parent/url pour coller au schéma Radio Paradise if let Some(resource) = item.resources.first_mut() { if let Some(pk) = resource.url.split('/').last() { item.id = format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk); item.parent_id = format!("radio-paradise:channel:{}:liveplaylist", slug); if resource.url.starts_with('/') { resource.url = format!("{}{}", self.base_url, resource.url); } } } if item.genre.is_none() { item.genre = Some("Radio Paradise".to_string()); } if let Some(art) = item.album_art.as_mut() { if art.starts_with('/') { *art = format!("{}{}", self.base_url, art); } } else { item.album_art = Some(self.default_cover_url()); } } Ok(items) } /// Get a single item from the live playlist by pk #[cfg(feature = "playlist")] async fn get_live_playlist_item(&self, slug: &str, pk: &str) -> Result { let items = self.get_live_playlist_items(slug, 0, 1000).await?; let expected_id = format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk); for item in items { if item.id == expected_id { return Ok(item); } } Err(MusicSourceError::ObjectNotFound(format!( "Track with pk {} not found in live playlist", pk ))) } } /// Types of object IDs in the Radio Paradise source #[derive(Debug, Clone, PartialEq)] enum ObjectIdType { Root, Channel { slug: String }, LiveStream { slug: String }, LivePlaylist { slug: String }, LivePlaylistTrack { slug: String, pk: String }, History { slug: String }, HistoryTrack { slug: String, pk: String }, Unknown, } #[async_trait] impl MusicSource for RadioParadiseSource { fn name(&self) -> &str { "Radio Paradise" } fn id(&self) -> &str { "radio-paradise" } fn default_image(&self) -> &[u8] { DEFAULT_IMAGE } async fn root_container(&self) -> Result { Ok(Container { id: "radio-paradise".to_string(), parent_id: "0".to_string(), restricted: Some("1".to_string()), // childCount retiré pour éviter les soucis de compatibilité côté CP child_count: None, searchable: Some("1".to_string()), title: "Radio Paradise".to_string(), class: "object.container".to_string(), containers: vec![], items: vec![], }) } async fn browse(&self, object_id: &str) -> Result { match Self::parse_object_id(object_id) { ObjectIdType::Root => { // Return the 4 channel containers let containers: Vec = ALL_CHANNELS .iter() .map(|ch| self.build_channel_container(ch)) .collect(); Ok(BrowseResult::Containers(containers)) } ObjectIdType::Channel { slug } => { // Return live stream item + history container let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) })?; let live_item = self.build_live_stream_item(descriptor); let live_playlist_container = self.build_live_playlist_container(descriptor); #[cfg(feature = "playlist")] let history_container = self.build_history_container_with_count(descriptor).await; #[cfg(not(feature = "playlist"))] let history_container = self.build_history_container(descriptor); Ok(BrowseResult::Mixed { containers: vec![live_playlist_container, history_container], items: vec![live_item], }) } ObjectIdType::History { slug } => { // Return history container (for BrowseMetadata) and items (for BrowseDirectChildren) // The content_handler will filter out the container when browsing direct children let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) })?; #[cfg(feature = "playlist")] { let history_container = self.build_history_container_with_count(descriptor).await; let items = self.get_history_items(&slug, 0, 100).await?; Ok(BrowseResult::Mixed { containers: vec![history_container], items, }) } #[cfg(not(feature = "playlist"))] { // If playlist feature is disabled, return just the container let history_container = self.build_history_container(descriptor); Ok(BrowseResult::Containers(vec![history_container])) } } ObjectIdType::LiveStream { slug } => { // Return metadata for the live stream item let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) })?; let item = self.build_live_stream_item(descriptor); Ok(BrowseResult::Items(vec![item])) } ObjectIdType::LivePlaylist { slug } => { // Playlist du live : container + items let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) })?; #[cfg(feature = "playlist")] { let container = self.build_live_playlist_container(descriptor); let items = self.get_live_playlist_items(&slug, 0, 100).await?; Ok(BrowseResult::Mixed { containers: vec![container], items, }) } #[cfg(not(feature = "playlist"))] { let container = self.build_live_playlist_container(descriptor); Ok(BrowseResult::Containers(vec![container])) } } ObjectIdType::HistoryTrack { slug: _, pk: _ } => { // Return metadata for the history track item let item = self.get_item(object_id).await?; Ok(BrowseResult::Items(vec![item])) } ObjectIdType::LivePlaylistTrack { slug, pk } => { // Détails d'un titre du live (playlist live) #[cfg(feature = "playlist")] { let item = self.get_live_playlist_item(&slug, &pk).await?; Ok(BrowseResult::Items(vec![item])) } #[cfg(not(feature = "playlist"))] { let _ = (slug, pk); Err(MusicSourceError::NotSupported( "Playlist feature not enabled".to_string(), )) } } ObjectIdType::Unknown => Err(MusicSourceError::ObjectNotFound(format!( "Unknown object ID: {}", object_id ))), } } async fn resolve_uri(&self, object_id: &str) -> Result { match Self::parse_object_id(object_id) { ObjectIdType::LiveStream { slug } => { // Return live stream URL Ok(self.build_live_url(&slug)) } ObjectIdType::HistoryTrack { pk, .. } => { // Return cached audio URL Ok(format!("{}/cache/audio/{}", self.base_url, pk)) } ObjectIdType::LivePlaylistTrack { pk, .. } => { // Return cached audio URL Ok(format!("{}/cache/audio/{}", self.base_url, pk)) } _ => Err(MusicSourceError::ObjectNotFound(format!( "Cannot resolve URI for object: {}", object_id ))), } } fn capabilities(&self) -> SourceCapabilities { SourceCapabilities { supports_fifo: self.supports_fifo(), supports_search: false, supports_favorites: false, supports_playlists: false, supports_user_content: false, supports_high_res_audio: true, max_sample_rate: Some(44100), supports_multiple_formats: true, supports_advanced_search: false, supports_pagination: false, } } async fn get_available_formats(&self, object_id: &str) -> Result> { match Self::parse_object_id(object_id) { ObjectIdType::LiveStream { .. } => Ok(vec![ AudioFormat { format_id: "flac".to_string(), mime_type: "audio/flac".to_string(), sample_rate: Some(44100), bit_depth: Some(16), bitrate: None, channels: Some(2), }, AudioFormat { format_id: "ogg-flac".to_string(), mime_type: "audio/ogg".to_string(), sample_rate: Some(44100), bit_depth: Some(16), bitrate: None, channels: Some(2), }, ]), ObjectIdType::HistoryTrack { .. } => Ok(vec![AudioFormat { format_id: "flac".to_string(), mime_type: "audio/flac".to_string(), sample_rate: Some(44100), bit_depth: Some(16), bitrate: None, channels: Some(2), }]), ObjectIdType::LivePlaylistTrack { .. } => Ok(vec![AudioFormat { format_id: "flac".to_string(), mime_type: "audio/flac".to_string(), sample_rate: Some(44100), bit_depth: Some(16), bitrate: None, channels: Some(2), }]), _ => Err(MusicSourceError::ObjectNotFound(format!( "Cannot list formats for object: {}", object_id ))), } } async fn get_item(&self, object_id: &str) -> Result { match Self::parse_object_id(object_id) { ObjectIdType::LiveStream { slug } => { // Try to fetch current metadata from live stream if let Ok(Some(item)) = self.fetch_live_metadata(&slug).await { return Ok(item); } // Fallback to static item if metadata fetch fails let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) })?; Ok(self.build_live_stream_item(descriptor)) } ObjectIdType::HistoryTrack { slug, pk } => { // Get from history playlist #[cfg(feature = "playlist")] { let playlist_id = Self::history_playlist_id(&slug); let manager = pmoplaylist::PlaylistManager(); let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { MusicSourceError::BrowseError(format!( "Failed to get playlist {}: {}", playlist_id, e )) })?; // Try to find the item with this pk let items = reader.to_items(1000).await.map_err(|e| { MusicSourceError::BrowseError(format!( "Failed to read playlist entries: {}", e )) })?; // Ajuster les IDs/parent_id/URL pour coller au schéma Radio Paradise, // comme dans get_history_items. let mut adjusted = Vec::new(); for mut item in items { if let Some(resource) = item.resources.first_mut() { if let Some(pk2) = resource.url.split('/').last() { item.id = format!( "radio-paradise:channel:{}:history:track:{}", slug, pk2 ); item.parent_id = format!("radio-paradise:channel:{}:history", slug); if resource.url.starts_with('/') { resource.url = format!("{}{}", self.base_url, resource.url); } } } if item.genre.is_none() { item.genre = Some("Radio Paradise".to_string()); } adjusted.push(item); } // Find the item matching this pk in the item ID let expected_id = format!("radio-paradise:channel:{}:history:track:{}", slug, pk); for item in adjusted { if item.id == expected_id { return Ok(item); } } Err(MusicSourceError::ObjectNotFound(format!( "Track with pk {} not found in history", pk ))) } #[cfg(not(feature = "playlist"))] { let _ = (slug, pk); Err(MusicSourceError::NotSupported( "Playlist feature not enabled".to_string(), )) } } ObjectIdType::LivePlaylistTrack { slug, pk } => { #[cfg(feature = "playlist")] { let playlist_id = Self::live_playlist_id(&slug); let manager = pmoplaylist::PlaylistManager(); let reader = manager.get_read_handle(&playlist_id).await.map_err(|e| { MusicSourceError::BrowseError(format!( "Failed to get live playlist {}: {}", playlist_id, e )) })?; let items = reader.to_items(1000).await.map_err(|e| { MusicSourceError::BrowseError(format!( "Failed to read live playlist entries: {}", e )) })?; for mut item in items { if let Some(resource) = item.resources.first_mut() { if let Some(pk2) = resource.url.split('/').last() { item.id = format!( "radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk2 ); item.parent_id = format!("radio-paradise:channel:{}:liveplaylist", slug); if resource.url.starts_with('/') { resource.url = format!("{}{}", self.base_url, resource.url); } } } if item.genre.is_none() { item.genre = Some("Radio Paradise".to_string()); } if let Some(art) = item.album_art.as_mut() { if art.starts_with('/') { *art = format!("{}{}", self.base_url, art); } } else { item.album_art = Some(self.default_cover_url()); } let expected_id = format!("radio-paradise:channel:{}:liveplaylist:track:{}", slug, pk); if item.id == expected_id { return Ok(item); } } Err(MusicSourceError::ObjectNotFound(format!( "Track with pk {} not found in live playlist", pk ))) } #[cfg(not(feature = "playlist"))] { let _ = (slug, pk); Err(MusicSourceError::NotSupported( "Playlist feature not enabled".to_string(), )) } } _ => Err(MusicSourceError::ObjectNotFound(format!( "Cannot get item for object: {}", object_id ))), } } fn supports_fifo(&self) -> bool { // History playlists are FIFO cfg!(feature = "playlist") } async fn append_track(&self, _track: Item) -> Result<()> { // Tracks are added automatically by FlacCacheSink Err(MusicSourceError::NotSupported( "Tracks are automatically added to history by the streaming system".to_string(), )) } async fn remove_oldest(&self) -> Result> { // Managed automatically by playlist FIFO Ok(None) } async fn update_id(&self) -> u32 { *self.update_counter.read().await } async fn last_change(&self) -> Option { Some(*self.last_change.read().await) } async fn get_items(&self, offset: usize, count: usize) -> Result> { // For Radio Paradise, we don't have a global FIFO // Each channel has its own history // Return empty for now - clients should browse specific channel histories let _ = (offset, count); Ok(vec![]) } } -------End of pmoparadise/src/source.rs --------- ------------ pmoparadise/src/stream_channel_old.rs ---------- use std::{ collections::HashMap, pin::Pin, sync::{ atomic::{AtomicUsize, Ordering}, Arc, }, task::{Context, Poll}, time::Duration, }; use crate::{ channels::{ChannelDescriptor, ParadiseChannelKind, ALL_CHANNELS}, client::RadioParadiseClient, radio_paradise_stream_source::RadioParadiseStreamSource, }; use anyhow::{anyhow, Result}; use pmoaudio::{nodes::DEFAULT_CHANNEL_SIZE, AudioPipelineNode}; use pmoaudio_ext::{ FlacCacheSink, FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, OggFlacStreamHandle, PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink, TrackBoundaryCoverNode, StreamingSinkOptions, }; use pmoaudiocache::Cache as AudioCache; use pmocovers::Cache as CoverCache; use pmoflac::EncoderOptions; use pmoplaylist::WriteHandle; use thiserror::Error; use tokio::io::{AsyncRead, ReadBuf}; use tokio::sync::Notify; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; /// Configuration pour un canal Radio Paradise. #[derive(Clone, Debug)] pub struct ParadiseStreamChannelConfig { /// Durée maximale (en secondes) d'avance acceptée par le broadcast. pub max_lead_seconds: f64, pub flac_options: StreamingSinkOptions, pub ogg_options: StreamingSinkOptions, pub server_base_url: Option, } impl Default for ParadiseStreamChannelConfig { fn default() -> Self { Self { max_lead_seconds: 1.0, flac_options: StreamingSinkOptions::flac_defaults(), ogg_options: StreamingSinkOptions::ogg_defaults(), server_base_url: None, } } } /// Options pour activer l'archivage/historique d'un canal. pub struct ParadiseHistoryOptions { pub audio_cache: Arc, pub cover_cache: Arc, pub playlist_id: String, pub playlist_writer: WriteHandle, pub collection: Option, pub replay_max_lead_seconds: f64, } /// Builder pratique pour configurer automatiquement les playlists historiques. #[derive(Clone)] pub struct ParadiseHistoryBuilder { pub audio_cache: Arc, pub cover_cache: Arc, pub playlist_prefix: String, pub playlist_title_prefix: Option, pub max_history_tracks: Option, pub collection_prefix: Option, pub replay_max_lead_seconds: f64, } impl ParadiseHistoryBuilder { pub fn new(audio_cache: Arc, cover_cache: Arc) -> Self { Self { audio_cache, cover_cache, playlist_prefix: "radio-paradise-history".into(), playlist_title_prefix: Some("Radio Paradise History".into()), max_history_tracks: Some(500), collection_prefix: Some("radio-paradise".into()), replay_max_lead_seconds: 1.0, } } pub async fn build_for_channel( &self, descriptor: &ChannelDescriptor, ) -> Result { let playlist_id = format!("{}-{}", self.playlist_prefix, descriptor.slug); let manager = pmoplaylist::PlaylistManager(); let writer = manager .get_persistent_write_handle(playlist_id.clone()) .await?; if let Some(prefix) = &self.playlist_title_prefix { let title = format!("{} - {}", prefix, descriptor.display_name); writer.set_title(title).await?; } if let Some(capacity) = self.max_history_tracks { writer.set_capacity(Some(capacity)).await?; } let collection = self .collection_prefix .as_ref() .map(|prefix| format!("{}-{}", prefix, descriptor.slug)); Ok(ParadiseHistoryOptions { audio_cache: self.audio_cache.clone(), cover_cache: self.cover_cache.clone(), playlist_id, playlist_writer: writer, collection, replay_max_lead_seconds: self.replay_max_lead_seconds, }) } } struct HistoryState { playlist_id: String, audio_cache: Arc, replay_max_lead_seconds: f64, } #[cfg(feature = "pmoconfig")] impl ParadiseStreamChannelConfig { pub fn from_config(cfg: &pmoconfig::Config, channel: ParadiseChannelKind) -> Self { use serde_yaml::Value; let path = [ "sources", "radio_paradise", "channels", channel.slug(), "max_lead_seconds", ]; match cfg.get_value(&path) { Ok(Value::Number(num)) => { if let Some(v) = num.as_f64() { Self { max_lead_seconds: v.max(0.1), flac_options: StreamingSinkOptions::flac_defaults(), ogg_options: StreamingSinkOptions::ogg_defaults(), server_base_url: None, } } else { let default = Self::default(); let _ = cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); default } } Ok(Value::String(s)) => { if let Ok(v) = s.parse::() { Self { max_lead_seconds: v.max(0.1), flac_options: StreamingSinkOptions::flac_defaults(), ogg_options: StreamingSinkOptions::ogg_defaults(), server_base_url: None, } } else { let default = Self::default(); let _ = cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); default } } _ => { let default = Self::default(); let _ = cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); default } } } } /// Stream complet (FLAC pur + OGG-FLAC) pour un canal Radio Paradise. pub struct ParadiseStreamChannel { descriptor: ChannelDescriptor, state: Arc, pipeline_handle: JoinHandle<()>, feeder_handle: JoinHandle<()>, history: Option, } impl ParadiseStreamChannel { /// Crée un canal avec client déjà configuré. pub fn with_client( descriptor: ChannelDescriptor, client: RadioParadiseClient, config: ParadiseStreamChannelConfig, cover_cache: Option>, history: Option, ) -> Self { let mut source = RadioParadiseStreamSource::new(client.clone()); let block_handle = source.block_handle(); let (flac_sink, stream_handle) = StreamingFlacSink::with_options( EncoderOptions::default(), 16, config.max_lead_seconds, config.flac_options.clone(), ); let (ogg_sink, ogg_handle) = StreamingOggFlacSink::with_options( EncoderOptions::default(), 16, config.max_lead_seconds, config.ogg_options.clone(), ); let mut downstream_children: Vec> = Vec::new(); downstream_children.push(Box::new(flac_sink)); downstream_children.push(Box::new(ogg_sink)); let mut history_state = None; if let Some(history_opts) = history { let ParadiseHistoryOptions { audio_cache, cover_cache, playlist_id, playlist_writer, collection, replay_max_lead_seconds, } = history_opts; let mut cache_sink = FlacCacheSink::with_config( audio_cache.clone(), cover_cache, DEFAULT_CHANNEL_SIZE, EncoderOptions::default(), collection, ); cache_sink.register_playlist(playlist_writer); downstream_children.push(Box::new(cache_sink)); history_state = Some(HistoryState { playlist_id, audio_cache, replay_max_lead_seconds, }); } if let Some(cache) = cover_cache { let mut cover_node = TrackBoundaryCoverNode::new(cache); for child in downstream_children { cover_node.register(child); } source.register(Box::new(cover_node)); } else { for child in downstream_children { source.register(child); } } stream_handle.set_auto_stop(false); ogg_handle.set_auto_stop(false); let stop_token = CancellationToken::new(); let pipeline_stop = stop_token.clone(); let pipeline_handle = tokio::spawn(async move { info!( "RadioParadise stream pipeline started for channel {}", descriptor.display_name ); if let Err(e) = Box::new(source).run(pipeline_stop).await { error!( "Pipeline error for channel {}: {}", descriptor.display_name, e ); } }); let state = Arc::new(ChannelState { descriptor, config, client, block_handle, stream_handle, ogg_handle, active_clients: AtomicUsize::new(0), activity_notify: Notify::new(), stop_token, }); let feeder_state = state.clone(); let feeder_handle = tokio::spawn(async move { feeder_state.run_scheduler().await; }); Self { descriptor, state, pipeline_handle, feeder_handle, history: history_state, } } /// Crée un canal en construisant automatiquement le client pour ce descriptor. pub async fn new( descriptor: ChannelDescriptor, config: ParadiseStreamChannelConfig, cover_cache: Option>, history: Option, ) -> Result { let client = RadioParadiseClient::builder() .channel(descriptor.id) .build() .await?; Ok(Self::with_client( descriptor, client, config, cover_cache, history, )) } /// S'abonne au flux FLAC pur. pub fn subscribe_flac(&self) -> ChannelFlacStream { self.state.on_client_added(); let inner = self.state.stream_handle.subscribe_flac(); ChannelFlacStream::new(inner, self.state.clone()) } /// S'abonne au flux FLAC + ICY metadata. pub fn subscribe_icy(&self) -> ChannelIcyStream { self.state.on_client_added(); let inner = self.state.stream_handle.subscribe_icy(); ChannelIcyStream::new(inner, self.state.clone()) } /// S'abonne au flux OGG-FLAC. pub fn subscribe_ogg(&self) -> ChannelOggStream { self.state.on_client_added(); let inner = self.state.ogg_handle.subscribe(); ChannelOggStream::new(inner, self.state.clone()) } /// Snapshot des métadonnées actuelles. pub async fn metadata(&self) -> MetadataSnapshot { self.state.stream_handle.get_metadata().await } /// Nombre de clients actifs. pub fn active_clients(&self) -> usize { self.state.active_clients.load(Ordering::SeqCst) } pub fn descriptor(&self) -> ChannelDescriptor { self.descriptor } /// Lance un pipeline dédié pour rejouer l'historique (FLAC pur) pour un client. pub async fn stream_history_flac( &self, client_id: &str, ) -> Result { let history = self .history .as_ref() .ok_or(HistoryStreamError::HistoryDisabled)?; tracing::info!( "Starting historical FLAC replay for channel {} (client_id={})", self.descriptor.display_name, client_id ); let reader = pmoplaylist::PlaylistManager() .get_read_handle(&history.playlist_id) .await .map_err(|e| HistoryStreamError::Playlist(e.to_string()))?; let mut source = PlaylistSource::new(reader, history.audio_cache.clone()); let (flac_sink, handle) = StreamingFlacSink::with_options( EncoderOptions::default(), 16, history.replay_max_lead_seconds, self.state.config.flac_options.clone(), ); source.register(Box::new(flac_sink)); let stop_token = CancellationToken::new(); let mut pipeline_source = source; let stop_clone = stop_token.clone(); let pipeline = tokio::spawn(async move { let _ = Box::new(pipeline_source).run(stop_clone).await; }); let stream = handle.subscribe_flac(); Ok(HistoryFlacStream::new(stream, stop_token, pipeline)) } /// Lance un pipeline dédié pour rejouer l'historique (OGG-FLAC) pour un client. pub async fn stream_history_ogg( &self, client_id: &str, ) -> Result { let history = self .history .as_ref() .ok_or(HistoryStreamError::HistoryDisabled)?; tracing::info!( "Starting historical OGG replay for channel {} (client_id={})", self.descriptor.display_name, client_id ); let reader = pmoplaylist::PlaylistManager() .get_read_handle(&history.playlist_id) .await .map_err(|e| HistoryStreamError::Playlist(e.to_string()))?; let mut source = PlaylistSource::new(reader, history.audio_cache.clone()); let (ogg_sink, handle) = StreamingOggFlacSink::with_options( EncoderOptions::default(), 16, history.replay_max_lead_seconds, self.state.config.ogg_options.clone(), ); source.register(Box::new(ogg_sink)); let stop_token = CancellationToken::new(); let mut pipeline_source = source; let stop_clone = stop_token.clone(); let pipeline = tokio::spawn(async move { let _ = Box::new(pipeline_source).run(stop_clone).await; }); let stream = handle.subscribe(); Ok(HistoryOggStream::new(stream, stop_token, pipeline)) } } impl Drop for ParadiseStreamChannel { fn drop(&mut self) { self.state.stop_token.cancel(); self.pipeline_handle.abort(); self.feeder_handle.abort(); } } struct ChannelState { descriptor: ChannelDescriptor, config: ParadiseStreamChannelConfig, client: RadioParadiseClient, block_handle: crate::radio_paradise_stream_source::BlockQueueHandle, stream_handle: StreamHandle, ogg_handle: OggFlacStreamHandle, active_clients: AtomicUsize, activity_notify: Notify, stop_token: CancellationToken, } impl ChannelState { fn on_client_added(&self) { if self.active_clients.fetch_add(1, Ordering::SeqCst) == 0 { self.activity_notify.notify_one(); } } fn on_client_removed(&self) { self.active_clients.fetch_sub(1, Ordering::SeqCst); } async fn wait_for_clients(&self) -> bool { while self.active_clients.load(Ordering::SeqCst) == 0 { tokio::select! { _ = self.stop_token.cancelled() => return false, _ = self.activity_notify.notified() => {}, } } true } async fn run_scheduler(self: Arc) { let mut backoff = Duration::from_secs(5); loop { if self.stop_token.is_cancelled() { break; } if !self.wait_for_clients().await { break; } match self.client.get_block(None).await { Ok(block) => { info!( "Channel {} streaming block {}", self.descriptor.display_name, block.event ); self.block_handle.enqueue(block.event); let mut next_event = block.end_event; loop { if self.stop_token.is_cancelled() { return; } if self.active_clients.load(Ordering::SeqCst) == 0 { break; } match self.client.get_block(Some(next_event)).await { Ok(next_block) => { self.block_handle.enqueue(next_block.event); next_event = next_block.end_event; backoff = Duration::from_secs(5); } Err(e) => { warn!( "Failed to fetch next block for channel {}: {}", self.descriptor.display_name, e ); tokio::select! { _ = self.stop_token.cancelled() => return, _ = tokio::time::sleep(backoff) => {}, } backoff = (backoff * 2).min(Duration::from_secs(60)); } } } } Err(e) => { warn!( "Failed to fetch current block for channel {}: {}", self.descriptor.display_name, e ); tokio::select! { _ = self.stop_token.cancelled() => break, _ = tokio::time::sleep(backoff) => {}, } backoff = (backoff * 2).min(Duration::from_secs(60)); } } } } } macro_rules! wrap_stream { ($name:ident, $inner:ty) => { pub struct $name { inner: $inner, state: Arc, } impl $name { fn new(inner: $inner, state: Arc) -> Self { Self { inner, state } } } impl AsyncRead for $name { fn poll_read( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { Pin::new(&mut self.inner).poll_read(cx, buf) } } impl Drop for $name { fn drop(&mut self) { self.state.on_client_removed(); } } }; } wrap_stream!(ChannelFlacStream, FlacClientStream); wrap_stream!(ChannelIcyStream, IcyClientStream); wrap_stream!(ChannelOggStream, OggFlacClientStream); #[derive(Debug, Error)] pub enum HistoryStreamError { #[error("history replay not enabled for this channel")] HistoryDisabled, #[error("playlist error: {0}")] Playlist(String), } pub struct HistoryFlacStream { inner: FlacClientStream, stop_token: CancellationToken, pipeline: Option>, } impl HistoryFlacStream { fn new( inner: FlacClientStream, stop_token: CancellationToken, pipeline: JoinHandle<()>, ) -> Self { Self { inner, stop_token, pipeline: Some(pipeline), } } } impl AsyncRead for HistoryFlacStream { fn poll_read( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { Pin::new(&mut self.inner).poll_read(cx, buf) } } impl Unpin for HistoryFlacStream {} impl Drop for HistoryFlacStream { fn drop(&mut self) { self.stop_token.cancel(); if let Some(handle) = self.pipeline.take() { handle.abort(); } } } pub struct HistoryOggStream { inner: OggFlacClientStream, stop_token: CancellationToken, pipeline: Option>, } impl HistoryOggStream { fn new( inner: OggFlacClientStream, stop_token: CancellationToken, pipeline: JoinHandle<()>, ) -> Self { Self { inner, stop_token, pipeline: Some(pipeline), } } } impl AsyncRead for HistoryOggStream { fn poll_read( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { Pin::new(&mut self.inner).poll_read(cx, buf) } } impl Unpin for HistoryOggStream {} impl Drop for HistoryOggStream { fn drop(&mut self) { self.stop_token.cancel(); if let Some(handle) = self.pipeline.take() { handle.abort(); } } } /// Gestionnaire multi-canaux. pub struct ParadiseChannelManager { channels: HashMap>, } impl ParadiseChannelManager { pub fn new(channels: HashMap>) -> Self { Self { channels } } pub async fn with_defaults_with_cover_cache( cover_cache: Option>, history_builder: Option, server_base_url: Option, ) -> Result { let mut map = HashMap::new(); for descriptor in ALL_CHANNELS.iter().copied() { let mut config = ParadiseStreamChannelConfig::default(); config.server_base_url = server_base_url.clone(); let history_opts = if let Some(builder) = &history_builder { Some( builder .build_for_channel(&descriptor) .await .map_err(|e| anyhow!("Failed to init history playlist: {}", e))?, ) } else { None }; let channel = ParadiseStreamChannel::new( descriptor, config, cover_cache.clone(), history_opts, ) .await?; map.insert(descriptor.id, Arc::new(channel)); } Ok(Self { channels: map }) } pub async fn with_defaults() -> Result { Self::with_defaults_with_cover_cache(None, None, None).await } pub fn get(&self, id: u8) -> Option> { self.channels.get(&id).cloned() } pub fn iter(&self) -> impl Iterator> { self.channels.values() } } -------End of pmoparadise/src/stream_channel_old.rs --------- ------------ pmoparadise/src/stream_channel.rs ---------- //! Version simplifiée de stream_channel.rs utilisant RadioParadisePlaylistFeeder + PlaylistSource //! //! Cette version remplace l'architecture complexe RadioParadiseStreamSource par : //! - RadioParadisePlaylistFeeder : télécharge les URLs gapless et alimente une playlist //! - PlaylistSource::with_history() : lit la playlist et gère l'historique automatiquement use std::{ collections::HashMap, pin::Pin, sync::{ atomic::{AtomicUsize, Ordering}, Arc, }, task::{Context, Poll}, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use crate::{ channels::{ChannelDescriptor, ParadiseChannelKind, ALL_CHANNELS}, client::RadioParadiseClient, models::{Block, EventId}, playlist_feeder::RadioParadisePlaylistFeeder, }; use anyhow::{anyhow, Context as AnyhowContext, Result}; use once_cell::sync::OnceCell; use pmoaudio::{AudioError, AudioPipelineNode}; use pmoaudio_ext::{ FlacClientStream, IcyClientStream, MetadataSnapshot, OggFlacClientStream, OggFlacStreamHandle, PlaylistSource, StreamHandle, StreamingFlacSink, StreamingOggFlacSink, StreamingSinkOptions, TrackBoundaryCoverNode, }; use pmoaudiocache::{get_audio_cache, Cache as AudioCache}; use pmocovers::{get_cover_cache, Cache as CoverCache}; use pmoflac::EncoderOptions; use pmoplaylist::PlaylistManager; use thiserror::Error; use tokio::io::{AsyncRead, ReadBuf}; use tokio::sync::{Mutex, Notify}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; /// Configuration pour un canal Radio Paradise. #[derive(Clone, Debug)] pub struct ParadiseStreamChannelConfig { /// Durée maximale (en secondes) d'avance acceptée par le broadcast. pub max_lead_seconds: f64, /// Options pour le flux FLAC pur. pub flac_options: StreamingSinkOptions, /// Options pour le flux OGG-FLAC. pub ogg_options: StreamingSinkOptions, /// URL de base du serveur (pour les métadonnées, covers...) pub server_base_url: Option, } impl Default for ParadiseStreamChannelConfig { fn default() -> Self { Self { max_lead_seconds: 3.0, // Compromis live/fluidité : assez pour absorber les transitions flac_options: StreamingSinkOptions::flac_defaults(), ogg_options: StreamingSinkOptions::ogg_defaults(), server_base_url: None, } } } /// Options pour activer l'archivage/historique d'un canal. pub struct ParadiseHistoryOptions { pub audio_cache: Arc, pub cover_cache: Arc, pub playlist_id: String, pub collection: Option, pub replay_max_lead_seconds: f64, pub max_history_tracks: Option, } /// Builder pratique pour configurer automatiquement les playlists historiques. #[derive(Clone)] pub struct ParadiseHistoryBuilder { pub audio_cache: Arc, pub cover_cache: Arc, pub playlist_prefix: String, pub playlist_title_prefix: Option, pub max_history_tracks: Option, pub collection_prefix: Option, pub replay_max_lead_seconds: f64, } impl ParadiseHistoryBuilder { pub fn new(audio_cache: Arc, cover_cache: Arc) -> Self { Self { audio_cache, cover_cache, playlist_prefix: "radio-paradise-history".into(), playlist_title_prefix: Some("Radio Paradise History".into()), max_history_tracks: Some(500), collection_prefix: Some("radio-paradise".into()), replay_max_lead_seconds: 3.0, // Aligné avec le live } } pub async fn build_for_channel( &self, descriptor: &ChannelDescriptor, ) -> Result { let playlist_id = format!("{}-{}", self.playlist_prefix, descriptor.slug); let collection = self .collection_prefix .as_ref() .map(|prefix| format!("{}-{}", prefix, descriptor.slug)); Ok(ParadiseHistoryOptions { audio_cache: self.audio_cache.clone(), cover_cache: self.cover_cache.clone(), playlist_id, collection, replay_max_lead_seconds: self.replay_max_lead_seconds, max_history_tracks: self.max_history_tracks, }) } } impl Default for ParadiseHistoryBuilder { fn default() -> Self { let audio_cache = get_audio_cache() .expect("pmoaudiocache::register_audio_cache must be called before using ParadiseHistoryBuilder::default()"); let cover_cache = get_cover_cache() .expect("pmocovers::register_cover_cache must be called before using ParadiseHistoryBuilder::default()"); Self::new(audio_cache, cover_cache) } } #[cfg(feature = "pmoconfig")] impl ParadiseStreamChannelConfig { pub fn from_config(cfg: &pmoconfig::Config, channel: ParadiseChannelKind) -> Self { use serde_yaml::Value; let path = [ "sources", "radio_paradise", "channels", channel.slug(), "max_lead_seconds", ]; match cfg.get_value(&path) { Ok(Value::Number(num)) => { if let Some(v) = num.as_f64() { Self { max_lead_seconds: v.max(0.1), flac_options: StreamingSinkOptions::flac_defaults(), ogg_options: StreamingSinkOptions::ogg_defaults(), server_base_url: None, } } else { let default = Self::default(); let _ = cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); default } } Ok(Value::String(s)) => { if let Ok(v) = s.parse::() { Self { max_lead_seconds: v.max(0.1), flac_options: StreamingSinkOptions::flac_defaults(), ogg_options: StreamingSinkOptions::ogg_defaults(), server_base_url: None, } } else { let default = Self::default(); let _ = cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); default } } _ => { let default = Self::default(); let _ = cfg.set_value(&path, Value::String(default.max_lead_seconds.to_string())); default } } } } /// Stream complet (FLAC pur + OGG-FLAC) pour un canal Radio Paradise. /// /// Version simplifiée utilisant RadioParadisePlaylistFeeder + PlaylistSource pub struct ParadiseStreamChannel { descriptor: ChannelDescriptor, state: Arc, pipeline_handle: JoinHandle<()>, feeder_handle: JoinHandle<()>, } impl ParadiseStreamChannel { /// Crée un canal avec client déjà configuré. pub async fn with_client( descriptor: ChannelDescriptor, client: RadioParadiseClient, config: ParadiseStreamChannelConfig, cover_cache: Option>, history: Option, ) -> Result { // Propager server_base_url dans les options pour que les encoders injectent les covers du cache let mut config = config; if let Some(ref base) = config.server_base_url { config.flac_options = config .flac_options .clone() .with_server_base_url(Some(base.clone())); config.ogg_options = config .ogg_options .clone() .with_server_base_url(Some(base.clone())); } let cover_cache = cover_cache .or_else(|| history.as_ref().map(|opts| opts.cover_cache.clone())) .or_else(|| get_cover_cache()); let manager = PlaylistManager::get(); // 1. Créer la playlist live pour ce canal let live_playlist_id = format!("radio-paradise-live-{}", descriptor.slug); let (feeder, live_read) = if let Some(ref history_opts) = history { RadioParadisePlaylistFeeder::new( client.clone(), history_opts.audio_cache.clone(), history_opts.cover_cache.clone(), live_playlist_id.clone(), history_opts.collection.clone(), ) .await? } else { // Pas d'historique, on a besoin quand même d'un cache audio basique return Err(anyhow!( "History options required for now (audio cache needed)" )); }; let feeder = Arc::new(feeder); // 2. Créer/récupérer la playlist historique si activée let history_write = if let Some(ref history_opts) = history { let write = manager .get_persistent_write_handle(history_opts.playlist_id.clone()) .await?; // Configurer la capacité if let Some(capacity) = history_opts.max_history_tracks { write.set_capacity(Some(capacity)).await?; } // Configurer le titre let title = format!("Radio Paradise History - {}", descriptor.display_name); write.set_title(title).await?; Some(Arc::new(write)) } else { None }; // 3. Créer la source playlist avec historique let audio_cache = history.as_ref().unwrap().audio_cache.clone(); let mut source = if let Some(history_write) = history_write.clone() { PlaylistSource::with_history(live_read, audio_cache.clone(), history_write) } else { PlaylistSource::new(live_read, audio_cache.clone()) }; // 4. Créer les sinks de broadcast (FLAC + OGG) let (flac_sink, stream_handle) = StreamingFlacSink::with_options( EncoderOptions::default(), 16, config.max_lead_seconds, config.flac_options.clone(), ); let (ogg_sink, ogg_handle) = StreamingOggFlacSink::with_options( EncoderOptions::default(), 16, config.max_lead_seconds, config.ogg_options.clone(), ); let mut downstream_children: Vec> = Vec::new(); downstream_children.push(Box::new(flac_sink)); downstream_children.push(Box::new(ogg_sink)); // 5. Optionnel : ajouter le nœud de cache de covers if let Some(cache) = cover_cache { let mut cover_node = TrackBoundaryCoverNode::new(cache); for child in downstream_children { cover_node.register(child); } source.register(Box::new(cover_node)); } else { for child in downstream_children { source.register(child); } } stream_handle.set_auto_stop(false); ogg_handle.set_auto_stop(false); // 6. Lancer le pipeline audio let stop_token = CancellationToken::new(); let pipeline_stop = stop_token.clone(); let channel_display_name = descriptor.display_name; let state = Arc::new(ChannelState { descriptor, config, client, feeder: feeder.clone(), stream_handle, ogg_handle, history_playlist_id: history.map(|h| h.playlist_id), history_audio_cache: history_write.map(|_| audio_cache), active_clients: AtomicUsize::new(0), activity_notify: Notify::new(), stop_token, current_block: Mutex::new(None), prefetch_lock: Mutex::new(()), }); let pipeline_state = state.clone(); let pipeline_handle = tokio::spawn(async move { info!( "RadioParadise stream pipeline started for channel {}", channel_display_name ); if let Err(e) = Box::new(source).run(pipeline_stop).await { error!("Pipeline error for channel {}: {}", channel_display_name, e); pipeline_state.handle_pipeline_error(&e).await; } }); // 7. Lancer le feeder qui traite les blocs let feeder_runner = feeder.clone(); tokio::spawn(async move { if let Err(e) = feeder_runner.run().await { error!("RadioParadisePlaylistFeeder error: {}", e); } }); // 8. Lancer le scheduler qui enqueue les blocs let feeder_state = state.clone(); let feeder_handle = tokio::spawn(async move { feeder_state.run_scheduler().await; }); Ok(Self { descriptor, state, pipeline_handle, feeder_handle, }) } /// Crée un canal en construisant automatiquement le client pour ce descriptor. pub async fn new( descriptor: ChannelDescriptor, config: ParadiseStreamChannelConfig, cover_cache: Option>, history: Option, ) -> Result { let client = RadioParadiseClient::builder() .channel(descriptor.id) .build() .await?; Self::with_client(descriptor, client, config, cover_cache, history).await } /// S'abonne au flux FLAC pur. pub fn subscribe_flac(&self) -> ChannelFlacStream { self.state.on_client_added(); let inner = self.state.stream_handle.subscribe_flac(); ChannelFlacStream::new(inner, self.state.clone()) } /// S'abonne au flux FLAC + ICY metadata. pub fn subscribe_icy(&self) -> ChannelIcyStream { self.state.on_client_added(); let inner = self.state.stream_handle.subscribe_icy(); ChannelIcyStream::new(inner, self.state.clone()) } /// S'abonne au flux OGG-FLAC. pub fn subscribe_ogg(&self) -> ChannelOggStream { self.state.on_client_added(); let inner = self.state.ogg_handle.subscribe(); ChannelOggStream::new(inner, self.state.clone()) } /// Snapshot des métadonnées actuelles. pub async fn metadata(&self) -> MetadataSnapshot { self.state.stream_handle.get_metadata().await } /// Nombre de clients actifs. pub fn active_clients(&self) -> usize { self.state.active_clients.load(Ordering::SeqCst) } pub fn descriptor(&self) -> ChannelDescriptor { self.descriptor } /// Lance un pipeline dédié pour rejouer l'historique (FLAC pur) pour un client. pub async fn stream_history_flac( &self, client_id: &str, ) -> Result { let history_id = self .state .history_playlist_id .as_ref() .ok_or(HistoryStreamError::HistoryDisabled)?; let audio_cache = self .state .history_audio_cache .as_ref() .ok_or(HistoryStreamError::HistoryDisabled)?; tracing::info!( "Starting historical FLAC replay for channel {} (client_id={})", self.descriptor.display_name, client_id ); let reader = pmoplaylist::PlaylistManager::get() .get_read_handle(history_id) .await .map_err(|e| HistoryStreamError::Playlist(e.to_string()))?; let mut source = PlaylistSource::new(reader, audio_cache.clone()); let (flac_sink, handle) = StreamingFlacSink::with_max_broadcast_lead( EncoderOptions::default(), 16, self.state.config.max_lead_seconds, ); source.register(Box::new(flac_sink)); let stop_token = CancellationToken::new(); let stop_clone = stop_token.clone(); let pipeline = tokio::spawn(async move { let _ = Box::new(source).run(stop_clone).await; }); let stream = handle.subscribe_flac(); Ok(HistoryFlacStream::new(stream, stop_token, pipeline)) } /// Lance un pipeline dédié pour rejouer l'historique (OGG-FLAC) pour un client. pub async fn stream_history_ogg( &self, client_id: &str, ) -> Result { let history_id = self .state .history_playlist_id .as_ref() .ok_or(HistoryStreamError::HistoryDisabled)?; let audio_cache = self .state .history_audio_cache .as_ref() .ok_or(HistoryStreamError::HistoryDisabled)?; tracing::info!( "Starting historical OGG replay for channel {} (client_id={})", self.descriptor.display_name, client_id ); let reader = pmoplaylist::PlaylistManager::get() .get_read_handle(history_id) .await .map_err(|e| HistoryStreamError::Playlist(e.to_string()))?; let mut source = PlaylistSource::new(reader, audio_cache.clone()); let (ogg_sink, handle) = StreamingOggFlacSink::with_max_broadcast_lead( EncoderOptions::default(), 16, self.state.config.max_lead_seconds, ); source.register(Box::new(ogg_sink)); let stop_token = CancellationToken::new(); let stop_clone = stop_token.clone(); let pipeline = tokio::spawn(async move { let _ = Box::new(source).run(stop_clone).await; }); let stream = handle.subscribe(); Ok(HistoryOggStream::new(stream, stop_token, pipeline)) } } impl Drop for ParadiseStreamChannel { fn drop(&mut self) { self.state.stop_token.cancel(); self.pipeline_handle.abort(); self.feeder_handle.abort(); } } const MAX_BLOCK_LEAD: Duration = Duration::from_secs(3600); const BLOCK_LEAD_CHECK_CHUNK: Duration = Duration::from_secs(300); const LIVE_PREFETCH_MIN_TRACKS: usize = 5; const LIVE_PREFETCH_TIMEOUT: Duration = Duration::from_secs(10); const LIVE_PREFETCH_POLL_INTERVAL: Duration = Duration::from_millis(200); const LIVE_PREFETCH_MAX_BLOCKS: usize = 4; static GLOBAL_CHANNEL_MANAGER: OnceCell> = OnceCell::new(); struct ChannelState { descriptor: ChannelDescriptor, config: ParadiseStreamChannelConfig, client: RadioParadiseClient, feeder: Arc, stream_handle: StreamHandle, ogg_handle: OggFlacStreamHandle, history_playlist_id: Option, history_audio_cache: Option>, active_clients: AtomicUsize, activity_notify: Notify, stop_token: CancellationToken, current_block: Mutex>, prefetch_lock: Mutex<()>, } impl ChannelState { fn current_unix_millis() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0) } fn block_lead_delay(&self, block: &Block) -> Option { let start = block.start_time_millis()?; let now = Self::current_unix_millis(); let max_lead_ms = MAX_BLOCK_LEAD.as_millis() as u64; if start <= now + max_lead_ms { None } else { Some(Duration::from_millis(start - now - max_lead_ms)) } } fn on_client_added(&self) { if self.active_clients.fetch_add(1, Ordering::SeqCst) == 0 { self.activity_notify.notify_one(); } } fn on_client_removed(&self) { self.active_clients.fetch_sub(1, Ordering::SeqCst); } async fn wait_for_clients(&self) -> bool { while self.active_clients.load(Ordering::SeqCst) == 0 { tokio::select! { _ = self.stop_token.cancelled() => return false, _ = self.activity_notify.notified() => {}, } } true } async fn wait_until_block_ready(&self, block: &Block) -> BlockReadiness { loop { if self.stop_token.is_cancelled() { return BlockReadiness::Stopped; } if self.active_clients.load(Ordering::SeqCst) == 0 { return BlockReadiness::NoClients; } if let Some(delay) = self.block_lead_delay(block) { let sleep_for = delay.min(BLOCK_LEAD_CHECK_CHUNK); let lead_secs = delay.as_secs_f64(); info!( "Block {} scheduled too far in the future ({:.1} min). Sleeping {:?} before retrying.", block.event, lead_secs / 60.0, sleep_for ); tokio::select! { _ = self.stop_token.cancelled() => return BlockReadiness::Stopped, _ = tokio::time::sleep(sleep_for) => {}, } continue; } return BlockReadiness::Ready; } } fn live_playlist_id(&self) -> String { format!("radio-paradise-live-{}", self.descriptor.slug) } async fn prefetch_until_horizon(&self) -> Result<()> { let _guard = self.prefetch_lock.lock().await; let playlist_id = self.live_playlist_id(); let manager = PlaylistManager::get(); let reader = manager .get_read_handle(&playlist_id) .await .with_context(|| format!("Failed to get live playlist {}", playlist_id))?; let start = Instant::now(); let mut next_event: Option = None; let mut attempts = 0usize; loop { let available = reader .remaining() .await .with_context(|| format!("Failed to inspect playlist {}", playlist_id))?; if available >= LIVE_PREFETCH_MIN_TRACKS { return Ok(()); } if start.elapsed() >= LIVE_PREFETCH_TIMEOUT { warn!( "Prefetch timeout for channel {} ({} tracks available)", self.descriptor.display_name, available ); return Ok(()); } if attempts >= LIVE_PREFETCH_MAX_BLOCKS { warn!( "Prefetch block limit reached for channel {} ({} tracks available)", self.descriptor.display_name, available ); return Ok(()); } match self.client.get_block(next_event).await { Ok(block) => { attempts += 1; next_event = Some(block.end_event); self.feeder.push_block_id(block.event).await; } Err(e) => { warn!( "Failed to fetch block during prefetch for channel {}: {}", self.descriptor.display_name, e ); return Ok(()); } } tokio::time::sleep(LIVE_PREFETCH_POLL_INTERVAL).await; } } async fn set_current_block(&self, event_id: EventId) { let mut guard = self.current_block.lock().await; *guard = Some(event_id); } async fn take_current_block(&self) -> Option { self.current_block.lock().await.take() } async fn handle_pipeline_error(&self, err: &AudioError) { if let Some(event_id) = self.take_current_block().await { warn!( "Pipeline error while streaming block {} on channel {}: {}. Rescheduling block.", event_id, self.descriptor.display_name, err ); self.feeder.retry_block(event_id).await; } else { warn!( "Pipeline error for channel {} but no tracked block: {}", self.descriptor.display_name, err ); } } async fn run_scheduler(self: Arc) { let mut backoff = Duration::from_secs(5); 'scheduler: loop { if self.stop_token.is_cancelled() { break; } if !self.wait_for_clients().await { break; } match self.client.get_block(None).await { Ok(block) => { match self.wait_until_block_ready(&block).await { BlockReadiness::Ready => {} BlockReadiness::NoClients => continue, BlockReadiness::Stopped => break, } info!( "Channel {} streaming block {}", self.descriptor.display_name, block.event ); self.set_current_block(block.event).await; self.feeder.push_block_id(block.event).await; let mut next_event = block.end_event; loop { if self.stop_token.is_cancelled() { return; } if self.active_clients.load(Ordering::SeqCst) == 0 { break; } match self.client.get_block(Some(next_event)).await { Ok(next_block) => { match self.wait_until_block_ready(&next_block).await { BlockReadiness::Ready => {} BlockReadiness::NoClients => break, BlockReadiness::Stopped => break 'scheduler, } self.set_current_block(next_block.event).await; self.feeder.push_block_id(next_block.event).await; next_event = next_block.end_event; backoff = Duration::from_secs(5); } Err(e) => { warn!( "Failed to fetch next block for channel {}: {}", self.descriptor.display_name, e ); tokio::select! { _ = self.stop_token.cancelled() => return, _ = tokio::time::sleep(backoff) => {}, } backoff = (backoff * 2).min(Duration::from_secs(60)); } } } } Err(e) => { warn!( "Failed to fetch current block for channel {}: {}", self.descriptor.display_name, e ); tokio::select! { _ = self.stop_token.cancelled() => break, _ = tokio::time::sleep(backoff) => {}, } backoff = (backoff * 2).min(Duration::from_secs(60)); } } } } } enum BlockReadiness { Ready, NoClients, Stopped, } macro_rules! wrap_stream { ($name:ident, $inner:ty) => { pub struct $name { inner: $inner, state: Arc, } impl $name { fn new(inner: $inner, state: Arc) -> Self { Self { inner, state } } } impl AsyncRead for $name { fn poll_read( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { Pin::new(&mut self.inner).poll_read(cx, buf) } } impl Drop for $name { fn drop(&mut self) { self.state.on_client_removed(); } } }; } wrap_stream!(ChannelFlacStream, FlacClientStream); wrap_stream!(ChannelIcyStream, IcyClientStream); wrap_stream!(ChannelOggStream, OggFlacClientStream); #[derive(Debug, Error)] pub enum HistoryStreamError { #[error("history replay not enabled for this channel")] HistoryDisabled, #[error("playlist error: {0}")] Playlist(String), } pub struct HistoryFlacStream { inner: FlacClientStream, stop_token: CancellationToken, pipeline: Option>, } impl HistoryFlacStream { fn new( inner: FlacClientStream, stop_token: CancellationToken, pipeline: JoinHandle<()>, ) -> Self { Self { inner, stop_token, pipeline: Some(pipeline), } } } impl AsyncRead for HistoryFlacStream { fn poll_read( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { Pin::new(&mut self.inner).poll_read(cx, buf) } } impl Unpin for HistoryFlacStream {} impl Drop for HistoryFlacStream { fn drop(&mut self) { self.stop_token.cancel(); if let Some(handle) = self.pipeline.take() { handle.abort(); } } } pub struct HistoryOggStream { inner: OggFlacClientStream, stop_token: CancellationToken, pipeline: Option>, } impl HistoryOggStream { fn new( inner: OggFlacClientStream, stop_token: CancellationToken, pipeline: JoinHandle<()>, ) -> Self { Self { inner, stop_token, pipeline: Some(pipeline), } } } impl AsyncRead for HistoryOggStream { fn poll_read( mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>, ) -> Poll> { Pin::new(&mut self.inner).poll_read(cx, buf) } } impl Unpin for HistoryOggStream {} impl Drop for HistoryOggStream { fn drop(&mut self) { self.stop_token.cancel(); if let Some(handle) = self.pipeline.take() { handle.abort(); } } } /// Gestionnaire multi-canaux. pub struct ParadiseChannelManager { channels: HashMap>, } impl ParadiseChannelManager { pub fn new(channels: HashMap>) -> Self { Self { channels } } pub async fn with_defaults_with_cover_cache( cover_cache: Option>, history_builder: Option, server_base_url: Option, ) -> Result { tracing::warn!( "➡️ Entering with_defaults_with_cover_cache ({} channels, base_url={:?})", ALL_CHANNELS.len(), server_base_url ); let mut map = HashMap::new(); for descriptor in ALL_CHANNELS.iter().copied() { let mut config = ParadiseStreamChannelConfig::default(); config.server_base_url = server_base_url.clone(); let start = Instant::now(); tracing::warn!( "⏳ Initializing Radio Paradise channel {} ({})...", descriptor.display_name, descriptor.slug ); let history_opts = if let Some(builder) = &history_builder { tracing::warn!( " ⏳ Building history options for channel {} ({})", descriptor.display_name, descriptor.slug ); Some( builder .build_for_channel(&descriptor) .await .map_err(|e| anyhow!("Failed to init history playlist: {}", e))?, ) } else { None }; tracing::warn!( " ⏩ History options ready for channel {} ({})", descriptor.display_name, descriptor.slug ); let channel = match tokio::time::timeout( Duration::from_secs(20), ParadiseStreamChannel::new(descriptor, config, cover_cache.clone(), history_opts), ) .await { Ok(Ok(ch)) => { tracing::warn!( "✅ Channel {} ({}) initialized in {:?}", descriptor.display_name, descriptor.slug, start.elapsed() ); ch } Ok(Err(e)) => { tracing::error!( "⚠️ Failed to initialize channel {} ({}): {}", descriptor.display_name, descriptor.slug, e ); continue; } Err(_) => { tracing::error!( "⚠️ Timeout initializing channel {} ({}) after 20s, skipping", descriptor.display_name, descriptor.slug ); continue; } }; map.insert(descriptor.id, Arc::new(channel)); } Ok(Self { channels: map }) } pub async fn with_defaults() -> Result { Self::with_defaults_with_cover_cache(None, None, None).await } pub fn get(&self, id: u8) -> Option> { self.channels.get(&id).cloned() } pub fn iter(&self) -> impl Iterator> { self.channels.values() } pub async fn prefetch_until_horizon(&self, channel_id: u8) -> Result<()> { let channel = self .get(channel_id) .ok_or_else(|| anyhow!("Unknown channel id {}", channel_id))?; channel.prefetch_until_horizon().await } } pub fn register_global_channel_manager(manager: Arc) { let _ = GLOBAL_CHANNEL_MANAGER.set(Arc::downgrade(&manager)); } pub fn get_global_channel_manager() -> Option> { GLOBAL_CHANNEL_MANAGER.get().and_then(|weak| weak.upgrade()) } impl ParadiseStreamChannel { pub async fn prefetch_until_horizon(&self) -> Result<()> { self.state.prefetch_until_horizon().await } } -------End of pmoparadise/src/stream_channel.rs --------- ------------ pmoparadise/examples/download_block.rs ---------- //! Télécharge un bloc complet de Radio Paradise et sauvegarde toutes les pistes en FLAC //! //! Ce programme démontre l'utilisation de la chaîne : //! 1. RadioParadiseStreamSource - Télécharge et décode un bloc FLAC de Radio Paradise //! 2. FlacFileSink - Sauvegarde automatiquement chaque piste dans un fichier FLAC séparé //! //! La nouvelle architecture AudioPipelineNode permet de : //! - Télécharger et décoder automatiquement les blocs FLAC de Radio Paradise //! - Détecter les limites de pistes (TrackBoundary) //! - Sauvegarder automatiquement chaque piste dans un fichier séparé //! - Gérer proprement l'arrêt du pipeline avec un CancellationToken //! //! Usage: //! cargo run --example download_block -- //! //! Exemple: //! cargo run --example download_block -- 0 # Main Mix //! cargo run --example download_block -- 1 # Mellow Mix //! cargo run --example download_block -- 2 # Rock Mix //! cargo run --example download_block -- 3 # World/Etc Mix use pmoaudio::{AudioPipelineNode, FlacFileSink}; use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource}; use std::env; use tokio_util::sync::CancellationToken; #[tokio::main] async fn main() -> Result<(), Box> { // Initialiser tracing pour le debug tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::from_default_env() .add_directive(tracing::Level::INFO.into()), ) .init(); // Récupérer les arguments let args: Vec = env::args().collect(); if args.len() != 2 { eprintln!("Usage: {} ", args[0]); eprintln!(); eprintln!("Downloads a complete Radio Paradise block and saves all tracks as FLAC files."); eprintln!(); eprintln!("Channel IDs:"); eprintln!(" 0 - Main Mix (eclectic, diverse mix)"); eprintln!(" 1 - Mellow Mix (smooth, chilled music)"); eprintln!(" 2 - Rock Mix (classic & modern rock)"); eprintln!(" 3 - World/Etc Mix (global sounds)"); eprintln!(); eprintln!("Example:"); eprintln!(" {} 0 # Download Main Mix", args[0]); eprintln!(" {} 2 # Download Rock Mix", args[0]); std::process::exit(1); } let channel_id: u8 = match args[1].parse() { Ok(id) => id, Err(_) => { eprintln!("Error: channel_id must be a number between 0 and 3"); std::process::exit(1); } }; if channel_id > 3 { eprintln!("Error: channel_id must be between 0 and 3"); std::process::exit(1); } println!("=== Radio Paradise Block Downloader ==="); println!(); println!("Channel ID: {}", channel_id); println!(); // Créer le client Radio Paradise pour le channel spécifié println!("Fetching current block metadata..."); let client = RadioParadiseClient::builder() .channel(channel_id) .build() .await?; // Récupérer le bloc actuel let block = client.get_block(None).await?; println!("Block Information:"); println!(" Event ID: {}", block.event); println!(" Songs: {}", block.song_count()); println!(" Duration: {:.1} minutes", block.length as f64 / 60000.0); println!(); // Afficher la liste des pistes println!("Tracklist:"); for (index, song) in block.songs_ordered() { println!( " {:2}. {} - {} ({})", index + 1, song.artist, song.title, song.album.as_deref().unwrap_or("Unknown Album") ); } println!(); // Créer le répertoire de sortie let output_dir = format!("./rp_channel_{}block{}", channel_id, block.event); std::fs::create_dir_all(&output_dir)?; println!("Output directory: {}", output_dir); println!(); // Créer le pipeline: RadioParadiseStreamSource → FlacFileSink let mut source = RadioParadiseStreamSource::new(client); // Ajouter le bloc à télécharger source.push_block_id(block.event); // Créer le sink qui sauvegarde chaque piste dans un fichier séparé let base_path = format!("{}/track.flac", output_dir); let sink = FlacFileSink::new(&base_path); // Construire la chaîne: source → sink source.register(Box::new(sink)); // Créer un token d'arrêt let stop_token = CancellationToken::new(); // Gérer Ctrl+C pour arrêt propre let stop_token_clone = stop_token.clone(); tokio::spawn(async move { tokio::signal::ctrl_c().await.ok(); println!("\n\nReceived Ctrl+C, stopping..."); stop_token_clone.cancel(); }); // Lancer tout le pipeline println!("Downloading and processing block..."); println!("Press Ctrl+C to stop."); println!(); let start = std::time::Instant::now(); let result = Box::new(source).run(stop_token).await; let elapsed = start.elapsed(); // Vérifier le résultat match result { Ok(()) => { println!(); println!( "✓ Download completed successfully in {:.2}s", elapsed.as_secs_f64() ); println!(" Output directory: {}", output_dir); println!(); // Afficher les fichiers créés let entries = std::fs::read_dir(&output_dir)?; let mut files: Vec<_> = entries .filter_map(|e| e.ok()) .filter(|e| { e.path() .extension() .and_then(|s| s.to_str()) .map(|s| s == "flac") .unwrap_or(false) }) .collect(); files.sort_by_key(|e| e.path()); println!("Files created:"); for (i, entry) in files.iter().enumerate() { let path = entry.path(); let metadata = std::fs::metadata(&path)?; let size_mb = metadata.len() as f64 / (1024.0 * 1024.0); println!( " {:2}. {} ({:.2} MB)", i + 1, path.file_name().unwrap().to_string_lossy(), size_mb ); } println!(); // Calculer la taille totale let total_size: u64 = files .iter() .filter_map(|e| std::fs::metadata(e.path()).ok()) .map(|m| m.len()) .sum(); println!( "Total size: {:.2} MB", total_size as f64 / (1024.0 * 1024.0) ); } Err(e) => { eprintln!(); eprintln!("✗ Download error: {}", e); eprintln!(); return Err(e.into()); } } Ok(()) } -------End of pmoparadise/examples/download_block.rs --------- ------------ pmoparadise/examples/now_playing.rs ---------- //! Example: Display currently playing song and block information //! //! This example demonstrates: //! - Creating a Radio Paradise client //! - Fetching the current block //! - Displaying song metadata //! - Generating cover image URLs //! //! Run with: cargo run --example now_playing use pmoparadise::{RadioParadiseClient, Result}; #[tokio::main] async fn main() -> Result<()> { // Initialize logging (optional) #[cfg(feature = "logging")] tracing_subscriber::fmt::init(); println!("Radio Paradise - Now Playing"); println!("=============================\n"); // Create client with default settings (FLAC quality, channel 0) let client = RadioParadiseClient::new().await?; // Get what's currently playing let now_playing = client.now_playing().await?; let block = &now_playing.block; // Display block information println!("Block Information:"); println!(" Event ID: {}", block.event); println!(" Next Event: {}", block.end_event); println!(" Duration: {:.1} minutes", block.length as f64 / 60000.0); println!(" Songs in block: {}", block.song_count()); println!(" Stream URL: {}\n", block.url); // Display current song (if available) if let Some(song) = &now_playing.current_song { println!("Now Playing:"); println!(" Title: {}", song.title); println!(" Artist: {}", song.artist); if let Some(ref album) = song.album { println!(" Album: {}", album); } if let Some(year) = song.year { println!(" Year: {}", year); } if let Some(rating) = song.rating { println!(" Rating: {:.1}/10", rating); } println!( " Duration: {}:{:02}", song.duration / 60000, (song.duration % 60000) / 1000 ); // Display cover URL if let Some(cover) = &song.cover { if let Some(cover_url) = block.cover_url(cover) { println!(" Cover: {}", cover_url); } } println!(); } // Display all songs in the block println!("All Songs in This Block:"); println!("------------------------"); for (index, song) in block.songs_ordered() { let start_sec = song.elapsed / 1000; let duration_sec = song.duration / 1000; println!( "{}. [{:02}:{:02}] {} - {} ({:02}:{:02})", index + 1, start_sec / 60, start_sec % 60, song.artist, song.title, duration_sec / 60, duration_sec % 60 ); if let Some(ref album) = song.album { println!(" Album: {}", album); } if let Some(year) = song.year { print!(" Year: {}", year); } if let Some(rating) = song.rating { print!(" Rating: {:.1}/10", rating); } println!("\n"); } // Show how to get the next block println!("Fetching Next Block..."); let next_block = client.get_block(Some(block.end_event)).await?; println!(" Next block event: {}", next_block.event); println!(" Songs in next block: {}", next_block.song_count()); if let Some((_, first_song)) = next_block.songs_ordered().first() { println!(" First song: {} - {}", first_song.artist, first_song.title); } Ok(()) } -------End of pmoparadise/examples/now_playing.rs --------- ------------ pmoparadise/examples/play_and_cache.rs ---------- //! Télécharge un bloc Radio Paradise, le cache, et le joue en même temps //! //! Ce programme démontre l'utilisation complète de la chaîne : //! 1. RadioParadiseStreamSource - Télécharge et décode un bloc FLAC //! 2. FlacCacheSink - Cache chaque piste en FLAC et alimente une playlist //! 3. PlaylistSource - Lit la playlist pendant le téléchargement //! 4. TimerNode - Régule le débit pour éviter EOF prématurés (progressive cache) //! 5. AudioSink - Joue l'audio sur la sortie standard //! //! Architecture : //! ```text //! Pipeline 1 (Download & Cache): //! RadioParadiseStreamSource → FlacCacheSink (avec playlist abonnée) //! //! Pipeline 2 (Playback): //! PlaylistSource → TimerNode (rate limiting) → AudioSink //! ↓ //! Prévention EOF //! (3s max lead) //! ``` //! //! Usage: //! cargo run --example play_and_cache --features full -- //! //! Exemple: //! cargo run --example play_and_cache --features full -- 0 # Main Mix //! cargo run --example play_and_cache --features full -- 2 # Rock Mix use pmoaudio::{AudioPipelineNode, AudioSink, TimerNode}; use pmoaudio_ext::{FlacCacheSink, PlaylistSource}; use pmoaudiocache::{ new_cache_with_consolidation as new_audio_cache, register_audio_cache as register_global_audio_cache, }; use pmocovers::{new_cache_with_consolidation as new_cover_cache, register_cover_cache}; use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource}; use pmoplaylist::register_audio_cache as register_playlist_audio_cache; use std::env; use std::sync::Arc; use tokio_util::sync::CancellationToken; #[tokio::main] async fn main() -> Result<(), Box> { // Initialiser tracing avec beaucoup de logs tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::from_default_env() .add_directive(tracing::Level::DEBUG.into()) .add_directive("pmoaudio=debug".parse()?) .add_directive("pmoaudio_ext=debug".parse()?) .add_directive("pmoplaylist=debug".parse()?) .add_directive("pmoparadise=debug".parse()?) .add_directive("pmoaudiocache=debug".parse()?), ) .init(); tracing::info!("=== Radio Paradise Play & Cache ==="); // Récupérer les arguments let args: Vec = env::args().collect(); if args.len() < 2 { eprintln!("Usage: {} [--null-audio]", args[0]); eprintln!(); eprintln!("Downloads a Radio Paradise block, caches it, and plays it simultaneously."); eprintln!(); eprintln!("Channel IDs:"); eprintln!(" 0 - Main Mix (eclectic, diverse mix)"); eprintln!(" 1 - Mellow Mix (smooth, chilled music)"); eprintln!(" 2 - Rock Mix (classic & modern rock)"); eprintln!(" 3 - World/Etc Mix (global sounds)"); eprintln!(); eprintln!("Options:"); eprintln!(" --null-audio Don't play audio (for testing without audio device)"); std::process::exit(1); } let channel_id: u8 = match args[1].parse() { Ok(id) if id <= 3 => id, _ => { eprintln!("Error: channel_id must be a number between 0 and 3"); std::process::exit(1); } }; let use_null_audio = args.len() > 2 && args[2] == "--null-audio"; tracing::info!("Channel ID: {}", channel_id); if use_null_audio { tracing::info!("Using null audio output (no playback)"); } // ═══════════════════════════════════════════════════════════════════════════ // Initialiser les caches et le gestionnaire de playlist // ═══════════════════════════════════════════════════════════════════════════ let base_dir = std::env::var("PMO_CONFIG_DIR").unwrap_or_else(|_| "/tmp/pmomusic_test".to_string()); std::fs::create_dir_all(&base_dir)?; tracing::info!("Initializing caches in: {}", base_dir); // Créer le cache audio let audio_cache_dir = format!("{}/audio_cache", base_dir); std::fs::create_dir_all(&audio_cache_dir)?; let audio_cache = new_audio_cache(&audio_cache_dir, 1000).await?; tracing::debug!("Audio cache initialized at: {}", audio_cache_dir); // Créer le cache de covers let cover_cache_dir = format!("{}/cover_cache", base_dir); std::fs::create_dir_all(&cover_cache_dir)?; let cover_cache = new_cover_cache(&cover_cache_dir, 100).await?; tracing::debug!("Cover cache initialized at: {}", cover_cache_dir); // Enregistrer le cache audio dans pmoplaylist // (requis par pmoplaylist pour valider les pks) register_global_audio_cache(audio_cache.clone()); register_playlist_audio_cache(audio_cache.clone()); register_cover_cache(cover_cache.clone()); tracing::debug!("Audio cache registered in pmoplaylist"); // Utiliser le gestionnaire de playlist singleton tracing::info!("Getting playlist manager..."); let playlist_manager = pmoplaylist::PlaylistManager(); tracing::debug!("Playlist manager obtained"); // ═══════════════════════════════════════════════════════════════════════════ // Créer la playlist pour ce channel // ═══════════════════════════════════════════════════════════════════════════ let playlist_id = format!("radio-paradise-ch{}", channel_id); tracing::info!("Creating playlist: {}", playlist_id); // Créer une playlist éphémère (non persistante) pour cet exemple let writer = playlist_manager .get_write_handle(playlist_id.clone()) .await?; writer .set_title(format!("Radio Paradise - Channel {}", channel_id)) .await?; writer.flush().await?; // Vider la playlist si elle existait tracing::debug!("Playlist created and flushed"); // Créer le reader pour la lecture let reader = playlist_manager.get_read_handle(&playlist_id).await?; tracing::debug!("Read handle created"); // ═══════════════════════════════════════════════════════════════════════════ // Récupérer les infos du bloc à télécharger // ═══════════════════════════════════════════════════════════════════════════ tracing::info!("Fetching current block metadata..."); let client = RadioParadiseClient::builder() .channel(channel_id) .build() .await?; let block = client.get_block(None).await?; tracing::info!("Block Information:"); tracing::info!(" Event ID: {}", block.event); tracing::info!(" Songs: {}", block.song_count()); tracing::info!(" Duration: {:.1} minutes", block.length as f64 / 60000.0); tracing::info!(""); tracing::info!("Tracklist:"); for (index, song) in block.songs_ordered() { tracing::info!( " {:2}. {} - {} ({})", index + 1, song.artist, song.title, song.album.as_deref().unwrap_or("Unknown Album") ); } tracing::info!(""); // ═══════════════════════════════════════════════════════════════════════════ // Pipeline 1: Téléchargement et cache // ═══════════════════════════════════════════════════════════════════════════ tracing::info!("Creating download pipeline..."); // Créer la source Radio Paradise let mut download_source = RadioParadiseStreamSource::new(client); download_source.push_block_id(block.event); tracing::debug!( "RadioParadiseStreamSource created with block {}", block.event ); // Créer le sink de cache FLAC let mut cache_sink = FlacCacheSink::new(audio_cache.clone(), cover_cache.clone()); cache_sink.register_playlist(writer); tracing::debug!("FlacCacheSink created and registered with playlist"); // Connecter source → sink download_source.register(Box::new(cache_sink)); tracing::info!("Download pipeline connected: RadioParadiseStreamSource → FlacCacheSink"); // ═══════════════════════════════════════════════════════════════════════════ // Pipeline 2: Lecture depuis la playlist // ═══════════════════════════════════════════════════════════════════════════ tracing::info!("Creating playback pipeline..."); // Créer la source playlist let mut playlist_source = PlaylistSource::new(reader, audio_cache.clone()); tracing::debug!("PlaylistSource created"); // Créer le timer node pour réguler le débit (empêche EOF prématurés) // Tolère 3 secondes d'avance max pour permettre le buffering let mut timer = TimerNode::new(3.0); tracing::debug!("TimerNode created (max_lead_time=3.0s)"); // Créer le sink audio let audio_sink = if use_null_audio { AudioSink::with_null_output() } else { AudioSink::new() }; tracing::debug!("AudioSink created"); // Connecter timer → audio (AVANT de mettre timer dans une Box) timer.register(Box::new(audio_sink)); // Connecter playlist → timer playlist_source.register(Box::new(timer)); tracing::info!("Playback pipeline connected: PlaylistSource → TimerNode → AudioSink"); // ═══════════════════════════════════════════════════════════════════════════ // Lancer les deux pipelines en parallèle // ═══════════════════════════════════════════════════════════════════════════ tracing::info!(""); tracing::info!("========================================"); tracing::info!("Starting both pipelines..."); tracing::info!("Pipeline 1: Downloading and caching"); tracing::info!("Pipeline 2: Playing from playlist"); tracing::info!("========================================"); tracing::info!(""); let stop_token = CancellationToken::new(); let stop_token_download = stop_token.clone(); let stop_token_playback = stop_token.clone(); // Gérer Ctrl+C let stop_token_ctrl_c = stop_token.clone(); tokio::spawn(async move { tokio::signal::ctrl_c().await.ok(); tracing::warn!("Received Ctrl+C, stopping..."); stop_token_ctrl_c.cancel(); }); let start = std::time::Instant::now(); // Lancer les deux pipelines en parallèle let download_handle = tokio::spawn(async move { tracing::info!("[DOWNLOAD] Pipeline starting..."); let result = Box::new(download_source).run(stop_token_download).await; match &result { Ok(()) => tracing::info!("[DOWNLOAD] Pipeline completed successfully"), Err(e) => tracing::error!("[DOWNLOAD] Pipeline error: {}", e), } result }); let playback_handle = tokio::spawn(async move { // Pas de sleep - le cache progressif permet de démarrer immédiatement // dès que le prebuffer (512 KB) est atteint tracing::info!("[PLAYBACK] Pipeline starting (will wait for prebuffer)..."); let result = Box::new(playlist_source).run(stop_token_playback).await; match &result { Ok(()) => tracing::info!("[PLAYBACK] Pipeline completed successfully"), Err(e) => tracing::error!("[PLAYBACK] Pipeline error: {}", e), } result }); // Attendre les deux pipelines let (download_result, playback_result) = tokio::join!(download_handle, playback_handle); let elapsed = start.elapsed(); // Vérifier les résultats match (download_result, playback_result) { (Ok(Ok(())), Ok(Ok(()))) => { tracing::info!(""); tracing::info!("========================================"); tracing::info!("✓ Both pipelines completed successfully"); tracing::info!(" Total time: {:.2}s", elapsed.as_secs_f64()); tracing::info!("========================================"); } (download_res, playback_res) => { tracing::error!(""); tracing::error!("========================================"); if let Err(e) = download_res { tracing::error!("✗ Download pipeline error: {:?}", e); } else if let Ok(Err(e)) = download_res { tracing::error!("✗ Download pipeline error: {}", e); } if let Err(e) = playback_res { tracing::error!("✗ Playback pipeline error: {:?}", e); } else if let Ok(Err(e)) = playback_res { tracing::error!("✗ Playback pipeline error: {}", e); } tracing::error!("========================================"); return Err("Pipeline error".into()); } } Ok(()) } -------End of pmoparadise/examples/play_and_cache.rs --------- ------------ pmoparadise/examples/serve_channels.rs ---------- //! Minimal HTTP server exposing all four Radio Paradise channels. //! //! Routes: //! - `/radioparadise/stream//flac` //! - `/radioparadise/stream//ogg` //! - `/radioparadise/stream//icy` //! - `/radioparadise/stream//historic//flac` //! - `/radioparadise/stream//historic//ogg` //! - `/radioparadise/metadata/` use std::{fs, sync::Arc}; use axum::{ body::Body, extract::{Path, State}, http::{ header::{ACCEPT_RANGES, CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, StatusCode, }, response::{IntoResponse, Response}, routing::get, Json, Router, }; use pmoaudiocache::{ new_cache_with_consolidation as new_audio_cache, register_audio_cache as register_global_audio_cache, }; use pmocovers::{new_cache_with_consolidation as new_cover_cache, register_cover_cache}; use pmoparadise::{channels::ALL_CHANNELS, ParadiseChannelManager, ParadiseHistoryBuilder}; use pmoplaylist::register_audio_cache as register_playlist_audio_cache; use pmoserver::{init_logging, ServerBuilder}; use tokio_util::io::ReaderStream; use tracing::{error, info}; #[derive(Clone)] struct AppState { manager: Arc, } #[tokio::main] async fn main() -> anyhow::Result<()> { let _ = init_logging(); // Préparer les caches partagés let cover_cache_dir = "./cache/rp_covers"; let audio_cache_dir = "./cache/rp_audio"; fs::create_dir_all(cover_cache_dir)?; fs::create_dir_all(audio_cache_dir)?; let cover_cache = new_cover_cache(cover_cache_dir, 500).await?; let audio_cache = new_audio_cache(audio_cache_dir, 1000).await?; register_global_audio_cache(audio_cache.clone()); register_playlist_audio_cache(audio_cache.clone()); register_cover_cache(cover_cache.clone()); let _playlist_manager = pmoplaylist::PlaylistManager(); let history_builder = ParadiseHistoryBuilder { audio_cache: audio_cache.clone(), cover_cache: cover_cache.clone(), playlist_prefix: "radio-paradise-history".into(), playlist_title_prefix: Some("Radio Paradise History".into()), max_history_tracks: Some(500), collection_prefix: Some("radioparadise".into()), replay_max_lead_seconds: 1.0, }; info!("Initializing Radio Paradise channels..."); let server_base_url = format!("http://localhost:{}", 8080); let manager = Arc::new( ParadiseChannelManager::with_defaults_with_cover_cache( Some(cover_cache), Some(history_builder), Some(server_base_url), ) .await?, ); let app_state = Arc::new(AppState { manager: manager.clone(), }); let mut server = ServerBuilder::new("RadioParadiseChannels", "http://localhost", 8080).build(); for descriptor in ALL_CHANNELS.iter() { let slug = descriptor.slug; let flac_path = format!("/radioparadise/stream/{}/flac", slug); let ogg_path = format!("/radioparadise/stream/{}/ogg", slug); let icy_path = format!("/radioparadise/stream/{}/icy", slug); let history_path = format!("/radioparadise/stream/{}/historic", slug); let meta_path = format!("/radioparadise/metadata/{}", slug); let channel_id = descriptor.id; server .add_handler_with_state( &flac_path, move |State(state): State>| { let manager = state.manager.clone(); async move { stream_flac(manager, channel_id).await } }, app_state.clone(), ) .await; server .add_handler_with_state( &ogg_path, move |State(state): State>| { let manager = state.manager.clone(); async move { stream_ogg(manager, channel_id).await } }, app_state.clone(), ) .await; server .add_handler_with_state( &icy_path, move |State(state): State>| { let manager = state.manager.clone(); async move { stream_icy(manager, channel_id).await } }, app_state.clone(), ) .await; let history_router = Router::new() .route( "/{client_id}/flac", get({ let manager = manager.clone(); move |Path(client_id): Path| { let manager = manager.clone(); async move { stream_history_flac(manager, channel_id, client_id).await } } }), ) .route( "/{client_id}/ogg", get({ let manager = manager.clone(); move |Path(client_id): Path| { let manager = manager.clone(); async move { stream_history_ogg(manager, channel_id, client_id).await } } }), ); server.add_router(&history_path, history_router).await; server .add_handler_with_state( &meta_path, move |State(state): State>| { let manager = state.manager.clone(); async move { get_metadata(manager, channel_id).await } }, app_state.clone(), ) .await; } info!("========================================"); info!("Radio Paradise streaming server running on http://localhost:8080"); info!("Available channels:"); for descriptor in ALL_CHANNELS.iter() { info!( " {}: /radioparadise/stream/{}/flac (also /ogg, /icy, metadata, /historic//(flac|ogg))", descriptor.display_name, descriptor.slug ); } info!("Press Ctrl+C to stop."); info!("========================================"); server.start().await; server.wait().await; Ok(()) } async fn stream_flac( manager: Arc, channel_id: u8, ) -> Result { let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let stream = channel.subscribe_flac(); Ok(Response::builder() .status(StatusCode::OK) .header(CONTENT_TYPE, "audio/flac") .header(CACHE_CONTROL, "no-store, no-transform") .header(CONNECTION, "keep-alive") .header(ACCEPT_RANGES, "none") .body(Body::from_stream(ReaderStream::new(stream))) .unwrap()) } async fn stream_ogg( manager: Arc, channel_id: u8, ) -> Result { let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let stream = channel.subscribe_ogg(); Ok(Response::builder() .status(StatusCode::OK) .header(CONTENT_TYPE, "application/ogg") .header(CACHE_CONTROL, "no-store, no-transform") .header(CONNECTION, "keep-alive") .header(ACCEPT_RANGES, "none") .body(Body::from_stream(ReaderStream::new(stream))) .unwrap()) } async fn stream_icy( manager: Arc, channel_id: u8, ) -> Result { let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let stream = channel.subscribe_icy(); Ok(Response::builder() .status(StatusCode::OK) .header(CONTENT_TYPE, "audio/flac") .header(CACHE_CONTROL, "no-store, no-transform") .header(CONNECTION, "keep-alive") .header(ACCEPT_RANGES, "none") .header("icy-metaint", "16000") .body(Body::from_stream(ReaderStream::new(stream))) .unwrap()) } async fn get_metadata( manager: Arc, channel_id: u8, ) -> Result { let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let metadata = channel.metadata().await; Ok(Json(metadata)) } async fn stream_history_flac( manager: Arc, channel_id: u8, client_id: String, ) -> Result { let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let stream = channel.stream_history_flac(&client_id).await.map_err(|e| { error!( "Failed to start historical FLAC stream for channel {} (client_id={}): {}", channel_id, client_id, e ); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Response::builder() .status(StatusCode::OK) .header(CONTENT_TYPE, "audio/flac") .header(CACHE_CONTROL, "no-store, no-transform") .header(CONNECTION, "keep-alive") .header(ACCEPT_RANGES, "none") .body(Body::from_stream(ReaderStream::new(stream))) .unwrap()) } async fn stream_history_ogg( manager: Arc, channel_id: u8, client_id: String, ) -> Result { let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let stream = channel.stream_history_ogg(&client_id).await.map_err(|e| { error!( "Failed to start historical OGG stream for channel {} (client_id={}): {}", channel_id, client_id, e ); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Response::builder() .status(StatusCode::OK) .header(CONTENT_TYPE, "application/ogg") .header(CACHE_CONTROL, "no-store, no-transform") .header(CONNECTION, "keep-alive") .header(ACCEPT_RANGES, "none") .body(Body::from_stream(ReaderStream::new(stream))) .unwrap()) } -------End of pmoparadise/examples/serve_channels.rs --------- ------------ pmoparadise/examples/single_channel_server.rs ---------- //! Simple web server that exposes one Radio Paradise channel over HTTP. //! //! Usage: //! ```bash //! cargo run --example single_channel_server --features full -- main //! ``` //! Valid arguments are either the slug (`main`, `mellow`, `rock`, `eclectic`) or //! the numeric channel id (`0`..`3`). When no argument is provided, the example //! defaults to the “main” mix. use axum::{ body::Body, extract::{Path, Request, State}, http::StatusCode, response::{IntoResponse, Response}, routing::get, Json, Router, }; use pmoaudio_ext::StreamingSinkOptions; use pmoaudiocache::{ new_cache_with_consolidation as new_audio_cache, register_audio_cache as register_global_audio_cache, }; use pmocovers::{ new_cache_with_consolidation as new_cover_cache, register_cover_cache, Cache as CoverCache, }; use pmoparadise::{ channels::{ChannelDescriptor, ALL_CHANNELS}, ParadiseHistoryBuilder, ParadiseStreamChannel, ParadiseStreamChannelConfig, }; use pmoplaylist::register_audio_cache as register_playlist_audio_cache; use std::{fs, net::SocketAddr, sync::Arc}; use tokio::net::TcpListener; use tokio_util::io::ReaderStream; use tracing::info; #[derive(Clone)] struct AppState { channel: Arc, descriptor: ChannelDescriptor, cover_cache: Arc, } #[tokio::main] async fn main() -> anyhow::Result<()> { let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); tracing_subscriber::fmt().with_env_filter(env_filter).init(); let descriptor = pick_descriptor(std::env::args().nth(1))?; info!( "Selected Radio Paradise channel: {} ({})", descriptor.display_name, descriptor.slug ); // Prepare caches under ./cache/single-channel let cache_root = "./cache/single-channel"; let audio_cache_dir = format!("{}/audio", cache_root); let cover_cache_dir = format!("{}/covers", cache_root); fs::create_dir_all(&audio_cache_dir)?; fs::create_dir_all(&cover_cache_dir)?; let audio_cache = new_audio_cache(&audio_cache_dir, 1000).await?; let cover_cache = new_cover_cache(&cover_cache_dir, 200).await?; register_global_audio_cache(audio_cache.clone()); register_playlist_audio_cache(audio_cache.clone()); register_cover_cache(cover_cache.clone()); let mut history_builder = ParadiseHistoryBuilder::new(audio_cache.clone(), cover_cache.clone()); history_builder.playlist_prefix = format!("single-channel-history-{}", descriptor.slug); history_builder.collection_prefix = Some(format!("single-channel-{}", descriptor.slug)); let history_opts = history_builder.build_for_channel(&descriptor).await?; let mut channel_config = ParadiseStreamChannelConfig::default(); // Base URL for cover images in stream metadata let server_base_url = "http://localhost:8080".to_string(); // Configuration commune pour FLAC et OGG let common_options = StreamingSinkOptions::flac_defaults() .with_default_artist(Some("Radio Paradise".to_string())) .with_default_title(descriptor.display_name.to_string()) .with_server_base_url(Some(server_base_url.clone())); channel_config.flac_options = common_options.clone(); channel_config.ogg_options = StreamingSinkOptions::ogg_defaults() .with_default_artist(Some("Radio Paradise".to_string())) .with_default_title(descriptor.display_name.to_string()) .with_server_base_url(Some(server_base_url)); let channel = Arc::new( ParadiseStreamChannel::new( descriptor, channel_config, Some(cover_cache.clone()), Some(history_opts), ) .await?, ); let state = AppState { channel, descriptor, cover_cache, }; let app = Router::new() .route("/stream/flac", get(stream_flac)) .route("/stream/ogg", get(stream_ogg)) .route("/metadata", get(get_metadata)) .route("/covers/image/{pk}", get(get_cover)) .with_state(state); let addr: SocketAddr = ([0, 0, 0, 0], 8080).into(); info!("========================================"); info!("HTTP server listening on http://{addr}"); info!("Available endpoints:"); info!(" - /stream/flac : FLAC audio stream"); info!(" - /stream/ogg : OGG-FLAC audio stream"); info!(" - /metadata : Current track metadata (JSON)"); info!(" - /covers/image/{{pk}} : Album cover images (WebP)"); info!("========================================"); info!("Connect with a FLAC player: ffplay http://localhost:8080/stream/flac"); info!("Connect with an OGG-FLAC player: ffplay http://localhost:8080/stream/ogg"); let listener = TcpListener::bind(addr).await?; axum::serve(listener, app.into_make_service()).await?; Ok(()) } async fn stream_flac(State(state): State) -> Result { let stream = state.channel.subscribe_flac(); let body = Body::from_stream(ReaderStream::new(stream)); Response::builder() .status(StatusCode::OK) .header("Content-Type", "audio/flac") .header( "X-PMO-Channel", format!( "{} ({})", state.descriptor.display_name, state.descriptor.slug ), ) .body(body) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } async fn stream_ogg(State(state): State) -> Result { let stream = state.channel.subscribe_ogg(); let body = Body::from_stream(ReaderStream::new(stream)); Response::builder() .status(StatusCode::OK) .header("Content-Type", "audio/ogg") .header( "X-PMO-Channel", format!( "{} ({})", state.descriptor.display_name, state.descriptor.slug ), ) .body(body) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } async fn get_metadata( State(state): State, request: Request, ) -> Result { let mut metadata = state.channel.metadata().await; // Si cover_pk est disponible, construire l'URL complète depuis les headers // Format: /covers/image/{pk} (correspond à la structure du cache pmocovers) if let Some(ref pk) = metadata.cover_pk { let base_url = extract_base_url(&request); metadata.cover_url = Some(format!("{}/covers/image/{}", base_url, pk)); } Ok(Json(metadata)) } /// Extrait l'URL de base depuis les headers HTTP de la requête /// Supporte les proxies avec X-Forwarded-Host et X-Forwarded-Proto fn extract_base_url(request: &Request) -> String { let headers = request.headers(); // Déterminer le schéma (http ou https) let scheme = headers .get("x-forwarded-proto") .and_then(|h| h.to_str().ok()) .unwrap_or("http"); // Déterminer le host let host = headers .get("x-forwarded-host") .or_else(|| headers.get("host")) .and_then(|h| h.to_str().ok()) .unwrap_or("localhost:8080"); format!("{}://{}", scheme, host) } async fn get_cover( State(state): State, Path(pk): Path, ) -> Result { // Récupérer le chemin de la cover depuis le cache // Le cache retourne un PathBuf pointant vers le fichier .webp let cover_path = state.cover_cache.get(&pk).await.map_err(|e| { tracing::error!("Failed to get cover path for {}: {}", pk, e); StatusCode::NOT_FOUND })?; // Lire le fichier let cover_data = tokio::fs::read(&cover_path).await.map_err(|e| { tracing::error!("Failed to read cover file {:?}: {}", cover_path, e); StatusCode::NOT_FOUND })?; Response::builder() .status(StatusCode::OK) .header("Content-Type", "image/webp") .header("Cache-Control", "public, max-age=86400") .body(Body::from(cover_data)) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } fn pick_descriptor(arg: Option) -> anyhow::Result { if let Some(token) = arg { if let Some(desc) = ALL_CHANNELS.iter().find(|c| c.slug == token) { return Ok(*desc); } if let Ok(id) = token.parse::() { if let Some(desc) = ALL_CHANNELS.iter().find(|c| c.id == id) { return Ok(*desc); } } anyhow::bail!("Unknown channel identifier: {token}"); } Ok(ALL_CHANNELS[0]) } -------End of pmoparadise/examples/single_channel_server.rs --------- ------------ pmoparadise/examples/stream_block.rs ---------- //! Streams a Radio Paradise block via HTTP using pmoserver //! //! This example demonstrates streaming a single Radio Paradise block //! using the StreamingFlacSink over HTTP via pmoserver. Perfect for //! testing with VLC or other media players that support HTTP streaming. //! //! The example streams ONE block then terminates cleanly using END_OF_BLOCKS_SIGNAL. //! For continuous streaming, push multiple block_ids without the END signal. //! //! Architecture: //! ```text //! RadioParadiseStreamSource → TimerBufferNode → StreamingFlacSink //! ↓ //! StreamHandle //! ↓ //! pmoserver (Axum) //! ↓ //! VLC / Media Player Client //! ``` //! //! Usage: //! cargo run --example stream_block --features full -- //! //! Example: //! cargo run --example stream_block --features full -- 0 # Main Mix //! //! Then open in VLC: //! vlc http://localhost:8080/test/stream (pure FLAC) //! vlc http://localhost:8080/test/stream-ogg (OGG-FLAC streaming container) //! vlc http://localhost:8080/test/stream-icy (FLAC + ICY metadata) //! //! To check current metadata: //! curl http://localhost:8080/test/metadata use axum::{ body::Body, extract::State, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; use pmoaudio::{AudioPipelineNode, TimerBufferNode}; use pmoaudio_ext::{StreamingFlacSink, StreamingOggFlacSink}; use pmoflac::EncoderOptions; use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource, END_OF_BLOCKS_SIGNAL}; use pmoserver::{init_logging, ServerBuilder}; use std::env; use std::sync::Arc; use tokio_util::io::ReaderStream; use tokio_util::sync::CancellationToken; /// Shared application state struct AppState { stream_handle: pmoaudio_ext::StreamHandle, ogg_handle: pmoaudio_ext::OggFlacStreamHandle, } /// Main HTTP handler for streaming (pure FLAC, no ICY metadata) async fn stream_handler( State(state): State>, _headers: HeaderMap, ) -> Result { tracing::info!("New client connected (pure FLAC mode)"); // Pure FLAC stream without ICY metadata let flac_stream = state.stream_handle.subscribe_flac(); Ok(Response::builder() .status(StatusCode::OK) .header("Content-Type", "audio/flac") .header("Cache-Control", "no-cache, no-store") .body(Body::from_stream(ReaderStream::new(flac_stream))) .unwrap()) } /// ICY streaming handler (FLAC with embedded metadata) async fn stream_icy_handler( State(state): State>, _headers: HeaderMap, ) -> Result { tracing::info!("New client connected (ICY mode)"); // FLAC stream with ICY metadata let icy_stream = state.stream_handle.subscribe_icy(); Ok(Response::builder() .status(StatusCode::OK) .header("Content-Type", "audio/flac") .header("icy-metaint", "16000") .header("icy-name", "Radio Paradise Stream Test") .header("icy-genre", "Eclectic") .header("icy-pub", "1") .header("Cache-Control", "no-cache, no-store") .body(Body::from_stream(ReaderStream::new(icy_stream))) .unwrap()) } /// OGG-FLAC streaming handler async fn stream_ogg_handler( State(state): State>, _headers: HeaderMap, ) -> Result { tracing::info!("New client connected (OGG-FLAC mode)"); // OGG-FLAC stream let ogg_stream = state.ogg_handle.subscribe(); Ok(Response::builder() .status(StatusCode::OK) .header("Content-Type", "audio/ogg") .header("Cache-Control", "no-cache, no-store") .body(Body::from_stream(ReaderStream::new(ogg_stream))) .unwrap()) } /// Metadata endpoint (JSON) async fn metadata_handler(State(state): State>) -> impl IntoResponse { let metadata = state.stream_handle.get_metadata().await; axum::Json(metadata) } /// Health check endpoint async fn health_handler() -> &'static str { "OK" } #[tokio::main] async fn main() -> Result<(), Box> { // Initialize logging via pmoserver let _log_state = init_logging(); tracing::info!("=== Radio Paradise HTTP Streaming Test ==="); // Parse arguments let args: Vec = env::args().collect(); if args.len() < 2 { eprintln!("Usage: {} ", args[0]); eprintln!(); eprintln!("Streams a Radio Paradise block via HTTP for testing."); eprintln!(); eprintln!("Channel IDs:"); eprintln!(" 0 - Main Mix (eclectic, diverse mix)"); eprintln!(" 1 - Mellow Mix (smooth, chilled music)"); eprintln!(" 2 - Rock Mix (classic & modern rock)"); eprintln!(" 3 - World/Etc Mix (global sounds)"); eprintln!(); eprintln!("After starting, open in VLC:"); eprintln!(" vlc http://localhost:8080/test/stream (pure FLAC)"); eprintln!(" vlc http://localhost:8080/test/stream-ogg (OGG-FLAC container)"); eprintln!(" vlc http://localhost:8080/test/stream-icy (FLAC + ICY metadata)"); std::process::exit(1); } let channel_id: u8 = match args[1].parse() { Ok(id) if id <= 3 => id, _ => { eprintln!("Error: channel_id must be a number between 0 and 3"); std::process::exit(1); } }; tracing::info!("Channel ID: {}", channel_id); // ═══════════════════════════════════════════════════════════════════════════ // Fetch block metadata // ═══════════════════════════════════════════════════════════════════════════ tracing::info!("Fetching current block metadata..."); let client = RadioParadiseClient::builder() .channel(channel_id) .build() .await?; let block = client.get_block(None).await?; tracing::info!("Block Information:"); tracing::info!(" Event ID: {}", block.event); tracing::info!(" Songs: {}", block.song_count()); tracing::info!(" Duration: {:.1} minutes", block.length as f64 / 60000.0); tracing::info!(""); tracing::info!("Tracklist:"); for (index, song) in block.songs_ordered() { tracing::info!( " {:2}. {} - {} ({})", index + 1, song.artist, song.title, song.album.as_deref().unwrap_or("Unknown Album") ); } tracing::info!(""); // ═══════════════════════════════════════════════════════════════════════════ // Create streaming pipelines (FLAC and OGG-FLAC) // ═══════════════════════════════════════════════════════════════════════════ tracing::info!("Creating streaming pipelines..."); // Encoder options (shared) let encoder_options = EncoderOptions { compression_level: 5, verify: false, ..Default::default() }; // ───────────────────────────────────────────────────────────────────────── // Unique pipeline feeding both FLAC and OGG sinks // ───────────────────────────────────────────────────────────────────────── let mut source = RadioParadiseStreamSource::new(client); source.push_block_id(block.event); source.push_block_id(END_OF_BLOCKS_SIGNAL); // Signal: no more blocks after this one tracing::debug!( "RadioParadiseStreamSource created with block {} + END signal", block.event ); // Use SMALL channel size to make backpressure plus fan-out manageable. let buffer_sec = 0.1; let max_lead_time = buffer_sec; let channel_size = 512; tracing::debug!( "Using channel size: {} chunks ({:.1}s buffer à 50ms/chunk)", channel_size, channel_size as f64 * 0.05 ); let mut timer_node = TimerBufferNode::with_channel_size(buffer_sec, channel_size); tracing::debug!( "TimerBufferNode created with {:.1}s buffer, {} chunk queue", buffer_sec, channel_size ); // Streaming sinks let (streaming_sink, stream_handle) = StreamingFlacSink::with_max_broadcast_lead(encoder_options.clone(), 16, max_lead_time); tracing::debug!("StreamingFlacSink created"); let (ogg_sink, ogg_handle) = StreamingOggFlacSink::with_max_broadcast_lead(encoder_options, 16, max_lead_time); tracing::debug!("StreamingOggFlacSink created"); // timer_node.register(Box::new(streaming_sink)); // timer_node.register(Box::new(ogg_sink)); // source.register(Box::new(timer_node)); source.register(Box::new(streaming_sink)); source.register(Box::new(ogg_sink)); tracing::info!("Pipeline connected: StreamSource → TimerBufferNode → {{FLAC, OGG}} sinks"); // ═══════════════════════════════════════════════════════════════════════════ // Setup pmoserver with streaming routes // ═══════════════════════════════════════════════════════════════════════════ tracing::info!("Setting up pmoserver..."); let mut server = ServerBuilder::new("RadioParadiseStreamTest", "http://localhost", 8080).build(); let app_state = Arc::new(AppState { stream_handle, ogg_handle, }); // Add streaming routes let base = "/radioparadise/test"; server .add_handler_with_state( &format!("{}/stream", base), stream_handler, app_state.clone(), ) .await; server .add_handler_with_state( &format!("{}/stream-icy", base), stream_icy_handler, app_state.clone(), ) .await; server .add_handler_with_state( &format!("{}/stream-ogg", base), stream_ogg_handler, app_state.clone(), ) .await; // Add metadata route server .add_handler_with_state( &format!("{}/metadata", base), metadata_handler, app_state.clone(), ) .await; // Add health check server.add_handler("/test/health", health_handler).await; tracing::info!(""); tracing::info!("========================================"); tracing::info!("Ready to stream!"); tracing::info!(""); tracing::info!("Pure FLAC stream (for VLC, standard players):"); tracing::info!(" vlc http://localhost:8080{}/stream", base); tracing::info!(""); tracing::info!("OGG-FLAC stream (streaming container with metadata support):"); tracing::info!(" vlc http://localhost:8080{}/stream-ogg", base); tracing::info!(""); tracing::info!("FLAC + ICY metadata stream (for ICY-aware clients):"); tracing::info!(" http://localhost:8080{}/stream-icy", base); tracing::info!(""); tracing::info!("Metadata endpoint (JSON):"); tracing::info!(" curl http://localhost:8080{}/metadata", base); tracing::info!("========================================"); tracing::info!(""); // ═══════════════════════════════════════════════════════════════════════════ // Start pipelines and server // ═══════════════════════════════════════════════════════════════════════════ let stop_token = CancellationToken::new(); let pipeline_stop = stop_token.clone(); // Start shared pipeline in background let pipeline_handle = tokio::spawn(async move { tracing::info!("[PIPELINE] Starting..."); let result = Box::new(source).run(pipeline_stop).await; match &result { Ok(()) => tracing::info!("[PIPELINE] Completed successfully"), Err(e) => tracing::error!("[PIPELINE] Error: {}", e), } result }); // Start pmoserver (blocks until Ctrl+C) tracing::info!("[SERVER] Starting pmoserver..."); server.start().await; server.wait().await; // Server stopped, cancel pipelines tracing::info!("Server stopped, canceling pipelines..."); stop_token.cancel(); // Wait for pipeline to finish match pipeline_handle.await { Ok(Ok(())) => tracing::info!("Pipeline completed successfully"), Ok(Err(e)) => tracing::error!("Pipeline error: {}", e), Err(e) => tracing::error!("Pipeline task error: {}", e), } tracing::info!("Shutdown complete"); Ok(()) } -------End of pmoparadise/examples/stream_block.rs ---------