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