diff --git a/Cargo.lock b/Cargo.lock index 5187a837..98ae9a4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "PMOMusic" -version = "0.3.61" +version = "0.3.62" dependencies = [ "axum 0.8.7", "console-subscriber", diff --git a/PMOMusic/Cargo.toml b/PMOMusic/Cargo.toml index f1dae798..ada86030 100644 --- a/PMOMusic/Cargo.toml +++ b/PMOMusic/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "PMOMusic" -version = "0.3.61" +version = "0.3.62" edition = "2024" [dependencies] diff --git a/pmomediaserver/src/paradise_streaming.rs b/pmomediaserver/src/paradise_streaming.rs index b90f1658..a2d87668 100644 --- a/pmomediaserver/src/paradise_streaming.rs +++ b/pmomediaserver/src/paradise_streaming.rs @@ -20,7 +20,7 @@ use pmoaudiocache::{AudioCacheExt, get_audio_cache, register_audio_cache}; use pmocovers::{CoverCacheExt, get_cover_cache, register_cover_cache}; use pmoparadise::{ ParadiseChannelManager, ParadiseHistoryBuilder, - channels::{ALL_CHANNELS, ChannelDescriptor}, + channels::{ChannelDescriptor, channels}, stream_channel::register_global_channel_manager, }; use pmoplaylist::register_audio_cache as register_playlist_audio_cache; @@ -47,7 +47,7 @@ pub trait ParadiseStreamingExt { /// /// # Routes créées /// - /// Pour chaque canal (main, mellow, rock, eclectic) : + /// Pour chaque canal connu du registre (main, mellow, rock, eclectic, beyond, ...) : /// - `/radioparadise/stream/{slug}/flac` - Stream FLAC live /// - `/radioparadise/stream/{slug}/ogg` - Stream OGG live /// - `/radioparadise/stream/{slug}/historic/{client_id}/flac` - Historique FLAC @@ -157,10 +157,10 @@ impl ParadiseStreamingExt for pmoserver::Server { manager: manager.clone(), }); - // Ajouter les routes pour chaque canal + // Ajouter les routes pour chaque canal (registre rafraîchi par le manager) info!("🌐 Registering streaming routes..."); - for descriptor in ALL_CHANNELS.iter() { - let slug = descriptor.slug; + for descriptor in channels().iter() { + let slug = descriptor.slug.as_str(); let channel_id = descriptor.id; // Route FLAC live @@ -243,7 +243,7 @@ impl ParadiseStreamingExt for pmoserver::Server { async fn stream_flac( manager: Arc, - channel_id: u8, + channel_id: u16, ) -> Result { let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let stream = channel.subscribe_flac(); @@ -259,7 +259,7 @@ async fn stream_flac( async fn stream_ogg( manager: Arc, - channel_id: u8, + channel_id: u16, ) -> Result { let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let stream = channel.subscribe_ogg(); @@ -275,7 +275,7 @@ async fn stream_ogg( async fn get_metadata( manager: Arc, - channel_id: u8, + channel_id: u16, ) -> Result { let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let metadata = channel.metadata().await; @@ -284,7 +284,7 @@ async fn get_metadata( async fn stream_history_flac( manager: Arc, - channel_id: u8, + channel_id: u16, client_id: String, ) -> Result { let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; @@ -307,7 +307,7 @@ async fn stream_history_flac( async fn stream_history_ogg( manager: Arc, - channel_id: u8, + channel_id: u16, client_id: String, ) -> Result { let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; @@ -347,10 +347,8 @@ fn spawn_playlist_event_handler(manager: Arc) { }); } -fn channel_from_live_playlist(playlist_id: &str) -> Option<&'static ChannelDescriptor> { +fn channel_from_live_playlist(playlist_id: &str) -> Option { const PREFIX: &str = "radio-paradise-live-"; let slug = playlist_id.strip_prefix(PREFIX)?; - ALL_CHANNELS - .iter() - .find(|descriptor| descriptor.slug == slug) + pmoparadise::channels::channel_by_slug(slug) } diff --git a/pmoparadise/examples/download_block.rs b/pmoparadise/examples/download_block.rs index d5273cea..6e3f5943 100644 --- a/pmoparadise/examples/download_block.rs +++ b/pmoparadise/examples/download_block.rs @@ -53,7 +53,7 @@ async fn main() -> Result<(), Box> { std::process::exit(1); } - let channel_id: u8 = match args[1].parse() { + let channel_id: u16 = match args[1].parse() { Ok(id) => id, Err(_) => { eprintln!("Error: channel_id must be a number between 0 and 3"); diff --git a/pmoparadise/examples/play_and_cache.rs b/pmoparadise/examples/play_and_cache.rs index a55c782e..6caf1988 100644 --- a/pmoparadise/examples/play_and_cache.rs +++ b/pmoparadise/examples/play_and_cache.rs @@ -74,7 +74,7 @@ async fn main() -> Result<(), Box> { std::process::exit(1); } - let channel_id: u8 = match args[1].parse() { + let channel_id: u16 = match args[1].parse() { Ok(id) if id <= 3 => id, _ => { eprintln!("Error: channel_id must be a number between 0 and 3"); diff --git a/pmoparadise/examples/serve_channels.rs b/pmoparadise/examples/serve_channels.rs index da56c96a..ac45961e 100644 --- a/pmoparadise/examples/serve_channels.rs +++ b/pmoparadise/examples/serve_channels.rs @@ -26,7 +26,7 @@ use pmoaudiocache::{ 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 pmoparadise::{channels::channels, ParadiseChannelManager, ParadiseHistoryBuilder}; use pmoplaylist::register_audio_cache as register_playlist_audio_cache; use pmoserver::{init_logging, ServerBuilder}; use tokio_util::io::ReaderStream; @@ -80,8 +80,8 @@ async fn main() -> anyhow::Result<()> { let mut server = ServerBuilder::new("RadioParadiseChannels", "http://localhost", 8080).build(); - for descriptor in ALL_CHANNELS.iter() { - let slug = descriptor.slug; + for descriptor in channels().iter() { + let slug = descriptor.slug.as_str(); let flac_path = format!("/radioparadise/stream/{}/flac", slug); let ogg_path = format!("/radioparadise/stream/{}/ogg", slug); let icy_path = format!("/radioparadise/stream/{}/icy", slug); @@ -161,7 +161,7 @@ async fn main() -> anyhow::Result<()> { info!("========================================"); info!("Radio Paradise streaming server running on http://localhost:8080"); info!("Available channels:"); - for descriptor in ALL_CHANNELS.iter() { + for descriptor in channels().iter() { info!( " {}: /radioparadise/stream/{}/flac (also /ogg, /icy, metadata, /historic//(flac|ogg))", descriptor.display_name, descriptor.slug @@ -177,7 +177,7 @@ async fn main() -> anyhow::Result<()> { async fn stream_flac( manager: Arc, - channel_id: u8, + channel_id: u16, ) -> Result { let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let stream = channel.subscribe_flac(); @@ -193,7 +193,7 @@ async fn stream_flac( async fn stream_ogg( manager: Arc, - channel_id: u8, + channel_id: u16, ) -> Result { let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let stream = channel.subscribe_ogg(); @@ -209,7 +209,7 @@ async fn stream_ogg( async fn stream_icy( manager: Arc, - channel_id: u8, + channel_id: u16, ) -> Result { let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let stream = channel.subscribe_icy(); @@ -226,7 +226,7 @@ async fn stream_icy( async fn get_metadata( manager: Arc, - channel_id: u8, + channel_id: u16, ) -> Result { let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let metadata = channel.metadata().await; @@ -235,7 +235,7 @@ async fn get_metadata( async fn stream_history_flac( manager: Arc, - channel_id: u8, + channel_id: u16, client_id: String, ) -> Result { let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; @@ -258,7 +258,7 @@ async fn stream_history_flac( async fn stream_history_ogg( manager: Arc, - channel_id: u8, + channel_id: u16, client_id: String, ) -> Result { let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; diff --git a/pmoparadise/examples/single_channel_server.rs b/pmoparadise/examples/single_channel_server.rs index c83b3475..d7f00b87 100644 --- a/pmoparadise/examples/single_channel_server.rs +++ b/pmoparadise/examples/single_channel_server.rs @@ -25,7 +25,7 @@ use pmocovers::{ new_cache_with_consolidation as new_cover_cache, register_cover_cache, Cache as CoverCache, }; use pmoparadise::{ - channels::{ChannelDescriptor, ALL_CHANNELS}, + channels::{channels, resolve_channel, ChannelDescriptor}, ParadiseHistoryBuilder, ParadiseStreamChannel, ParadiseStreamChannelConfig, }; use pmoplaylist::register_audio_cache as register_playlist_audio_cache; @@ -90,7 +90,7 @@ async fn main() -> anyhow::Result<()> { let channel = Arc::new( ParadiseStreamChannel::new( - descriptor, + descriptor.clone(), channel_config, Some(cover_cache.clone()), Some(history_opts), @@ -227,15 +227,8 @@ async fn get_cover( 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}"); + return resolve_channel(&token) + .ok_or_else(|| anyhow::anyhow!("Unknown channel identifier: {token}")); } - Ok(ALL_CHANNELS[0]) + Ok(channels()[0].clone()) } diff --git a/pmoparadise/examples/stream_block.rs b/pmoparadise/examples/stream_block.rs index d7849d63..bd4a4a0f 100644 --- a/pmoparadise/examples/stream_block.rs +++ b/pmoparadise/examples/stream_block.rs @@ -150,7 +150,7 @@ async fn main() -> Result<(), Box> { std::process::exit(1); } - let channel_id: u8 = match args[1].parse() { + let channel_id: u16 = match args[1].parse() { Ok(id) if id <= 3 => id, _ => { eprintln!("Error: channel_id must be a number between 0 and 3"); diff --git a/pmoparadise/src/channels.rs b/pmoparadise/src/channels.rs index 2c1779ef..60f1705c 100644 --- a/pmoparadise/src/channels.rs +++ b/pmoparadise/src/channels.rs @@ -1,103 +1,187 @@ //! Radio Paradise channel definitions //! -//! This module defines the available Radio Paradise channels and their metadata. +//! This module maintains a dynamic registry of the available Radio Paradise +//! channels. The registry is initialized with a built-in default list and can +//! be refreshed at runtime from the `list_chan` API endpoint via +//! [`refresh_channels`], so newly added channels (Beyond, Serenity, KFAT, ...) +//! are picked up without a code change. +//! +//! Channel IDs are not contiguous (0, 1, 2, 3, 5, 42, 945...): never iterate +//! over an ID range, always go through [`channels`]. -use std::str::FromStr; +use std::sync::{Arc, RwLock}; -/// 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)), - } - } -} +use once_cell::sync::Lazy; +use serde::Deserialize; /// Metadata descriptor for a channel. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ChannelDescriptor { - pub kind: ParadiseChannelKind, - pub id: u8, - pub slug: &'static str, - pub display_name: &'static str, - pub description: &'static str, + /// Channel ID as used by the RP API (`chan` parameter). Not contiguous. + pub id: u16, + /// Stable identifier used in playlist IDs, config paths, routes and UPnP + /// object IDs. Legacy slugs are preserved for channels 0-3 so existing + /// persisted playlists and configuration keep working. + pub slug: String, + /// Human-readable channel name. + pub display_name: String, + /// Short description of the channel. + pub description: String, + /// Cover image URL provided by the API, if any. + pub image: Option, } impl ChannelDescriptor { - pub const fn new(kind: ParadiseChannelKind) -> Self { + fn new_static(id: u16, slug: &str, display_name: &str, description: &str) -> Self { Self { - id: kind.id(), - slug: kind.slug(), - display_name: kind.display_name(), - description: kind.description(), - kind, + id, + slug: slug.to_string(), + display_name: display_name.to_string(), + description: description.to_string(), + // Stable URL pattern observed on img.radioparadise.com; the value + // is overwritten by the API-provided one after refresh_channels(). + image: Some(format!( + "https://img.radioparadise.com/channels/0/{}/cover_512x512/0.jpg", + id + )), } } } -/// 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), -]; +/// Legacy slugs for the historical channels (0-3). +/// +/// Playlist IDs, config paths and UPnP object IDs are derived from the slug, +/// so the original slugs must be preserved even though the API now reports +/// different `stream_name`s ("main-mix", "global", ...). +fn legacy_slug(id: u16) -> Option<&'static str> { + match id { + 0 => Some("main"), + 1 => Some("mellow"), + 2 => Some("rock"), + 3 => Some("eclectic"), + _ => None, + } +} -/// Returns the maximum valid channel ID -pub const fn max_channel_id() -> u8 { - (ALL_CHANNELS.len() - 1) as u8 +/// Built-in channel list, used as fallback when the API cannot be reached. +/// +/// Snapshot of the `list_chan` endpoint (2026-07), with legacy slugs for 0-3. +pub fn default_channels() -> Vec { + vec![ + ChannelDescriptor::new_static( + 0, + "main", + "The Main Mix", + "Eclectic mix of rock, world, electronica, and more", + ), + ChannelDescriptor::new_static(1, "mellow", "Mellow Mix", "Mellower, less aggressive music"), + ChannelDescriptor::new_static(2, "rock", "RockIt!", "Heavier, more guitar-driven music"), + ChannelDescriptor::new_static(3, "eclectic", "The Globe", "Curated worldwide selection"), + ChannelDescriptor::new_static(5, "beyond", "Beyond...", "Adventurous, exploratory music"), + ChannelDescriptor::new_static( + 42, + "serenity", + "Serenity", + "Generative ambient soundscapes", + ), + ChannelDescriptor::new_static(945, "kfat", "KFAT", "Americana, blues and country"), + ] +} + +static CHANNEL_REGISTRY: Lazy>>> = + Lazy::new(|| RwLock::new(Arc::new(default_channels()))); + +/// Snapshot of the currently known channels. +/// +/// Returns the built-in defaults until [`refresh_channels`] has succeeded. +pub fn channels() -> Arc> { + CHANNEL_REGISTRY + .read() + .expect("channel registry poisoned") + .clone() +} + +/// Look up a channel by its API ID. +pub fn channel_by_id(id: u16) -> Option { + channels().iter().find(|ch| ch.id == id).cloned() +} + +/// Look up a channel by its slug. +pub fn channel_by_slug(slug: &str) -> Option { + channels().iter().find(|ch| ch.slug == slug).cloned() +} + +/// Resolve a channel from a user-supplied string: slug or numeric ID. +pub fn resolve_channel(s: &str) -> Option { + let s = s.trim(); + if let Ok(id) = s.parse::() { + return channel_by_id(id); + } + channel_by_slug(&s.to_ascii_lowercase()) +} + +/// Raw channel entry as returned by the `list_chan` API endpoint. +#[derive(Debug, Deserialize)] +pub(crate) struct ApiChannel { + pub chan: String, + pub title: String, + pub stream_name: String, + #[serde(rename = "type")] + pub channel_type: String, + #[serde(default)] + pub image: Option, +} + +impl ApiChannel { + /// Convert to a descriptor. Returns `None` for entries our block-based + /// pipeline cannot play (non-"block" channels) or with an unparsable ID. + pub(crate) fn into_descriptor(self) -> Option { + if self.channel_type != "block" { + return None; + } + let id: u16 = self.chan.parse().ok()?; + let slug = legacy_slug(id) + .map(str::to_string) + .unwrap_or(self.stream_name); + Some(ChannelDescriptor { + id, + slug, + // The API provides no description; reuse the title. + description: self.title.clone(), + display_name: self.title, + image: self.image, + }) + } +} + +/// Refresh the channel registry from the Radio Paradise API. +/// +/// On success the registry is replaced with the fetched list and the new +/// snapshot is returned. On failure the registry is left untouched (built-in +/// defaults or previous successful fetch). +pub async fn refresh_channels( + client: &crate::client::RadioParadiseClient, +) -> crate::error::Result>> { + let fetched = client.list_channels().await?; + if fetched.is_empty() { + return Err(crate::error::Error::other( + "list_chan returned no playable channel", + )); + } + let snapshot = Arc::new(fetched); + *CHANNEL_REGISTRY + .write() + .expect("channel registry poisoned") = snapshot.clone(); + tracing::info!( + "Radio Paradise channel registry refreshed: {} channels ({})", + snapshot.len(), + snapshot + .iter() + .map(|ch| ch.slug.as_str()) + .collect::>() + .join(", ") + ); + Ok(snapshot) } /// Default maximum number of tracks to keep in history @@ -111,33 +195,63 @@ 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); + fn test_default_channels_have_legacy_slugs() { + let channels = default_channels(); + assert_eq!(channels[0].slug, "main"); + assert_eq!(channels[1].slug, "mellow"); + assert_eq!(channels[2].slug, "rock"); + assert_eq!(channels[3].slug, "eclectic"); } #[test] - fn test_max_channel_id() { - assert_eq!(max_channel_id(), 3); + fn test_default_channels_include_new_channels() { + let channels = default_channels(); + assert!(channels.iter().any(|ch| ch.id == 5 && ch.slug == "beyond")); + assert!(channels.iter().any(|ch| ch.id == 42 && ch.slug == "serenity")); + assert!(channels.iter().any(|ch| ch.id == 945 && ch.slug == "kfat")); } #[test] - fn test_all_channels_length() { - assert_eq!(ALL_CHANNELS.len(), 4); + fn test_resolve_channel() { + assert_eq!(resolve_channel("main").map(|ch| ch.id), Some(0)); + assert_eq!(resolve_channel("0").map(|ch| ch.id), Some(0)); + assert_eq!(resolve_channel("MELLOW").map(|ch| ch.id), Some(1)); + assert_eq!(resolve_channel("945").map(|ch| ch.slug), Some("kfat".to_string())); + assert!(resolve_channel("invalid").is_none()); + // IDs are sparse: 4 is not a channel + assert!(resolve_channel("4").is_none()); } #[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()); + fn test_api_channel_conversion() { + let api = ApiChannel { + chan: "3".to_string(), + title: "The Globe".to_string(), + stream_name: "global".to_string(), + channel_type: "block".to_string(), + image: None, + }; + let desc = api.into_descriptor().unwrap(); + // Legacy slug preserved for channel 3 + assert_eq!(desc.slug, "eclectic"); + assert_eq!(desc.display_name, "The Globe"); + + let api = ApiChannel { + chan: "945".to_string(), + title: "KFAT".to_string(), + stream_name: "kfat".to_string(), + channel_type: "block".to_string(), + image: None, + }; + assert_eq!(api.into_descriptor().unwrap().slug, "kfat"); + + let api = ApiChannel { + chan: "7".to_string(), + title: "Live Stream".to_string(), + stream_name: "live".to_string(), + channel_type: "live".to_string(), + image: None, + }; + assert!(api.into_descriptor().is_none()); } } diff --git a/pmoparadise/src/client.rs b/pmoparadise/src/client.rs index 601b5a18..f189df39 100644 --- a/pmoparadise/src/client.rs +++ b/pmoparadise/src/client.rs @@ -29,7 +29,7 @@ pub const DEFAULT_BLOCK_TIMEOUT_SECS: u64 = 7200; // 2 hours pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0"; /// Default channel (0 = main mix) -pub const DEFAULT_CHANNEL: u8 = 0; +pub const DEFAULT_CHANNEL: u16 = 0; /// Radio Paradise HTTP client /// @@ -55,7 +55,7 @@ pub const DEFAULT_CHANNEL: u8 = 0; pub struct RadioParadiseClient { pub(crate) client: Client, api_base: String, - channel: u8, + channel: u16, pub(crate) request_timeout: Duration, pub(crate) block_timeout: Duration, next_block_url: Option, @@ -92,7 +92,7 @@ impl RadioParadiseClient { } /// Get the current channel (0 = main mix) - pub fn channel(&self) -> u8 { + pub fn channel(&self) -> u16 { self.channel } @@ -102,7 +102,7 @@ impl RadioParadiseClient { } /// Clone the client with a different channel while preserving other settings. - pub fn clone_with_channel(&self, channel: u8) -> Self { + pub fn clone_with_channel(&self, channel: u16) -> Self { let mut cloned = self.clone(); cloned.channel = channel; cloned.next_block_url = None; @@ -234,6 +234,36 @@ impl RadioParadiseClient { pub fn http_client(&self) -> &Client { &self.client } + + /// List the channels currently advertised by the Radio Paradise API + /// + /// Only block-based channels (playable by this crate) are returned. + /// Use `channels::refresh_channels()` to update the global registry. + pub async fn list_channels(&self) -> Result> { + let url = Url::parse(&format!("{}/list_chan", self.api_base))?; + + debug!("Fetching channel list: {}", 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 raw: Vec = response.json().await?; + Ok(raw + .into_iter() + .filter_map(|ch| ch.into_descriptor()) + .collect()) + } } /// Builder for configuring a RadioParadiseClient @@ -241,7 +271,7 @@ impl RadioParadiseClient { pub struct ClientBuilder { client: Option, api_base: String, - channel: u8, + channel: u16, request_timeout: Duration, block_timeout: Duration, user_agent: String, @@ -280,8 +310,8 @@ impl ClientBuilder { self } - /// Set the channel (0 = main mix, 1 = mellow, 2 = rock, 3 = world/etc) - pub fn channel(mut self, channel: u8) -> Self { + /// Set the channel (see `channels::channels()` for the available IDs) + pub fn channel(mut self, channel: u16) -> Self { self.channel = channel; self } diff --git a/pmoparadise/src/config_ext.rs b/pmoparadise/src/config_ext.rs index 49becbb5..20798de0 100644 --- a/pmoparadise/src/config_ext.rs +++ b/pmoparadise/src/config_ext.rs @@ -21,7 +21,10 @@ //! } //! ``` -use crate::{channels::ParadiseChannelKind, client::DEFAULT_CHANNEL}; +use crate::{ + channels::{channel_by_id, resolve_channel}, + client::DEFAULT_CHANNEL, +}; use anyhow::Result; use pmoconfig::Config; use serde_yaml::Value; @@ -94,11 +97,10 @@ pub trait RadioParadiseConfigExt { /// /// # 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) + /// Peut être configuré comme chaîne de caractères (slug) ou nombre (ID). + /// La liste des canaux est dynamique (voir `channels::channels()`) : + /// par exemple "main"/0, "mellow"/1, "rock"/2, "eclectic"/3, "beyond"/5, + /// "serenity"/42, "kfat"/945. /// /// # Exemple de configuration YAML /// @@ -114,13 +116,13 @@ pub trait RadioParadiseConfigExt { /// let channel = config.get_paradise_default_channel()?; /// let client = RadioParadiseClient::builder().channel(channel).build().await?; /// ``` - fn get_paradise_default_channel(&self) -> Result; + fn get_paradise_default_channel(&self) -> Result; /// Définit le channel par défaut /// /// # Arguments /// - /// * `channel` - Le channel (0-3) + /// * `channel` - L'ID du channel (doit exister dans le registre de canaux) /// /// La valeur est stockée sous forme de nom convivial ("main", "mellow", etc.) /// dans le fichier de configuration. @@ -128,14 +130,10 @@ pub trait RadioParadiseConfigExt { /// # 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<()>; + fn set_paradise_default_channel(&self, channel: u16) -> Result<()>; } impl RadioParadiseConfigExt for Config { @@ -157,13 +155,13 @@ impl RadioParadiseConfigExt for Config { ) } - fn get_paradise_default_channel(&self) -> Result { + 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(_) => { + // Slug ("main", "mellow", ...) ou ID numérique en chaîne + match resolve_channel(&s) { + Some(descriptor) => Ok(descriptor.id), + None => { // Invalid channel name, use default self.set_paradise_default_channel(DEFAULT_CHANNEL)?; Ok(DEFAULT_CHANNEL) @@ -171,19 +169,18 @@ impl RadioParadiseConfigExt for Config { } } 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 { + // Accept numeric channel ID (must exist in the registry) + match n + .as_u64() + .and_then(|ch| u16::try_from(ch).ok()) + .and_then(channel_by_id) + { + Some(descriptor) => Ok(descriptor.id), + None => { // 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) } } _ => { @@ -197,19 +194,14 @@ impl RadioParadiseConfigExt for Config { } } - 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)), - }; + fn set_paradise_default_channel(&self, channel: u16) -> Result<()> { + // Convert channel ID to user-friendly slug + let descriptor = channel_by_id(channel) + .ok_or_else(|| anyhow::anyhow!("Invalid channel ID: {}", channel))?; self.set_value( &["sources", "radio_paradise", "default_channel"], - Value::String(channel_name.to_string()), + Value::String(descriptor.slug), ) } } diff --git a/pmoparadise/src/pmoserver_ext.rs b/pmoparadise/src/pmoserver_ext.rs index 4b95acb5..749a7ca8 100644 --- a/pmoparadise/src/pmoserver_ext.rs +++ b/pmoparadise/src/pmoserver_ext.rs @@ -3,7 +3,7 @@ //! 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::channels::{channel_by_id, channels, refresh_channels, ChannelDescriptor}; use crate::{Block, NowPlaying, RadioParadiseClient}; use async_trait::async_trait; use axum::{ @@ -26,7 +26,7 @@ pub struct RadioParadiseState { #[derive(Debug, Default, Deserialize)] #[serde(default)] struct ParadiseQuery { - channel: Option, + channel: Option, } impl RadioParadiseState { @@ -35,6 +35,15 @@ impl RadioParadiseState { .await .map_err(|e| anyhow::anyhow!("Failed to create RadioParadise client: {}", e))?; + // Mettre à jour le registre de canaux depuis l'API (fallback sur les + // canaux par défaut en cas d'échec réseau) + if let Err(e) = refresh_channels(&client).await { + tracing::warn!( + "Failed to refresh Radio Paradise channel list, using defaults: {}", + e + ); + } + Ok(Self { client: Arc::new(RwLock::new(client)), }) @@ -52,7 +61,7 @@ impl RadioParadiseState { let mut client = base_client; if let Some(channel) = params.channel { - if channel > max_channel_id() { + if channel_by_id(channel).is_none() { tracing::warn!("Invalid Radio Paradise channel requested: {}", channel); return Err(StatusCode::BAD_REQUEST); } @@ -66,20 +75,26 @@ impl RadioParadiseState { /// Information sur un canal Radio Paradise #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ChannelInfo { - /// ID du canal (0-3) - pub id: u8, + /// ID du canal (attention : IDs non contigus, ex. 0, 1, 2, 3, 5, 42, 945) + pub id: u16, + /// Slug du canal ("main", "mellow", "beyond", ...) + pub slug: String, /// Nom du canal pub name: String, /// Description pub description: String, + /// Route locale de l'image du canal (servie par le cache covers) + pub image: Option, } 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(), + slug: descriptor.slug.clone(), + name: descriptor.display_name.clone(), + description: descriptor.description.clone(), + image: descriptor.image.clone(), } } } @@ -251,7 +266,7 @@ impl From for NowPlayingResponse { get, path = "/now-playing", params( - ("channel" = Option, Query, description = "Channel ID (0-3)") + ("channel" = Option, Query, description = "Channel ID (voir /channels)") ), responses( (status = 200, description = "Morceau en cours", body = NowPlayingResponse), @@ -277,7 +292,7 @@ async fn get_now_playing( get, path = "/block/current", params( - ("channel" = Option, Query, description = "Channel ID (0-3)") + ("channel" = Option, Query, description = "Channel ID (voir /channels)") ), responses( (status = 200, description = "Block actuel", body = BlockResponse), @@ -304,7 +319,7 @@ async fn get_current_block( path = "/block/{event_id}", params( ("event_id" = u64, Path, description = "Event ID du block"), - ("channel" = Option, Query, description = "Channel ID (0-3)") + ("channel" = Option, Query, description = "Channel ID (voir /channels)") ), responses( (status = 200, description = "Block demandé", body = BlockResponse), @@ -340,8 +355,27 @@ async fn get_block_by_id( tag = "Radio Paradise" )] async fn get_channels() -> Json> { - let channels: Vec = ALL_CHANNELS.iter().map(Into::into).collect(); - Json(channels) + let cover_cache = pmocovers::get_cover_cache(); + let mut list = Vec::new(); + for descriptor in channels().iter() { + let mut info: ChannelInfo = descriptor.into(); + // Toutes les images transitent par le cache covers local : on expose + // la route du cache, jamais l'URL externe img.radioparadise.com + info.image = match (&descriptor.image, &cover_cache) { + (Some(url), Some(cache)) => { + match cache.add_from_url(url, Some("radioparadise-channels")).await { + Ok(pk) => Some(pmocache::covers_route_for(&pk, None)), + Err(e) => { + tracing::warn!("Failed to cache channel image {}: {}", url, e); + None + } + } + } + _ => None, + }; + list.push(info); + } + Json(list) } /// GET /block/{event_id}/song/{index} - Récupère un morceau spécifique d'un block @@ -351,7 +385,7 @@ async fn get_channels() -> Json> { 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)") + ("channel" = Option, Query, description = "Channel ID (voir /channels)") ), responses( (status = 200, description = "Morceau demandé", body = SongInfo), @@ -404,7 +438,7 @@ async fn get_song_by_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)") + ("channel" = Option, Query, description = "Channel ID (voir /channels)") ), responses( (status = 200, description = "URL de la pochette avec fallback automatique", body = CoverUrlResponse), @@ -456,7 +490,7 @@ async fn get_cover_url( 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)") + ("channel" = Option, Query, description = "Channel ID (voir /channels)") ), responses( (status = 200, description = "URL de streaming", body = StreamUrlResponse), @@ -500,17 +534,15 @@ 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) +- **Multi-canaux** : Support de tous les canaux Radio Paradise (liste dynamique) - **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 +La liste des canaux est récupérée dynamiquement depuis l'API Radio Paradise +(`GET /channels`). Attention : les IDs ne sont pas contigus (ex. 0, 1, 2, 3, 5, 42, 945). ## Format des données diff --git a/pmoparadise/src/source.rs b/pmoparadise/src/source.rs index d2f804f5..cec9241d 100644 --- a/pmoparadise/src/source.rs +++ b/pmoparadise/src/source.rs @@ -1,9 +1,10 @@ //! 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. +//! exposing live streams and historical playlists for every channel known +//! to the dynamic channel registry (see `crate::channels`). -use crate::channels::{ChannelDescriptor, ALL_CHANNELS}; +use crate::channels::{channel_by_slug, channels, ChannelDescriptor}; use pmosource::pmodidl::{Container, Item, Resource}; use pmosource::{ async_trait, AudioFormat, BrowseResult, MusicSource, MusicSourceError, Result, @@ -27,7 +28,7 @@ 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) +/// - Live FLAC streams for every channel in the registry (Main, Mellow, Rock, Eclectic, Beyond, ...) /// - Historical playlists (FIFO) for each channel /// /// # Object ID Schema @@ -116,12 +117,12 @@ impl RadioParadiseSource { use pmoplaylist::PlaylistManager; // Préparer les IDs de playlists à surveiller (live + history pour chaque canal) - let ids: Vec = ALL_CHANNELS + let ids: Vec = channels() .iter() .flat_map(|ch| { vec![ - Self::live_playlist_id(ch.slug), - Self::history_playlist_id(ch.slug), + Self::live_playlist_id(&ch.slug), + Self::history_playlist_id(&ch.slug), ] }) .collect(); @@ -151,20 +152,21 @@ impl RadioParadiseSource { tokio::spawn(async move { strong.bump_update_counter().await; // Notifier ContentDirectory des conteneurs concernés + let known_channels = channels(); let containers: Vec = if pid.contains("history") { // history playlist -> container history - ALL_CHANNELS + known_channels .iter() - .find(|ch| pid.ends_with(ch.slug)) + .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 + known_channels .iter() - .find(|ch| pid.ends_with(ch.slug)) + .find(|ch| pid.ends_with(&ch.slug)) .map(|ch| { vec![format!( "radio-paradise:channel:{}:liveplaylist", @@ -192,6 +194,26 @@ impl RadioParadiseSource { format!("{}/api/sources/{}/image", self.base_url, self.id()) } + /// Résout l'image d'un canal en URL locale servie par le cache covers. + /// + /// Toutes les images transitent par pmocovers : aucune URL externe ne doit + /// apparaître dans les métadonnées UPnP. En cas de cache indisponible ou + /// d'échec de téléchargement, fallback sur l'image par défaut de la source. + async fn channel_art_url(&self, descriptor: &ChannelDescriptor) -> String { + if let (Some(url), Some(cache)) = (descriptor.image.as_ref(), pmocovers::get_cover_cache()) + { + match cache.add_from_url(url, Some("radioparadise-channels")).await { + Ok(pk) => { + return format!("{}{}", self.base_url, pmocache::covers_route_for(&pk, None)); + } + Err(e) => { + tracing::warn!("Failed to cache channel image {}: {}", url, e); + } + } + } + self.default_cover_url() + } + /// 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); @@ -212,11 +234,27 @@ impl RadioParadiseSource { // 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()); // Stocker la route relative (le handler REST appliquera base_url.url_for()) - let cover_url = cover_pk + let mut cover_url = cover_pk .as_ref() - .map(|pk| pmocache::covers_route_for(pk, None)) - .or_else(|| json["cover_url"].as_str().map(|s| s.to_string())) - .or_else(|| Some(self.default_cover_url())); + .map(|pk| pmocache::covers_route_for(pk, None)); + if cover_url.is_none() { + // Pas de pk : faire transiter l'URL externe par le cache covers + if let (Some(remote), Some(cache)) = + (json["cover_url"].as_str(), pmocovers::get_cover_cache()) + { + match cache.add_from_url(remote, Some("radioparadise")).await { + Ok(pk) => { + cover_url = Some(pmocache::covers_route_for(&pk, None)) + } + Err(e) => tracing::warn!( + "Failed to cache live cover {}: {}", + remote, + e + ), + } + } + } + let cover_url = cover_url.or_else(|| Some(self.default_cover_url())); // Parse duration from JSON (in seconds as a float) let duration = json["duration"] @@ -318,8 +356,8 @@ impl RadioParadiseSource { } /// Get channel descriptor by slug - fn get_channel_by_slug(slug: &str) -> Option<&'static ChannelDescriptor> { - ALL_CHANNELS.iter().find(|ch| ch.slug == slug) + fn get_channel_by_slug(slug: &str) -> Option { + channel_by_slug(slug) } /// Parse an object ID into its components @@ -356,7 +394,7 @@ impl RadioParadiseSource { } /// Build a channel container - fn build_channel_container(&self, descriptor: &ChannelDescriptor) -> Container { + async fn build_channel_container(&self, descriptor: &ChannelDescriptor) -> Container { Container { id: format!("radio-paradise:channel:{}", descriptor.slug), parent_id: "radio-paradise".to_string(), @@ -366,14 +404,14 @@ impl RadioParadiseSource { title: descriptor.display_name.to_string(), class: "object.container".to_string(), artist: None, - album_art: None, + album_art: Some(self.channel_art_url(descriptor).await), containers: vec![], items: vec![], } } /// Build the live playlist container for a channel - fn build_live_playlist_container(&self, descriptor: &ChannelDescriptor) -> Container { + async 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), @@ -383,15 +421,15 @@ impl RadioParadiseSource { title: format!("{} - Live Playlist", descriptor.display_name), class: "object.container.playlistContainer".to_string(), artist: None, - album_art: None, + album_art: Some(self.channel_art_url(descriptor).await), 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); + async 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), @@ -403,7 +441,7 @@ impl RadioParadiseSource { 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: Some(self.channel_art_url(descriptor).await), album_art_pk: None, date: None, original_track_number: None, @@ -422,7 +460,7 @@ impl RadioParadiseSource { sample_frequency: Some("44100".to_string()), nr_audio_channels: Some("2".to_string()), duration: None, - url: self.build_live_ogg_url(descriptor.slug), + url: self.build_live_ogg_url(&descriptor.slug), }, ], descriptions: vec![], @@ -430,7 +468,7 @@ impl RadioParadiseSource { } /// Build a history container for a channel - fn build_history_container(&self, descriptor: &ChannelDescriptor) -> Container { + async 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), @@ -441,7 +479,7 @@ impl RadioParadiseSource { // Expose l'historique comme une playlist jouable class: "object.container.playlistContainer".to_string(), artist: None, - album_art: None, + album_art: Some(self.channel_art_url(descriptor).await), containers: vec![], items: vec![], } @@ -453,10 +491,10 @@ impl RadioParadiseSource { &self, descriptor: &ChannelDescriptor, ) -> Container { - let mut container = self.build_history_container(descriptor); + let mut container = self.build_history_container(descriptor).await; // Try to get actual count from playlist - let playlist_id = Self::history_playlist_id(descriptor.slug); + let playlist_id = Self::history_playlist_id(&descriptor.slug); let manager = pmoplaylist::PlaylistManager(); if let Ok(reader) = manager.get_read_handle(&playlist_id).await { @@ -663,11 +701,11 @@ impl MusicSource for RadioParadiseSource { 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(); + // Return one container per known channel + let mut containers = Vec::new(); + for ch in channels().iter() { + containers.push(self.build_channel_container(ch).await); + } Ok(BrowseResult::Containers(containers)) } @@ -678,13 +716,13 @@ impl MusicSource for RadioParadiseSource { 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); + let live_item = self.build_live_stream_item(&descriptor).await; + let live_playlist_container = self.build_live_playlist_container(&descriptor).await; #[cfg(feature = "playlist")] - let history_container = self.build_history_container_with_count(descriptor).await; + let history_container = self.build_history_container_with_count(&descriptor).await; #[cfg(not(feature = "playlist"))] - let history_container = self.build_history_container(descriptor); + let history_container = self.build_history_container(&descriptor).await; Ok(BrowseResult::Mixed { containers: vec![live_playlist_container, history_container], @@ -702,7 +740,7 @@ impl MusicSource for RadioParadiseSource { #[cfg(feature = "playlist")] { let history_container = - self.build_history_container_with_count(descriptor).await; + 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], @@ -713,7 +751,7 @@ impl MusicSource for RadioParadiseSource { #[cfg(not(feature = "playlist"))] { // If playlist feature is disabled, return just the container - let history_container = self.build_history_container(descriptor); + let history_container = self.build_history_container(&descriptor).await; Ok(BrowseResult::Containers(vec![history_container])) } } @@ -723,7 +761,7 @@ impl MusicSource for RadioParadiseSource { 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); + let item = self.build_live_stream_item(&descriptor).await; Ok(BrowseResult::Items(vec![item])) } @@ -735,7 +773,7 @@ impl MusicSource for RadioParadiseSource { #[cfg(feature = "playlist")] { - let container = self.build_live_playlist_container(descriptor); + let container = self.build_live_playlist_container(&descriptor).await; let items = self.get_live_playlist_items(&slug, 0, 100).await?; Ok(BrowseResult::Mixed { containers: vec![container], @@ -745,7 +783,7 @@ impl MusicSource for RadioParadiseSource { #[cfg(not(feature = "playlist"))] { - let container = self.build_live_playlist_container(descriptor); + let container = self.build_live_playlist_container(&descriptor).await; Ok(BrowseResult::Containers(vec![container])) } } @@ -875,7 +913,7 @@ impl MusicSource for RadioParadiseSource { let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) })?; - Ok(self.build_live_stream_item(descriptor)) + Ok(self.build_live_stream_item(&descriptor).await) } ObjectIdType::HistoryTrack { slug, pk } => { diff --git a/pmoparadise/src/stream_channel.rs b/pmoparadise/src/stream_channel.rs index 4bd90ae4..d3be1ee4 100644 --- a/pmoparadise/src/stream_channel.rs +++ b/pmoparadise/src/stream_channel.rs @@ -16,7 +16,7 @@ use std::{ }; use crate::{ - channels::{ChannelDescriptor, ParadiseChannelKind, ALL_CHANNELS}, + channels::{channels, refresh_channels, ChannelDescriptor}, client::RadioParadiseClient, models::{Block, EventId}, playlist_feeder::RadioParadisePlaylistFeeder, @@ -133,13 +133,13 @@ impl Default for ParadiseHistoryBuilder { #[cfg(feature = "pmoconfig")] impl ParadiseStreamChannelConfig { - pub fn from_config(cfg: &pmoconfig::Config, channel: ParadiseChannelKind) -> Self { + pub fn from_config(cfg: &pmoconfig::Config, channel_slug: &str) -> Self { use serde_yaml::Value; let path = [ "sources", "radio_paradise", "channels", - channel.slug(), + channel_slug, "max_lead_seconds", ]; match cfg.get_value(&path) { @@ -303,10 +303,10 @@ impl ParadiseStreamChannel { // 6. Lancer le pipeline audio let stop_token = CancellationToken::new(); let pipeline_stop = stop_token.clone(); - let channel_display_name = descriptor.display_name; + let channel_display_name = descriptor.display_name.clone(); let state = Arc::new(ChannelState { - descriptor, + descriptor: descriptor.clone(), config, client, feeder: feeder.clone(), @@ -401,7 +401,7 @@ impl ParadiseStreamChannel { } pub fn descriptor(&self) -> ChannelDescriptor { - self.descriptor + self.descriptor.clone() } /// Lance un pipeline dédié pour rejouer l'historique (FLAC pur) pour un client. @@ -888,11 +888,11 @@ impl Drop for HistoryOggStream { /// Gestionnaire multi-canaux. pub struct ParadiseChannelManager { - channels: HashMap>, + channels: HashMap>, } impl ParadiseChannelManager { - pub fn new(channels: HashMap>) -> Self { + pub fn new(channels: HashMap>) -> Self { Self { channels } } @@ -901,13 +901,32 @@ impl ParadiseChannelManager { history_builder: Option, server_base_url: Option, ) -> Result { + // Rafraîchir la liste des canaux depuis l'API avant d'initialiser les + // pipelines (fallback sur le registre courant en cas d'échec réseau) + match RadioParadiseClient::new().await { + Ok(client) => { + if let Err(e) = refresh_channels(&client).await { + tracing::warn!( + "Failed to refresh Radio Paradise channel list, using current registry: {}", + e + ); + } + } + Err(e) => { + tracing::warn!( + "Failed to create Radio Paradise client for channel discovery: {}", + e + ); + } + } + let channel_list = channels(); tracing::info!( "➡️ Entering with_defaults_with_cover_cache ({} channels, base_url={:?})", - ALL_CHANNELS.len(), + channel_list.len(), server_base_url ); let mut map = HashMap::new(); - for descriptor in ALL_CHANNELS.iter().copied() { + for descriptor in channel_list.iter().cloned() { let mut config = ParadiseStreamChannelConfig::default(); config.server_base_url = server_base_url.clone(); @@ -940,7 +959,12 @@ impl ParadiseChannelManager { ); let channel = match tokio::time::timeout( Duration::from_secs(20), - ParadiseStreamChannel::new(descriptor, config, cover_cache.clone(), history_opts), + ParadiseStreamChannel::new( + descriptor.clone(), + config, + cover_cache.clone(), + history_opts, + ), ) .await { @@ -980,7 +1004,7 @@ impl ParadiseChannelManager { Self::with_defaults_with_cover_cache(None, None, None).await } - pub fn get(&self, id: u8) -> Option> { + pub fn get(&self, id: u16) -> Option> { self.channels.get(&id).cloned() } @@ -988,7 +1012,7 @@ impl ParadiseChannelManager { self.channels.values() } - pub async fn prefetch_until_horizon(&self, channel_id: u8) -> Result<()> { + pub async fn prefetch_until_horizon(&self, channel_id: u16) -> Result<()> { let channel = self .get(channel_id) .ok_or_else(|| anyhow!("Unknown channel id {}", channel_id))?; diff --git a/version.txt b/version.txt index c556cc53..eaf1be72 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.3.61 +0.3.62