7971 lines
272 KiB
Plaintext
7971 lines
272 KiB
Plaintext
------------ 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(¶ms).await?;
|
|
let now_playing = client.now_playing().await.map_err(|e| {
|
|
tracing::error!("Failed to fetch now playing from Radio Paradise: {}", e);
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
Ok(Json(now_playing.into()))
|
|
}
|
|
|
|
/// GET /block/current - Récupère le block actuel
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/block/current",
|
|
params(
|
|
("channel" = Option<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(¶ms).await?;
|
|
let block = client.get_block(None).await.map_err(|e| {
|
|
tracing::error!("Failed to fetch current block from Radio Paradise: {}", e);
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
Ok(Json(block.into()))
|
|
}
|
|
|
|
/// GET /block/{event_id} - Récupère un block spécifique
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/block/{event_id}",
|
|
params(
|
|
("event_id" = u64, Path, description = "Event ID du block"),
|
|
("channel" = Option<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(¶ms).await?;
|
|
let block = client.get_block(Some(event_id)).await.map_err(|e| {
|
|
tracing::error!(
|
|
"Failed to fetch block {} from Radio Paradise: {}",
|
|
event_id,
|
|
e
|
|
);
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
Ok(Json(block.into()))
|
|
}
|
|
|
|
/// GET /channels - Liste les canaux disponibles
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/channels",
|
|
responses(
|
|
(status = 200, description = "Liste des canaux", body = Vec<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(¶ms).await?;
|
|
let block = client.get_block(Some(event_id)).await.map_err(|e| {
|
|
tracing::error!(
|
|
"Failed to fetch block {} from Radio Paradise: {}",
|
|
event_id,
|
|
e
|
|
);
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
let song = block.get_song(index).ok_or_else(|| {
|
|
tracing::warn!("Song index {} not found in block {}", index, event_id);
|
|
StatusCode::NOT_FOUND
|
|
})?;
|
|
|
|
let song_info = SongInfo {
|
|
index,
|
|
artist: song.artist.clone(),
|
|
title: song.title.clone(),
|
|
album: song.album.clone().unwrap_or_default(),
|
|
year: song.year,
|
|
elapsed_ms: song.elapsed,
|
|
duration_ms: song.duration,
|
|
cover_url: song.cover.as_ref().and_then(|c| block.cover_url(c)),
|
|
rating: song.rating,
|
|
};
|
|
|
|
Ok(Json(song_info))
|
|
}
|
|
|
|
/// GET /cover-url/{event_id}/{song_index} - Récupère l'URL de la pochette d'un morceau
|
|
///
|
|
/// Utilise automatiquement cover_large si disponible, sinon cover en fallback
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/cover-url/{event_id}/{song_index}",
|
|
params(
|
|
("event_id" = u64, Path, description = "Event ID du block"),
|
|
("song_index" = usize, Path, description = "Index du morceau (0-based)"),
|
|
("channel" = Option<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(¶ms).await?;
|
|
let block = client.get_block(Some(event_id)).await.map_err(|e| {
|
|
tracing::error!(
|
|
"Failed to fetch block {} from Radio Paradise: {}",
|
|
event_id,
|
|
e
|
|
);
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
let song = block.get_song(song_index).ok_or_else(|| {
|
|
tracing::warn!("Song index {} not found in block {}", song_index, event_id);
|
|
StatusCode::NOT_FOUND
|
|
})?;
|
|
|
|
// Fallback: cover_large → cover → none
|
|
let (cover_url, cover_type) = if let Some(ref cover_large) = song.cover_large {
|
|
(block.cover_url(cover_large), "cover_large")
|
|
} else if let Some(ref cover) = song.cover {
|
|
(block.cover_url(cover), "cover")
|
|
} else {
|
|
(None, "none")
|
|
};
|
|
|
|
Ok(Json(CoverUrlResponse {
|
|
event: event_id,
|
|
song_index,
|
|
cover_url,
|
|
cover_type: cover_type.to_string(),
|
|
}))
|
|
}
|
|
|
|
/// GET /stream-url/{event_id} - Récupère l'URL de streaming direct d'un block
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/stream-url/{event_id}",
|
|
params(
|
|
("event_id" = u64, Path, description = "Event ID du block (None pour le block actuel)"),
|
|
("channel" = Option<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(¶ms).await?;
|
|
let block = client.get_block(Some(event_id)).await.map_err(|e| {
|
|
tracing::error!(
|
|
"Failed to fetch block {} from Radio Paradise: {}",
|
|
event_id,
|
|
e
|
|
);
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
Ok(Json(StreamUrlResponse {
|
|
event: block.event,
|
|
stream_url: block.url,
|
|
length_ms: block.length,
|
|
}))
|
|
}
|
|
|
|
/// Documentation OpenAPI pour l'API Radio Paradise
|
|
#[derive(OpenApi)]
|
|
#[openapi(
|
|
info(
|
|
title = "Radio Paradise API",
|
|
version = "1.0.0",
|
|
description = r#"
|
|
# API REST pour Radio Paradise
|
|
|
|
Cette API permet d'accéder aux métadonnées et flux de Radio Paradise.
|
|
|
|
## Fonctionnalités
|
|
|
|
- **Métadonnées en temps réel** : Récupération du morceau en cours et des blocks
|
|
- **Multi-canaux** : Support des 4 canaux Radio Paradise (Main, Mellow, Rock, Eclectic)
|
|
- **Streaming FLAC** : Accès direct aux URLs de streaming haute qualité
|
|
- **Pochettes d'albums** : URLs complètes des couvertures (petite et grande taille)
|
|
- **Historique** : Accès aux blocks passés via event_id
|
|
|
|
## Canaux disponibles
|
|
|
|
- **0: Main Mix** - Eclectic mix of rock, world, electronica, and more
|
|
- **1: Mellow Mix** - Mellower, less aggressive music
|
|
- **2: Rock Mix** - Heavier, more guitar-driven music
|
|
- **3: Eclectic Mix** - Curated worldwide selection
|
|
|
|
## Format des données
|
|
|
|
### Blocks
|
|
Les blocks sont des fichiers FLAC continus contenant plusieurs morceaux.
|
|
Chaque block a un `event` (ID de début) et `end_event` (ID du prochain block).
|
|
|
|
### Timing
|
|
- Tous les temps sont en millisecondes (ms)
|
|
- `elapsed_ms` : temps écoulé depuis le début du block
|
|
- `duration_ms` : durée du morceau
|
|
|
|
## Exemples d'utilisation
|
|
|
|
### Récupérer le morceau en cours
|
|
```
|
|
GET /api/radioparadise/now-playing?channel=0
|
|
```
|
|
|
|
### Récupérer un block spécifique
|
|
```
|
|
GET /api/radioparadise/block/1234567?channel=0
|
|
```
|
|
|
|
### Récupérer la pochette d'un morceau (avec fallback automatique)
|
|
```
|
|
GET /api/radioparadise/cover-url/1234567/0?channel=0
|
|
```
|
|
"#
|
|
),
|
|
paths(
|
|
get_now_playing,
|
|
get_current_block,
|
|
get_block_by_id,
|
|
get_channels,
|
|
get_song_by_index,
|
|
get_cover_url,
|
|
get_stream_url
|
|
),
|
|
components(schemas(
|
|
NowPlayingResponse,
|
|
BlockResponse,
|
|
SongInfo,
|
|
ChannelInfo,
|
|
StreamUrlResponse,
|
|
CoverUrlResponse
|
|
)),
|
|
tags(
|
|
(name = "Radio Paradise", description = "Endpoints pour Radio Paradise")
|
|
)
|
|
)]
|
|
pub struct RadioParadiseApiDoc;
|
|
|
|
/// Crée le router pour l'API Radio Paradise
|
|
pub fn create_api_router(state: RadioParadiseState) -> Router {
|
|
Router::new()
|
|
.route("/now-playing", get(get_now_playing))
|
|
.route("/block/current", get(get_current_block))
|
|
.route("/block/{event_id}", get(get_block_by_id))
|
|
.route("/block/{event_id}/song/{index}", get(get_song_by_index))
|
|
.route("/cover-url/{event_id}/{song_index}", get(get_cover_url))
|
|
.route("/stream-url/{event_id}", get(get_stream_url))
|
|
.route("/channels", get(get_channels))
|
|
.with_state(state)
|
|
}
|
|
|
|
/// Trait d'extension pour pmoserver::Server
|
|
///
|
|
/// Permet d'initialiser Radio Paradise avec routes HTTP complètes
|
|
#[cfg(feature = "pmoserver")]
|
|
#[async_trait]
|
|
pub trait RadioParadiseExt {
|
|
/// Initialise l'API Radio Paradise
|
|
///
|
|
/// # Routes créées
|
|
///
|
|
/// - API: `/api/radioparadise/*`
|
|
/// - `/now-playing`
|
|
/// - `/block/*`
|
|
/// - `/channels`
|
|
/// - Swagger: `/swagger-ui/radioparadise`
|
|
async fn init_radioparadise(&mut self) -> anyhow::Result<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 ---------
|
|
|