Files
pmomusic/pmoparadise_012.txt

7971 lines
272 KiB
Plaintext
Raw Normal View History

------------ 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<Self, Self::Err> {
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::<ParadiseChannelKind>(),
Ok(ParadiseChannelKind::Main)
));
assert!(matches!(
"0".parse::<ParadiseChannelKind>(),
Ok(ParadiseChannelKind::Main)
));
assert!("invalid".parse::<ParadiseChannelKind>().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<dyn std::error::Error>> {
/// 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<String>,
}
impl RadioParadiseClient {
/// Create a new client with default settings
///
/// Uses FLAC quality and channel 0 (main mix)
pub async fn new() -> Result<Self> {
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<dyn std::error::Error>> {
/// 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<EventId>) -> Result<Block> {
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<NowPlaying> {
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<Client>,
api_base: String,
channel: u8,
request_timeout: Duration,
block_timeout: Duration,
user_agent: String,
proxy: Option<String>,
}
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<String>) -> 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<String>) -> Self {
self.user_agent = user_agent.into();
self
}
/// Set a proxy URL
pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
self.proxy = Some(proxy.into());
self
}
/// Build the client
pub async fn build(self) -> Result<RadioParadiseClient> {
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<bool>;
/// 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<u8>;
/// 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<bool> {
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<u8> {
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::<ParadiseChannelKind>() {
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<T> = std::result::Result<T, Error>;
/// 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<String>) -> 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<dyn std::error::Error>> {
//! // 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<dyn std::error::Error>> {
//! 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<dyn std::error::Error>> {
//! 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/<start>-<end>.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 <block_url>
//! ```
//!
//! **Alternative** (resource-intensive, requires `per-track` feature):
//! - Download and decode block
//! - Extract specific track to PCM/WAV
//!
//! ## Error Handling
//!
//! All operations return `Result<T, Error>` 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<dyn std::error::Error>> {
//! 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<u64, D::Error>
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::<u64>().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<u64, D::Error>
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::<f64>().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<u32>
fn deserialize_optional_string_or_u32<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrU32 {
String(String),
Number(u32),
}
let opt = Option::<StringOrU32>::deserialize(deserializer)?;
match opt {
None => Ok(None),
Some(StringOrU32::String(s)) => {
if s.is_empty() {
Ok(None)
} else {
s.parse::<u32>().map(Some).map_err(D::Error::custom)
}
}
Some(StringOrU32::Number(n)) => Ok(Some(n)),
}
}
/// Deserialize an optional string or number into Option<f32>
fn deserialize_optional_string_or_f32<'de, D>(deserializer: D) -> Result<Option<f32>, 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::<StringOrF32>::deserialize(deserializer)?;
match opt {
None => Ok(None),
Some(StringOrF32::String(s)) => {
if s.is_empty() {
Ok(None)
} else {
s.parse::<f32>().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<String>,
/// 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<u32>,
/// 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<String>,
/// 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<f32>,
/// Gapless URL for individual song FLAC
/// This URL points to a FLAC file containing only this song
#[serde(default)]
pub gapless_url: Option<String>,
/// Scheduled playback time on Radio Paradise (Unix timestamp in milliseconds, UTC)
#[serde(default)]
pub sched_time_millis: Option<u64>,
/// Radio Paradise song ID (unique identifier)
#[serde(default)]
pub song_id: Option<String>,
/// Radio Paradise artist ID (for building artist URLs)
#[serde(default)]
pub artist_id: Option<String>,
/// Large cover image path (best quality)
#[serde(default)]
pub cover_large: Option<String>,
/// Additional metadata
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
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<u64> {
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<String>,
/// Scheduled start time for this block (Unix timestamp in milliseconds, UTC)
#[serde(default)]
pub sched_time_millis: Option<u64>,
/// Map of song index (as string) to Song metadata
/// Keys are "0", "1", "2", etc.
#[serde(default)]
pub song: HashMap<String, Song>,
/// Additional metadata
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
impl Block {
/// Scheduled start time in milliseconds if available.
pub fn start_time_millis(&self) -> Option<u64> {
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::<usize>().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<String> {
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/<start>-<end>.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::<EventId>().ok()?;
let end = parts.next()?.parse::<EventId>().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<usize>,
/// Current song
pub current_song: Option<Song>,
/// Approximate elapsed time in current block (ms)
/// Note: This is estimated and may not be perfectly accurate
pub block_elapsed_ms: Option<DurationMs>,
}
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<String>) -> Arc<Self> {
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<EventId, BlockStatus>,
order: VecDeque<EventId>,
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<AudioCache>,
covers_cache: Arc<CoversCache>,
playlist_handle: Arc<WriteHandle>,
block_queue: Arc<tokio::sync::Mutex<VecDeque<EventId>>>,
notify: Arc<Notify>,
collection: Option<String>,
recent_blocks: tokio::sync::Mutex<RecentBlocks>,
}
impl RadioParadisePlaylistFeeder {
/// Crée un nouveau feeder et retourne (feeder, read_handle)
pub async fn new(
client: RadioParadiseClient,
audio_cache: Arc<AudioCache>,
covers_cache: Arc<CoversCache>,
playlist_id: String,
collection: Option<String>,
) -> 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<Self>) -> 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<RwLock<RadioParadiseClient>>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct ParadiseQuery {
channel: Option<u8>,
}
impl RadioParadiseState {
pub async fn new() -> anyhow::Result<Self> {
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<RadioParadiseClient, StatusCode> {
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<usize>,
/// Morceau actuel
pub current_song: Option<SongInfo>,
/// Tous les morceaux du block
pub songs: Vec<SongInfo>,
}
/// 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<u32>,
/// 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<String>,
/// Note (0-10)
pub rating: Option<f32>,
}
/// 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<SongInfo>,
}
/// 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<String>,
/// Type de pochette: "cover" (petite) ou "cover_large" (grande)
#[schema(example = "cover_large")]
pub cover_type: String,
}
impl From<Block> 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<NowPlaying> for NowPlayingResponse {
fn from(np: NowPlaying) -> Self {
let songs: Vec<SongInfo> = 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<u8>, 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<RadioParadiseState>,
Query(params): Query<ParadiseQuery>,
) -> Result<Json<NowPlayingResponse>, StatusCode> {
let client = state.client_for_params(&params).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<u8>, 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<RadioParadiseState>,
Query(params): Query<ParadiseQuery>,
) -> Result<Json<BlockResponse>, StatusCode> {
let client = state.client_for_params(&params).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<u8>, 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<RadioParadiseState>,
Path(event_id): Path<u64>,
Query(params): Query<ParadiseQuery>,
) -> Result<Json<BlockResponse>, StatusCode> {
let client = state.client_for_params(&params).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<ChannelInfo>)
),
tag = "Radio Paradise"
)]
async fn get_channels() -> Json<Vec<ChannelInfo>> {
let channels: Vec<ChannelInfo> = 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<u8>, 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<RadioParadiseState>,
Path((event_id, index)): Path<(u64, usize)>,
Query(params): Query<ParadiseQuery>,
) -> Result<Json<SongInfo>, StatusCode> {
let client = state.client_for_params(&params).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<u8>, 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<RadioParadiseState>,
Path((event_id, song_index)): Path<(u64, usize)>,
Query(params): Query<ParadiseQuery>,
) -> Result<Json<CoverUrlResponse>, StatusCode> {
let client = state.client_for_params(&params).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<u8>, 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<RadioParadiseState>,
Path(event_id): Path<u64>,
Query(params): Query<ParadiseQuery>,
) -> Result<Json<StreamUrlResponse>, StatusCode> {
let client = state.client_for_params(&params).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<RadioParadiseState>;
}
#[cfg(feature = "pmoserver")]
#[async_trait]
impl RadioParadiseExt for pmoserver::Server {
async fn init_radioparadise(&mut self) -> anyhow::Result<RadioParadiseState> {
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<Mutex<VecDeque<EventId>>>,
notify: Arc<Notify>,
}
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<EventId> {
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<EventId> {
let queue = self.queue.lock().expect("block queue poisoned");
queue.iter().copied().collect()
}
fn front(&self) -> Option<EventId> {
let queue = self.queue.lock().expect("block queue poisoned");
queue.front().copied()
}
fn back(&self) -> Option<EventId> {
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<EventId>,
block_queue: BlockQueueHandle,
stats: Arc<NodeStats>,
}
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<Arc<AudioSegment>>],
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<u8> = 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::<Vec<u8>>();
// 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<Arc<AudioSegment>>],
segment: Arc<AudioSegment>,
) -> 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::<Self>(),
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<Arc<AudioSegment>, 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<RwLock<dyn TrackMetadata>> {
let metadata = MemoryTrackMetadata::new();
let metadata_arc = Arc::new(RwLock::new(metadata)) as Arc<RwLock<dyn TrackMetadata>>;
// 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<mpsc::Receiver<Arc<AudioSegment>>>,
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
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<Instant> = 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::<Self>(), &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<RadioParadiseStreamSourceLogic>
// ═══════════════════════════════════════════════════════════════════════════
pub struct RadioParadiseStreamSource {
inner: Node<RadioParadiseStreamSourceLogic>,
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<mpsc::Sender<Arc<AudioSegment>>> {
self.inner.get_tx()
}
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
self.inner.register(child);
}
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
Box::new(self.inner).run(stop_token).await
}
}
impl TypedAudioNode for RadioParadiseStreamSource {
fn input_type(&self) -> Option<TypeRequirement> {
None // Source node
}
fn output_type(&self) -> Option<TypeRequirement> {
// 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<RwLock<u32>>,
/// Last change timestamp
last_change: Arc<RwLock<SystemTime>>,
/// Tokens des callbacks enregistrés auprès du PlaylistManager
callback_tokens: Arc<std::sync::Mutex<Vec<u64>>>,
/// Notifier optionnel pour signaler les mises à jour de conteneurs au ContentDirectory
container_notifier: Option<Arc<dyn Fn(&[String]) + Send + Sync + 'static>>,
}
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<String>) -> 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<dyn Fn(&[String]) + Send + Sync + 'static>,
) -> 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<Self>) {
use pmoplaylist::PlaylistManager;
// Préparer les IDs de playlists à surveiller (live + history pour chaque canal)
let ids: Vec<String> = 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<String> = 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<Option<Item>> {
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::<serde_json::Value>().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<Vec<Item>> {
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 <upnp:genre>
// 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<Vec<Item>> {
#[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<Item> {
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<Container> {
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<BrowseResult> {
match Self::parse_object_id(object_id) {
ObjectIdType::Root => {
// Return the 4 channel containers
let containers: Vec<Container> = 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<String> {
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<Vec<AudioFormat>> {
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<Item> {
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<Option<Item>> {
// Managed automatically by playlist FIFO
Ok(None)
}
async fn update_id(&self) -> u32 {
*self.update_counter.read().await
}
async fn last_change(&self) -> Option<SystemTime> {
Some(*self.last_change.read().await)
}
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
// 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<String>,
}
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<AudioCache>,
pub cover_cache: Arc<CoverCache>,
pub playlist_id: String,
pub playlist_writer: WriteHandle,
pub collection: Option<String>,
pub replay_max_lead_seconds: f64,
}
/// Builder pratique pour configurer automatiquement les playlists historiques.
#[derive(Clone)]
pub struct ParadiseHistoryBuilder {
pub audio_cache: Arc<AudioCache>,
pub cover_cache: Arc<CoverCache>,
pub playlist_prefix: String,
pub playlist_title_prefix: Option<String>,
pub max_history_tracks: Option<usize>,
pub collection_prefix: Option<String>,
pub replay_max_lead_seconds: f64,
}
impl ParadiseHistoryBuilder {
pub fn new(audio_cache: Arc<AudioCache>, cover_cache: Arc<CoverCache>) -> 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<ParadiseHistoryOptions, pmoplaylist::Error> {
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<AudioCache>,
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::<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
}
}
_ => {
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<ChannelState>,
pipeline_handle: JoinHandle<()>,
feeder_handle: JoinHandle<()>,
history: Option<HistoryState>,
}
impl ParadiseStreamChannel {
/// Crée un canal avec client déjà configuré.
pub fn with_client(
descriptor: ChannelDescriptor,
client: RadioParadiseClient,
config: ParadiseStreamChannelConfig,
cover_cache: Option<Arc<CoverCache>>,
history: Option<ParadiseHistoryOptions>,
) -> 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<Box<dyn AudioPipelineNode>> = 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<Arc<CoverCache>>,
history: Option<ParadiseHistoryOptions>,
) -> Result<Self> {
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<HistoryFlacStream, HistoryStreamError> {
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<HistoryOggStream, HistoryStreamError> {
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<Self>) {
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<ChannelState>,
}
impl $name {
fn new(inner: $inner, state: Arc<ChannelState>) -> Self {
Self { inner, state }
}
}
impl AsyncRead for $name {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
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<JoinHandle<()>>,
}
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<std::io::Result<()>> {
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<JoinHandle<()>>,
}
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<std::io::Result<()>> {
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<u8, Arc<ParadiseStreamChannel>>,
}
impl ParadiseChannelManager {
pub fn new(channels: HashMap<u8, Arc<ParadiseStreamChannel>>) -> Self {
Self { channels }
}
pub async fn with_defaults_with_cover_cache(
cover_cache: Option<Arc<CoverCache>>,
history_builder: Option<ParadiseHistoryBuilder>,
server_base_url: Option<String>,
) -> Result<Self> {
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> {
Self::with_defaults_with_cover_cache(None, None, None).await
}
pub fn get(&self, id: u8) -> Option<Arc<ParadiseStreamChannel>> {
self.channels.get(&id).cloned()
}
pub fn iter(&self) -> impl Iterator<Item = &Arc<ParadiseStreamChannel>> {
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<String>,
}
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<AudioCache>,
pub cover_cache: Arc<CoverCache>,
pub playlist_id: String,
pub collection: Option<String>,
pub replay_max_lead_seconds: f64,
pub max_history_tracks: Option<usize>,
}
/// Builder pratique pour configurer automatiquement les playlists historiques.
#[derive(Clone)]
pub struct ParadiseHistoryBuilder {
pub audio_cache: Arc<AudioCache>,
pub cover_cache: Arc<CoverCache>,
pub playlist_prefix: String,
pub playlist_title_prefix: Option<String>,
pub max_history_tracks: Option<usize>,
pub collection_prefix: Option<String>,
pub replay_max_lead_seconds: f64,
}
impl ParadiseHistoryBuilder {
pub fn new(audio_cache: Arc<AudioCache>, cover_cache: Arc<CoverCache>) -> 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<ParadiseHistoryOptions, pmoplaylist::Error> {
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::<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
}
}
_ => {
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<ChannelState>,
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<Arc<CoverCache>>,
history: Option<ParadiseHistoryOptions>,
) -> Result<Self> {
// 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<Box<dyn AudioPipelineNode>> = 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<Arc<CoverCache>>,
history: Option<ParadiseHistoryOptions>,
) -> Result<Self> {
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<HistoryFlacStream, HistoryStreamError> {
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<HistoryOggStream, HistoryStreamError> {
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<std::sync::Weak<ParadiseChannelManager>> = OnceCell::new();
struct ChannelState {
descriptor: ChannelDescriptor,
config: ParadiseStreamChannelConfig,
client: RadioParadiseClient,
feeder: Arc<RadioParadisePlaylistFeeder>,
stream_handle: StreamHandle,
ogg_handle: OggFlacStreamHandle,
history_playlist_id: Option<String>,
history_audio_cache: Option<Arc<AudioCache>>,
active_clients: AtomicUsize,
activity_notify: Notify,
stop_token: CancellationToken,
current_block: Mutex<Option<EventId>>,
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<Duration> {
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<EventId> = 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<EventId> {
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<Self>) {
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<ChannelState>,
}
impl $name {
fn new(inner: $inner, state: Arc<ChannelState>) -> Self {
Self { inner, state }
}
}
impl AsyncRead for $name {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
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<JoinHandle<()>>,
}
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<std::io::Result<()>> {
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<JoinHandle<()>>,
}
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<std::io::Result<()>> {
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<u8, Arc<ParadiseStreamChannel>>,
}
impl ParadiseChannelManager {
pub fn new(channels: HashMap<u8, Arc<ParadiseStreamChannel>>) -> Self {
Self { channels }
}
pub async fn with_defaults_with_cover_cache(
cover_cache: Option<Arc<CoverCache>>,
history_builder: Option<ParadiseHistoryBuilder>,
server_base_url: Option<String>,
) -> Result<Self> {
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> {
Self::with_defaults_with_cover_cache(None, None, None).await
}
pub fn get(&self, id: u8) -> Option<Arc<ParadiseStreamChannel>> {
self.channels.get(&id).cloned()
}
pub fn iter(&self) -> impl Iterator<Item = &Arc<ParadiseStreamChannel>> {
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<ParadiseChannelManager>) {
let _ = GLOBAL_CHANNEL_MANAGER.set(Arc::downgrade(&manager));
}
pub fn get_global_channel_manager() -> Option<Arc<ParadiseChannelManager>> {
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 -- <channel_id>
//!
//! 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<dyn std::error::Error>> {
// 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<String> = env::args().collect();
if args.len() != 2 {
eprintln!("Usage: {} <channel_id>", 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 -- <channel_id>
//!
//! 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<dyn std::error::Error>> {
// 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<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: {} <channel_id> [--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/<slug>/flac`
//! - `/radioparadise/stream/<slug>/ogg`
//! - `/radioparadise/stream/<slug>/icy`
//! - `/radioparadise/stream/<slug>/historic/<client_id>/flac`
//! - `/radioparadise/stream/<slug>/historic/<client_id>/ogg`
//! - `/radioparadise/metadata/<slug>`
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<ParadiseChannelManager>,
}
#[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<Arc<AppState>>| {
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<Arc<AppState>>| {
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<Arc<AppState>>| {
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<String>| {
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<String>| {
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<Arc<AppState>>| {
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/<client_id>/(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<ParadiseChannelManager>,
channel_id: u8,
) -> Result<Response, StatusCode> {
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<ParadiseChannelManager>,
channel_id: u8,
) -> Result<Response, StatusCode> {
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<ParadiseChannelManager>,
channel_id: u8,
) -> Result<Response, StatusCode> {
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<ParadiseChannelManager>,
channel_id: u8,
) -> Result<impl IntoResponse, StatusCode> {
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<ParadiseChannelManager>,
channel_id: u8,
client_id: String,
) -> Result<Response, StatusCode> {
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<ParadiseChannelManager>,
channel_id: u8,
client_id: String,
) -> Result<Response, StatusCode> {
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<ParadiseStreamChannel>,
descriptor: ChannelDescriptor,
cover_cache: Arc<CoverCache>,
}
#[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<AppState>) -> Result<Response, StatusCode> {
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<AppState>) -> Result<Response, StatusCode> {
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<AppState>,
request: Request,
) -> Result<impl IntoResponse, StatusCode> {
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<AppState>,
Path(pk): Path<String>,
) -> Result<Response, StatusCode> {
// 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<String>) -> anyhow::Result<ChannelDescriptor> {
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::<u8>() {
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 -- <channel_id>
//!
//! 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<Arc<AppState>>,
_headers: HeaderMap,
) -> Result<Response, StatusCode> {
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<Arc<AppState>>,
_headers: HeaderMap,
) -> Result<Response, StatusCode> {
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<Arc<AppState>>,
_headers: HeaderMap,
) -> Result<Response, StatusCode> {
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<Arc<AppState>>) -> 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<dyn std::error::Error>> {
// Initialize logging via pmoserver
let _log_state = init_logging();
tracing::info!("=== Radio Paradise HTTP Streaming Test ===");
// Parse arguments
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: {} <channel_id>", 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 ---------