------------ 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 ---------

