From 23af037f36f5f8c13a9a7b5288bca71a4da90958 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 10:21:41 +0000 Subject: [PATCH 1/5] feat: Add download_block example for Radio Paradise Add a new example that demonstrates downloading a complete Radio Paradise block and saving each track as a separate FLAC file. The example: - Takes a channel ID as argument (0-3) - Fetches current block metadata - Creates an output directory ./rp_channel_{id}block{blockid} - Uses RadioParadiseStreamSource to stream and decode the block - Uses FlacFileSink to automatically detect TrackBoundary markers - Saves each track as a separate FLAC file with metadata Example usage: cargo run --example download_block --features=pmoaudio -- 0 This demonstrates the full pipeline integration between pmoparadise and pmoaudio, showing how RadioParadiseStreamSource and FlacFileSink work together to handle multi-track FLAC blocks seamlessly. --- pmoparadise/examples/download_block.rs | 205 +++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 pmoparadise/examples/download_block.rs diff --git a/pmoparadise/examples/download_block.rs b/pmoparadise/examples/download_block.rs new file mode 100644 index 00000000..47cdeda8 --- /dev/null +++ b/pmoparadise/examples/download_block.rs @@ -0,0 +1,205 @@ +//! 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(()) +} From cc3e31dbd04e4c7c7b75f422260823f17ec22661 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 11:10:17 +0000 Subject: [PATCH 2/5] fix: Eliminate channel/block_base duplication in ClientBuilder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le bug identifié était que le block_base n'était pas synchronisé avec le channel dans le ClientBuilder, causant le téléchargement du même bloc pour différents channels. Changements: - Supprimé le champ block_base du ClientBuilder (duplication) - Supprimé la constante DEFAULT_BLOCK_BASE (plus nécessaire) - Supprimé la méthode .block_base() du builder (complexité inutile) - Le block_base est maintenant calculé dynamiquement dans build() à partir du channel, éliminant toute possibilité de désynchronisation Cette approche suit le principe DRY et élimine une source de bugs. --- pmoparadise/src/client.rs | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/pmoparadise/src/client.rs b/pmoparadise/src/client.rs index 3ba5ca94..2f6b7795 100644 --- a/pmoparadise/src/client.rs +++ b/pmoparadise/src/client.rs @@ -9,9 +9,6 @@ use url::Url; /// Default Radio Paradise API base URL pub const DEFAULT_API_BASE: &str = "https://api.radioparadise.com/api"; -/// Default block base URL pattern -pub const DEFAULT_BLOCK_BASE: &str = "https://apps.radioparadise.com/blocks/chan/0"; - /// Default image base URL pub const DEFAULT_IMAGE_BASE: &str = "https://img.radioparadise.com/"; @@ -72,11 +69,12 @@ impl RadioParadiseClient { /// /// Useful for sharing HTTP connection pools or custom proxy settings pub fn with_client(client: Client) -> Self { + let channel = 0; Self { client, api_base: DEFAULT_API_BASE.to_string(), - block_base: DEFAULT_BLOCK_BASE.to_string(), - channel: 0, + block_base: Self::block_base_for_channel(channel), + channel, request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS), next_block_url: None, @@ -234,7 +232,6 @@ impl RadioParadiseClient { pub struct ClientBuilder { client: Option, api_base: String, - block_base: String, channel: u8, request_timeout: Duration, block_timeout: Duration, @@ -247,7 +244,6 @@ impl Default for ClientBuilder { Self { client: None, api_base: DEFAULT_API_BASE.to_string(), - block_base: DEFAULT_BLOCK_BASE.to_string(), channel: 0, request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS), @@ -275,15 +271,10 @@ impl ClientBuilder { self } - /// Set the block base URL - pub fn block_base(mut self, url: impl Into) -> Self { - self.block_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; + // block_base sera calculé dynamiquement dans build() à partir du channel self } @@ -329,11 +320,8 @@ impl ClientBuilder { builder.build()? }; - let block_base = if self.block_base == DEFAULT_BLOCK_BASE { - RadioParadiseClient::block_base_for_channel(self.channel) - } else { - self.block_base.clone() - }; + // Calculer block_base dynamiquement à partir du channel + let block_base = RadioParadiseClient::block_base_for_channel(self.channel); Ok(RadioParadiseClient { client, From 0a54db59639fc4383de9b24b10e69c118a733149 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 11:13:26 +0000 Subject: [PATCH 3/5] refactor: Replace block_base field with dynamic calculation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supprime complètement la duplication d'information en transformant block_base d'un champ stocké en une méthode calculée dynamiquement. Changements: - Supprimé le champ block_base de RadioParadiseClient - Ajouté la constante BLOCK_BASE_URL pour éviter la duplication de l'URL - Transformé block_base en méthode publique qui calcule à partir de channel - Simplifié with_client() et clone_with_channel() - Simplifié le builder qui n'a plus besoin d'initialiser block_base Cette approche garantit que block_base est toujours cohérent avec channel, éliminant définitivement toute possibilité de bug de synchronisation. --- pmoparadise/src/client.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/pmoparadise/src/client.rs b/pmoparadise/src/client.rs index 2f6b7795..3e6e4b4e 100644 --- a/pmoparadise/src/client.rs +++ b/pmoparadise/src/client.rs @@ -9,6 +9,9 @@ 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/"; @@ -45,7 +48,6 @@ pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0"; pub struct RadioParadiseClient { pub(crate) client: Client, api_base: String, - block_base: String, channel: u8, pub(crate) request_timeout: Duration, pub(crate) block_timeout: Duration, @@ -69,12 +71,10 @@ impl RadioParadiseClient { /// /// Useful for sharing HTTP connection pools or custom proxy settings pub fn with_client(client: Client) -> Self { - let channel = 0; Self { client, api_base: DEFAULT_API_BASE.to_string(), - block_base: Self::block_base_for_channel(channel), - channel, + channel: 0, request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS), block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS), next_block_url: None, @@ -86,15 +86,15 @@ impl RadioParadiseClient { self.channel } - fn block_base_for_channel(channel: u8) -> String { - format!("https://apps.radioparadise.com/blocks/chan/{}", 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.block_base = Self::block_base_for_channel(channel); cloned.next_block_url = None; cloned } @@ -320,13 +320,9 @@ impl ClientBuilder { builder.build()? }; - // Calculer block_base dynamiquement à partir du channel - let block_base = RadioParadiseClient::block_base_for_channel(self.channel); - Ok(RadioParadiseClient { client, api_base: self.api_base, - block_base, channel: self.channel, request_timeout: self.request_timeout, block_timeout: self.block_timeout, From bac4a94cade925d4feb3c4011c2b1611a46453f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 11:18:11 +0000 Subject: [PATCH 4/5] refactor: Eliminate remaining duplications in client.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrections : 1. Supprimé le commentaire obsolète sur block_base (ligne 277) 2. Créé la constante DEFAULT_CHANNEL pour éviter de coder "0" en dur 3. Utilisé DEFAULT_CHANNEL dans with_client(), ClientBuilder::default() et tests 4. Amélioré la documentation de with_client() pour guider vers le builder Bien que with_client() et ClientBuilder::default() aient encore une structure similaire, ils utilisent maintenant les mêmes constantes, réduisant ainsi le risque d'incohérence lors de modifications futures. --- pmoparadise/src/client.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/pmoparadise/src/client.rs b/pmoparadise/src/client.rs index 3e6e4b4e..48b87295 100644 --- a/pmoparadise/src/client.rs +++ b/pmoparadise/src/client.rs @@ -24,6 +24,9 @@ pub const DEFAULT_BLOCK_TIMEOUT_SECS: u64 = 180; /// 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, @@ -70,11 +73,14 @@ impl RadioParadiseClient { /// 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: 0, + 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, @@ -244,7 +250,7 @@ impl Default for ClientBuilder { Self { client: None, api_base: DEFAULT_API_BASE.to_string(), - channel: 0, + 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(), @@ -274,7 +280,6 @@ impl ClientBuilder { /// Set the channel (0 = main mix, 1 = mellow, 2 = rock, 3 = world/etc) pub fn channel(mut self, channel: u8) -> Self { self.channel = channel; - // block_base sera calculé dynamiquement dans build() à partir du channel self } @@ -339,6 +344,6 @@ mod tests { fn test_builder_defaults() { let builder = ClientBuilder::default(); assert_eq!(builder.api_base, DEFAULT_API_BASE); - assert_eq!(builder.channel, 0); + assert_eq!(builder.channel, DEFAULT_CHANNEL); } } From 98bf45cd279ba2a9220dc319a1eeb6deec3f9c3b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 11:21:33 +0000 Subject: [PATCH 5/5] feat: Add user-friendly default_channel configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la possibilité de configurer le channel par défaut de Radio Paradise de manière persistante et user-friendly. Fonctionnalités : - get_paradise_default_channel() : récupère le channel configuré (défaut: 0/main) - set_paradise_default_channel(u8) : définit le channel par défaut - Accepte DEUX formats dans le fichier YAML : * Noms conviviaux : "main", "mellow", "rock", "eclectic" * IDs numériques : 0, 1, 2, 3 - Stocke les valeurs comme chaînes conviviales pour la lisibilité - Validation automatique avec fallback sur "main" si invalide - Persistence automatique de la valeur par défaut lors du premier accès Exemple de configuration YAML : ```yaml sources: radio_paradise: enabled: true default_channel: mellow # ou 1 ``` Cette amélioration rend la configuration plus accessible aux utilisateurs qui préfèrent un channel autre que Main Mix par défaut. --- pmoparadise/src/config_ext.rs | 111 ++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/pmoparadise/src/config_ext.rs b/pmoparadise/src/config_ext.rs index c2303595..49becbb5 100644 --- a/pmoparadise/src/config_ext.rs +++ b/pmoparadise/src/config_ext.rs @@ -21,6 +21,7 @@ //! } //! ``` +use crate::{channels::ParadiseChannelKind, client::DEFAULT_CHANNEL}; use anyhow::Result; use pmoconfig::Config; use serde_yaml::Value; @@ -81,6 +82,60 @@ pub trait RadioParadiseConfigExt { /// 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 { @@ -101,6 +156,62 @@ impl RadioParadiseConfigExt for Config { 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)]