From ea1774f21698e575c30f38b3030f8a55f7beddbc Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Mon, 1 Dec 2025 20:40:24 +0100 Subject: [PATCH] Premier exemple pour tester si le controle point marche --- pmocontrol/examples/arylic_tcp_demo.rs | 13 +- pmocontrol/examples/linkplay_demo.rs | 13 +- pmocontrol/examples/queue_pmomusic_demo.rs | 429 ++ pmocontrol/src/avtransport_client.rs | 54 +- pmocontrol/src/control_point.rs | 121 +- pmocontrol/src/model.rs | 10 + pmocontrol/src/music_renderer.rs | 7 +- pmocontrol/src/registry.rs | 13 + pmocontrol/src/upnp_renderer.rs | 45 +- pmoparadise_011.txt | 7970 ++++++++++++++++++++ 10 files changed, 8607 insertions(+), 68 deletions(-) create mode 100644 pmocontrol/examples/queue_pmomusic_demo.rs create mode 100644 pmoparadise_011.txt diff --git a/pmocontrol/examples/arylic_tcp_demo.rs b/pmocontrol/examples/arylic_tcp_demo.rs index d6a90afd..e46ff1d7 100644 --- a/pmocontrol/examples/arylic_tcp_demo.rs +++ b/pmocontrol/examples/arylic_tcp_demo.rs @@ -38,14 +38,11 @@ fn main() -> io::Result<()> { ); } - let arylic_renderers: Vec = { - let reg = registry.read().unwrap(); - renderers - .iter() - .filter(|info| info.capabilities.has_arylic_tcp) - .filter_map(|info| MusicRenderer::from_registry_info(info.clone(), ®)) - .collect() - }; + let arylic_renderers: Vec = renderers + .iter() + .filter(|info| info.capabilities.has_arylic_tcp) + .filter_map(|info| MusicRenderer::from_registry_info(info.clone(), ®istry)) + .collect(); if arylic_renderers.is_empty() { println!("\nNo Arylic TCP-capable renderers detected."); diff --git a/pmocontrol/examples/linkplay_demo.rs b/pmocontrol/examples/linkplay_demo.rs index d4499ad2..ec76a24c 100644 --- a/pmocontrol/examples/linkplay_demo.rs +++ b/pmocontrol/examples/linkplay_demo.rs @@ -34,14 +34,11 @@ fn main() -> io::Result<()> { ); } - let linkplay_renderers: Vec = { - let reg = registry.read().unwrap(); - renderers - .iter() - .filter(|info| info.capabilities.has_linkplay_http) - .filter_map(|info| MusicRenderer::from_registry_info(info.clone(), ®)) - .collect() - }; + let linkplay_renderers: Vec = renderers + .iter() + .filter(|info| info.capabilities.has_linkplay_http) + .filter_map(|info| MusicRenderer::from_registry_info(info.clone(), ®istry)) + .collect(); if linkplay_renderers.is_empty() { println!("\nNo LinkPlay-capable renderers detected."); diff --git a/pmocontrol/examples/queue_pmomusic_demo.rs b/pmocontrol/examples/queue_pmomusic_demo.rs new file mode 100644 index 00000000..e71aec87 --- /dev/null +++ b/pmocontrol/examples/queue_pmomusic_demo.rs @@ -0,0 +1,429 @@ +//! End-to-end queue demo that prefers the PMOMusic media server and exercises +//! the ControlPoint playback queue API. + +use std::collections::VecDeque; +use std::env; +use std::process; +use std::thread; +use std::time::Duration; + +use anyhow::{Context, Result}; +use pmocontrol::{ + ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaResource, MediaServerInfo, + MusicRenderer, MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo, + RendererProtocol, +}; + +const DEFAULT_TIMEOUT_SECS: u64 = 5; +const DEFAULT_DISCOVERY_SECS: u64 = 5; +const DEFAULT_MAX_TRACKS: usize = 3; +const MONITOR_DURATION_SECS: u64 = 600; +const MONITOR_POLL_SECS: u64 = 5; +const MAX_BROWSE_DEPTH: usize = 2; + +fn main() -> Result<()> { + let _ = tracing_subscriber::fmt::try_init(); + let config = CliConfig::parse_from_env().unwrap_or_else(|err| { + eprintln!("Error parsing arguments: {err}"); + print_usage_and_exit(); + }); + + if config.max_tracks == 0 { + eprintln!("--max-tracks must be >= 1"); + process::exit(1); + } + + println!( + "Starting queue_pmomusic_demo with timeout={}s discovery={}s max_tracks={}", + config.timeout_secs, config.discovery_secs, config.max_tracks + ); + + // ControlPoint::spawn starts the HttpXmlDescriptionProvider + DiscoveryManager combo. + let control_point = + ControlPoint::spawn(config.timeout_secs).context("Failed to start control point")?; + + println!( + "Discovery running for {} seconds before selecting devices...", + config.discovery_secs + ); + thread::sleep(Duration::from_secs(config.discovery_secs)); + + let registry = control_point.registry(); + let (renderer, server_info) = { + let reg = registry.read().expect("registry poisoned"); + let renderer_candidates: Vec = reg + .list_renderers() + .into_iter() + .filter(|info| !is_pmomusic_renderer(info)) + .collect(); + let renderer = pick_renderer(renderer_candidates) + .unwrap_or_else(|| no_renderer_and_exit("No suitable renderer found after discovery.")); + let server = pick_media_server(reg.list_servers()) + .unwrap_or_else(|| no_server_and_exit("No media server with ContentDirectory.")); + (renderer, server) + }; + + println!( + "Selected renderer \"{}\" (protocol={:?}, id={})", + renderer.friendly_name, renderer.protocol, renderer.id.0 + ); + println!( + "Selected media server \"{}\" at {} (id={})", + server_info.friendly_name, server_info.location, server_info.id.0 + ); + + let renderer_instance = MusicRenderer::from_registry_info(renderer.clone(), ®istry) + .expect("Selected renderer is not usable by MusicRenderer façade"); + let supports_set_next = renderer_instance + .as_upnp() + .map(|upnp| upnp.supports_set_next()) + .unwrap_or(false); + println!( + "Renderer \"{}\": AVTransport present = {}, SetNextAVTransportURI supported = {}", + renderer.friendly_name, + renderer.capabilities.has_avtransport, + supports_set_next + ); + + let timeout = Duration::from_secs(config.timeout_secs); + let server = + MusicServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?; + + let root_entries = server + .browse_root() + .context("Failed to browse ContentDirectory root")?; + println!("Root returned {} entries", root_entries.len()); + + let playback_items = collect_playable_items(&server, &root_entries, config.max_tracks) + .context("Failed to derive playable items from ContentDirectory root/children")?; + + if playback_items.is_empty() { + println!("No playable tracks were found on the selected server."); + process::exit(1); + } + + println!( + "Discovered {} playable items; enqueuing…", + playback_items.len() + ); + + let mut planned_queue: VecDeque; + let renderer_id = renderer.id.clone(); + control_point + .clear_queue(&renderer_id) + .context("Failed to clear playback queue")?; + control_point + .enqueue_items(&renderer_id, playback_items) + .context("Failed to enqueue playback items")?; + + let snapshot = control_point + .get_queue_snapshot(&renderer_id) + .context("Failed to snapshot queue after enqueue")?; + print_queue_snapshot(&snapshot); + planned_queue = snapshot.clone().into(); + + control_point + .play_next_from_queue(&renderer_id) + .context("Failed to start playback from queue")?; + let mut current_track = planned_queue.pop_front(); + let remaining = control_point + .get_queue_snapshot(&renderer_id) + .context("Failed to snapshot queue after play_next_from_queue")?; + planned_queue = remaining.clone().into(); + println!( + "Playback started on \"{}\"; {} tracks remaining in queue.", + renderer.friendly_name, + remaining.len() + ); + + println!( + "Monitoring queue auto-advance for {} seconds (poll every {}s)…", + MONITOR_DURATION_SECS, MONITOR_POLL_SECS + ); + let poll_count = MONITOR_DURATION_SECS / MONITOR_POLL_SECS; + for tick in 0..poll_count { + thread::sleep(Duration::from_secs(MONITOR_POLL_SECS)); + let snapshot = control_point + .get_queue_snapshot(&renderer_id) + .context("Queue snapshot failed during monitoring loop")?; + let new_plan: VecDeque = snapshot.clone().into(); + if planned_queue.len() > new_plan.len() { + let removed = planned_queue.len() - new_plan.len(); + for _ in 0..removed { + current_track = planned_queue.pop_front(); + } + } + planned_queue = new_plan; + + let playback_info = control_point + .music_renderer_by_id(&renderer_id) + .and_then(|renderer| renderer.playback_position().ok()); + + let title = current_track_title(current_track.as_ref()); + if let Some(info) = playback_info { + println!( + "[tick {tick}] Queue length = {} | now playing: {} [{}]", + snapshot.len(), + title, + format_playback_position(&info) + ); + } else { + println!( + "[tick {tick}] Queue length = {} | now playing: {} [position unavailable]", + snapshot.len(), + title + ); + } + } + + println!("Monitoring finished, exiting."); + Ok(()) +} + +#[derive(Debug)] +struct CliConfig { + timeout_secs: u64, + discovery_secs: u64, + max_tracks: usize, +} + +impl CliConfig { + fn parse_from_env() -> Result { + let mut timeout_secs = DEFAULT_TIMEOUT_SECS; + let mut discovery_secs = DEFAULT_DISCOVERY_SECS; + let mut max_tracks = DEFAULT_MAX_TRACKS; + + let mut args = env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--timeout-secs" => { + let value = args + .next() + .ok_or_else(|| "--timeout-secs requires a value".to_string())?; + timeout_secs = value.parse().map_err(|err| { + format!("Invalid value for --timeout-secs ({value}): {err}") + })?; + } + "--discovery-secs" => { + let value = args + .next() + .ok_or_else(|| "--discovery-secs requires a value".to_string())?; + discovery_secs = value.parse().map_err(|err| { + format!("Invalid value for --discovery-secs ({value}): {err}") + })?; + } + "--max-tracks" => { + let value = args + .next() + .ok_or_else(|| "--max-tracks requires a value".to_string())?; + max_tracks = value.parse().map_err(|err| { + format!("Invalid value for --max-tracks ({value}): {err}") + })?; + } + "--help" | "-h" => { + print_usage_and_exit(); + } + unknown => { + return Err(format!("Unknown argument: {unknown}")); + } + } + } + + Ok(Self { + timeout_secs, + discovery_secs, + max_tracks, + }) + } +} + +fn pick_renderer(renderers: Vec) -> Option { + let mut candidates: Vec = renderers + .into_iter() + .filter(|info| match info.protocol { + RendererProtocol::OpenHomeOnly => false, + RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => true, + }) + .collect(); + + if candidates.is_empty() { + return None; + } + + println!("Renderer candidates:"); + for (idx, info) in candidates.iter().enumerate() { + println!( + " [{}] {} | model={} | location={} | online={}", + idx, info.friendly_name, info.model_name, info.location, info.online + ); + } + + let selected = candidates.remove(0); + println!( + "Automatically selecting renderer index 0: {}", + selected.friendly_name + ); + Some(selected) +} + +fn pick_media_server(servers: Vec) -> Option { + let mut candidates: Vec = servers + .into_iter() + .filter(|info| info.has_content_directory) + .filter(|info| info.content_directory_control_url.is_some()) + .collect(); + + if candidates.is_empty() { + return None; + } + + if let Some(idx) = candidates.iter().position(is_pmomusic_server) { + let server = candidates.remove(idx); + println!( + "Preferring PMOMusic server \"{}\" (server header: {}).", + server.friendly_name, server.server_header + ); + Some(server) + } else { + println!("No PMOMusic server discovered; falling back to first ContentDirectory server."); + Some(candidates.remove(0)) + } +} + +fn is_pmomusic_server(info: &MediaServerInfo) -> bool { + let name = info.friendly_name.to_ascii_lowercase(); + let header = info.server_header.to_ascii_lowercase(); + name.contains("pmomusic") || header.contains("pmomusic") +} + +fn is_pmomusic_renderer(info: &RendererInfo) -> bool { + info.friendly_name + .to_ascii_lowercase() + .contains("pmomusic audio renderer") +} + +fn collect_playable_items( + server: &MusicServer, + entries: &[MediaEntry], + max_tracks: usize, +) -> Result> { + let mut items = Vec::new(); + for entry in entries { + gather_items_from_entry(server, entry, max_tracks, 0, &mut items)?; + if items.len() >= max_tracks { + break; + } + } + Ok(items) +} + +fn gather_items_from_entry( + server: &MusicServer, + entry: &MediaEntry, + max_tracks: usize, + depth: usize, + out: &mut Vec, +) -> Result<()> { + if out.len() >= max_tracks { + return Ok(()); + } + + if entry.is_container { + if depth >= MAX_BROWSE_DEPTH { + return Ok(()); + } + + match server.browse_children(&entry.id, 0, 50) { + Ok(children) => { + for child in children { + gather_items_from_entry(server, &child, max_tracks, depth + 1, out)?; + if out.len() >= max_tracks { + break; + } + } + } + Err(err) => { + tracing::warn!( + container_id = entry.id.as_str(), + error = %err, + "Failed to browse child container" + ); + } + } + return Ok(()); + } + + if let Some(item) = playback_item_from_entry(server, entry) { + out.push(item); + } + Ok(()) +} + +fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option { + if entry.title.to_ascii_lowercase().contains("live stream") { + return None; + } + let resource = entry.resources.iter().find(|res| is_audio_resource(res))?; + let mut item = PlaybackItem::new(resource.uri.clone()); + item.title = Some(entry.title.clone()); + item.server_id = Some(server.id().clone()); + item.object_id = Some(entry.id.clone()); + Some(item) +} + +fn is_audio_resource(res: &MediaResource) -> bool { + let lower = res.protocol_info.to_ascii_lowercase(); + if lower.contains("audio/") { + return true; + } + lower + .split(':') + .nth(2) + .map(|mime| mime.starts_with("audio/")) + .unwrap_or(false) +} + +fn print_queue_snapshot(items: &[PlaybackItem]) { + println!("Current queue snapshot ({} items):", items.len()); + for (idx, item) in items.iter().enumerate() { + let label = item.title.as_deref().unwrap_or_else(|| item.uri.as_str()); + println!(" [{}] {} -> {}", idx, label, item.uri); + } + if items.is_empty() { + println!(" "); + } +} + +fn current_track_title(item: Option<&PlaybackItem>) -> String { + match item { + Some(track) => track + .title + .as_deref() + .unwrap_or_else(|| track.uri.as_str()) + .to_string(), + None => "".to_string(), + } +} + +fn format_playback_position(info: &PlaybackPositionInfo) -> String { + let rel = info.rel_time.as_deref().unwrap_or("-"); + let dur = info.track_duration.as_deref().unwrap_or("-"); + format!("{rel} / {dur}") +} + +fn no_renderer_and_exit(message: &str) -> ! { + println!("{message}"); + process::exit(1); +} + +fn no_server_and_exit(message: &str) -> ! { + println!("{message}"); + process::exit(1); +} + +fn print_usage_and_exit() -> ! { + println!( + "Usage: cargo run -p pmocontrol --example queue_pmomusic_demo -- [--timeout-secs N] [--discovery-secs N] [--max-tracks N]" + ); + process::exit(1); +} diff --git a/pmocontrol/src/avtransport_client.rs b/pmocontrol/src/avtransport_client.rs index eef3bdb3..fe949515 100644 --- a/pmocontrol/src/avtransport_client.rs +++ b/pmocontrol/src/avtransport_client.rs @@ -1,10 +1,14 @@ // pmocontrol/src/avtransport_client.rs -use crate::soap_client::{SoapCallResult, invoke_upnp_action}; +use std::time::Duration; + +use crate::soap_client::{SoapCallResult, invoke_upnp_action, invoke_upnp_action_with_timeout}; use anyhow::{Result, anyhow}; use pmoupnp::soap::SoapEnvelope; use xmltree::{Element, XMLNode}; +const AVTRANSPORT_ACTION_TIMEOUT: Duration = Duration::from_secs(5); + #[derive(Debug, Clone)] pub struct AvTransportClient { pub control_url: String, @@ -121,6 +125,45 @@ impl AvTransportClient { handle_action_response("Seek", &call_result) } + + /// Optional AVTransport:1 action SetNextAVTransportURI. + /// + /// This should configure the *next* track to be played after the current one. + /// Many renderers do NOT implement this action; in that case the method + /// returns an error derived from the UPnP error code. + pub fn set_next_av_transport_uri(&self, next_uri: &str, next_meta: &str) -> Result<()> { + let args = [ + ("InstanceID", "0"), + ("NextURI", next_uri), + ("NextURIMetaData", next_meta), + ]; + + let call_result = invoke_upnp_action_with_timeout( + &self.control_url, + &self.service_type, + "SetNextAVTransportURI", + &args, + Some(AVTRANSPORT_ACTION_TIMEOUT), + )?; + + if let Err(err) = handle_action_response("SetNextAVTransportURI", &call_result) { + if let Some(env) = &call_result.envelope { + if let Some(upnp_error) = parse_upnp_error(env) { + if is_set_next_not_supported_error(&upnp_error) { + return Err(anyhow!( + "Renderer does not support AVTransport.SetNextAVTransportURI (UPnP error {}: {})", + upnp_error.error_code, + upnp_error.error_description + )); + } + } + } + + return Err(err); + } + + Ok(()) + } } fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> { @@ -230,6 +273,15 @@ fn parse_upnp_error(envelope: &SoapEnvelope) -> Option { }) } +fn is_set_next_not_supported_error(err: &UpnpError) -> bool { + if err.error_code == 401 { + return true; + } + + let desc = err.error_description.to_ascii_lowercase(); + desc.contains("invalid action") || desc.contains("not implemented") +} + fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> { parent.children.iter().find_map(|node| match node { XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem), diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 258d54d3..bec10ab9 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -96,13 +96,14 @@ impl ControlPoint { thread::spawn(move || { loop { - let renderers = { + let infos = { let reg = runtime_cp.registry.read().unwrap(); reg.list_renderers() - .into_iter() - .filter_map(|info| MusicRenderer::from_registry_info(info, ®)) - .collect::>() }; + let renderers = infos + .into_iter() + .filter_map(|info| MusicRenderer::from_registry_info(info, &runtime_cp.registry)) + .collect::>(); for renderer in renderers { let info = renderer.info(); @@ -204,27 +205,35 @@ impl ControlPoint { /// Snapshot list of renderers currently known by the registry. pub fn list_upnp_renderers(&self) -> Vec { - let reg = self.registry.read().unwrap(); - reg.list_renderers() + let infos = { + let reg = self.registry.read().unwrap(); + reg.list_renderers() + }; + + infos .into_iter() - .map(|info| UpnpRenderer::from_registry(info, ®)) + .map(|info| UpnpRenderer::from_registry(info, &self.registry)) .collect() } /// Return the first renderer in the registry, if any. pub fn default_upnp_renderer(&self) -> Option { - let reg = self.registry.read().unwrap(); - reg.list_renderers() - .into_iter() - .next() - .map(|info| UpnpRenderer::from_registry(info, ®)) + let info = { + let reg = self.registry.read().unwrap(); + reg.list_renderers().into_iter().next() + }?; + + Some(UpnpRenderer::from_registry(info, &self.registry)) } /// Lookup a renderer by id. pub fn upnp_renderer_by_id(&self, id: &RendererId) -> Option { - let reg = self.registry.read().unwrap(); - reg.get_renderer(id) - .map(|info| UpnpRenderer::from_registry(info, ®)) + let info = { + let reg = self.registry.read().unwrap(); + reg.get_renderer(id) + }?; + + Some(UpnpRenderer::from_registry(info, &self.registry)) } /// Snapshot list of music renderers (protocol-agnostic view). @@ -233,26 +242,37 @@ impl ControlPoint { /// [`MusicRenderer::Upnp`]. OpenHome-only devices will be /// ignored until an OpenHome backend is implemented. pub fn list_music_renderers(&self) -> Vec { - let reg = self.registry.read().unwrap(); - reg.list_renderers() + let infos = { + let reg = self.registry.read().unwrap(); + reg.list_renderers() + }; + + infos .into_iter() - .filter_map(|info| MusicRenderer::from_registry_info(info, ®)) + .filter_map(|info| MusicRenderer::from_registry_info(info, &self.registry)) .collect() } /// Return the first music renderer in the registry, if any. pub fn default_music_renderer(&self) -> Option { - let reg = self.registry.read().unwrap(); - reg.list_renderers() + let infos = { + let reg = self.registry.read().unwrap(); + reg.list_renderers() + }; + + infos .into_iter() - .find_map(|info| MusicRenderer::from_registry_info(info, ®)) + .find_map(|info| MusicRenderer::from_registry_info(info, &self.registry)) } /// Lookup a music renderer by id. pub fn music_renderer_by_id(&self, id: &RendererId) -> Option { - let reg = self.registry.read().unwrap(); - reg.get_renderer(id) - .and_then(|info| MusicRenderer::from_registry_info(info, ®)) + let info = { + let reg = self.registry.read().unwrap(); + reg.get_renderer(id) + }?; + + MusicRenderer::from_registry_info(info, &self.registry) } /// Snapshot list of media servers currently known by the registry. @@ -374,15 +394,13 @@ impl ControlPoint { "Dequeued next playback item" ); - let renderer = self - .music_renderer_by_id(renderer_id) - .ok_or_else(|| { - warn!( - renderer = renderer_id.0.as_str(), - "Renderer disappeared before queue playback could start" - ); - anyhow!("Renderer {} not found", renderer_id.0) - })?; + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + warn!( + renderer = renderer_id.0.as_str(), + "Renderer disappeared before queue playback could start" + ); + anyhow!("Renderer {} not found", renderer_id.0) + })?; if matches!(renderer.info().protocol, RendererProtocol::OpenHomeOnly) { self.runtime @@ -430,6 +448,28 @@ impl ControlPoint { queue_len = remaining_after, "Started playback from queue" ); + + if let Some(snapshot) = self.runtime.queue_snapshot(renderer_id) { + if let Some(next_item) = snapshot.first() { + if let Some(upnp) = renderer.as_upnp() { + let known_supported = upnp.supports_set_next(); + if known_supported || upnp.has_avtransport() { + match upnp.set_next_uri(&next_item.uri, "") { + Ok(_) => debug!( + renderer = renderer_id.0.as_str(), + "Prefetched next track via SetNextAVTransportURI" + ), + Err(err) => debug!( + renderer = renderer_id.0.as_str(), + error = %err, + "SetNextAVTransportURI failed for next queue item; continuing without prefetch" + ), + } + } + } + } + } + Ok(()) } @@ -460,12 +500,10 @@ impl ControlPoint { error = %err, "Auto-advance failed; clearing queue playback state" ); - self.runtime - .set_playback_source(id, PlaybackSource::None); + self.runtime.set_playback_source(id, PlaybackSource::None); } } else { - self.runtime - .set_playback_source(id, PlaybackSource::None); + self.runtime.set_playback_source(id, PlaybackSource::None); } } PlaybackState::Playing => { @@ -542,9 +580,7 @@ impl RuntimeState { F: FnOnce(&mut PlaybackQueue) -> R, { let mut entries = self.entries.lock().unwrap(); - entries - .get_mut(id) - .map(|entry| f(&mut entry.queue)) + entries.get_mut(id).map(|entry| f(&mut entry.queue)) } fn queue_snapshot(&self, id: &RendererId) -> Option> { @@ -576,10 +612,7 @@ impl RuntimeState { } fn is_playing_from_queue(&self, id: &RendererId) -> bool { - matches!( - self.playback_source(id), - PlaybackSource::FromQueue - ) + matches!(self.playback_source(id), PlaybackSource::FromQueue) } fn mark_external_if_idle(&self, id: &RendererId) { diff --git a/pmocontrol/src/model.rs b/pmocontrol/src/model.rs index 6bbf7ca4..24b5abe8 100644 --- a/pmocontrol/src/model.rs +++ b/pmocontrol/src/model.rs @@ -13,6 +13,10 @@ pub enum RendererProtocol { #[derive(Clone, Debug, Default)] pub struct RendererCapabilities { pub has_avtransport: bool, + /// True if the renderer is known to support AVTransport.SetNextAVTransportURI. + /// + /// This is discovered lazily at runtime; default is false. + pub has_avtransport_set_next: bool, pub has_rendering_control: bool, pub has_connection_manager: bool, pub has_linkplay_http: bool, @@ -25,6 +29,12 @@ pub struct RendererCapabilities { pub has_oh_radio: bool, } +impl RendererCapabilities { + pub fn supports_set_next(&self) -> bool { + self.has_avtransport && self.has_avtransport_set_next + } +} + #[derive(Clone, Debug)] pub struct RendererInfo { pub id: RendererId, diff --git a/pmocontrol/src/music_renderer.rs b/pmocontrol/src/music_renderer.rs index 3eb918a2..7b77319c 100644 --- a/pmocontrol/src/music_renderer.rs +++ b/pmocontrol/src/music_renderer.rs @@ -6,6 +6,8 @@ //! this type so that transport, volume, and state queries stay backend-neutral. //! OpenHome-only renderers are intentionally unsupported for now. +use std::sync::{Arc, RwLock}; + use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus}; use crate::model::{RendererId, RendererInfo, RendererProtocol}; use crate::{ @@ -90,7 +92,10 @@ impl MusicRenderer { /// Returns `None` when no supported backend can be built for this renderer. /// UPnP AV / hybrid renderers map either to [`MusicRenderer::LinkPlay`] (when supported) /// or [`MusicRenderer::Upnp`]. - pub fn from_registry_info(info: RendererInfo, registry: &DeviceRegistry) -> Option { + pub fn from_registry_info( + info: RendererInfo, + registry: &Arc>, + ) -> Option { match info.protocol { RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => { let has_arylic = info.capabilities.has_arylic_tcp; diff --git a/pmocontrol/src/registry.rs b/pmocontrol/src/registry.rs index ebcaea2f..c8e54eca 100644 --- a/pmocontrol/src/registry.rs +++ b/pmocontrol/src/registry.rs @@ -6,6 +6,7 @@ use crate::connection_manager_client::ConnectionManagerClient; use crate::media_server::{MediaServerInfo, ServerId}; use crate::model::{RendererId, RendererInfo}; use crate::rendering_control_client::RenderingControlClient; +use tracing::debug; #[derive(Clone, Debug)] enum DeviceKey { @@ -190,4 +191,16 @@ impl DeviceRegistry { service_type.clone(), )) } + + pub fn mark_renderer_supports_set_next(&mut self, id: &RendererId) { + if let Some(info) = self.renderers.get_mut(id) { + if !info.capabilities.has_avtransport_set_next { + info.capabilities.has_avtransport_set_next = true; + debug!( + renderer = id.0.as_str(), + "Renderer now marked as supporting AVTransport.SetNextAVTransportURI" + ); + } + } + } } diff --git a/pmocontrol/src/upnp_renderer.rs b/pmocontrol/src/upnp_renderer.rs index 52d928f1..41d96c08 100644 --- a/pmocontrol/src/upnp_renderer.rs +++ b/pmocontrol/src/upnp_renderer.rs @@ -1,7 +1,10 @@ +use std::sync::{Arc, RwLock}; + use anyhow::{Result, anyhow}; use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus}; use crate::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo}; +use crate::music_renderer::op_not_supported; use crate::rendering_control_client::RenderingControlClient; use crate::{ AvTransportClient, DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, RendererId, @@ -12,6 +15,7 @@ use crate::{ #[derive(Clone, Debug)] pub struct UpnpRenderer { pub info: RendererInfo, + registry: Arc>, avtransport: Option, rendering_control: Option, connection_manager: Option, @@ -38,6 +42,11 @@ impl UpnpRenderer { self.connection_manager.is_some() } + /// Returns true if this renderer is known to support SetNextAVTransportURI. + pub fn supports_set_next(&self) -> bool { + self.info.capabilities.supports_set_next() + } + pub fn avtransport(&self) -> Result<&AvTransportClient> { self.avtransport .as_ref() @@ -62,6 +71,23 @@ impl UpnpRenderer { avt.play(0, "1") } + /// Best-effort attempt to configure the next URI via AVTransport SetNextAVTransportURI. + pub fn set_next_uri(&self, next_uri: &str, next_meta: &str) -> Result<()> { + if !self.info.capabilities.has_avtransport { + return Err(op_not_supported("SetNextAVTransportURI", "AVTransport")); + } + + let client = self.avtransport()?; + let result = client.set_next_av_transport_uri(next_uri, next_meta); + + if result.is_ok() { + let mut reg = self.registry.write().unwrap(); + reg.mark_renderer_supports_set_next(&self.info.id); + } + + result + } + pub fn pause(&self) -> Result<()> { let avt = self.avtransport()?; avt.pause(0) @@ -112,12 +138,18 @@ impl UpnpRenderer { cm.get_current_connection_info(connection_id) } - pub fn from_registry(info: RendererInfo, registry: &DeviceRegistry) -> Self { - let avtransport = registry.avtransport_client_for_renderer(&info.id); - let rendering_control = registry.rendering_control_client_for_renderer(&info.id); - let connection_manager = registry.connection_manager_client_for_renderer(&info.id); + pub fn from_registry(info: RendererInfo, registry: &Arc>) -> Self { + let (avtransport, rendering_control, connection_manager) = { + let reg = registry.read().unwrap(); + ( + reg.avtransport_client_for_renderer(&info.id), + reg.rendering_control_client_for_renderer(&info.id), + reg.connection_manager_client_for_renderer(&info.id), + ) + }; Self { info, + registry: Arc::clone(registry), avtransport, rendering_control, connection_manager, @@ -130,6 +162,7 @@ mod tests { use super::*; use crate::model::{RendererCapabilities, RendererProtocol}; use crate::registry::{DeviceRegistry, DeviceUpdate}; + use std::sync::{Arc, RwLock}; use std::time::SystemTime; fn renderer_info(id_suffix: &str, with_avtransport: bool) -> RendererInfo { @@ -160,10 +193,10 @@ mod tests { } } - fn registry_with_renderer(info: RendererInfo) -> DeviceRegistry { + fn registry_with_renderer(info: RendererInfo) -> Arc> { let mut registry = DeviceRegistry::new(); registry.apply_update(DeviceUpdate::RendererOnline(info)); - registry + Arc::new(RwLock::new(registry)) } #[test] diff --git a/pmoparadise_011.txt b/pmoparadise_011.txt new file mode 100644 index 00000000..6c10f232 --- /dev/null +++ b/pmoparadise_011.txt @@ -0,0 +1,7970 @@ +------------ 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 --------- +