2025-11-05 07:15:16 +00:00
|
|
|
//! Radio Paradise channel definitions
|
|
|
|
|
//!
|
2026-07-11 22:23:43 +02:00
|
|
|
//! 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::sync::{Arc, RwLock};
|
2025-11-05 07:15:16 +00:00
|
|
|
|
2026-07-11 22:23:43 +02:00
|
|
|
use once_cell::sync::Lazy;
|
|
|
|
|
use serde::Deserialize;
|
2025-11-05 07:15:16 +00:00
|
|
|
|
2026-07-11 22:23:43 +02:00
|
|
|
/// Metadata descriptor for a channel.
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub struct ChannelDescriptor {
|
|
|
|
|
/// 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>,
|
2025-11-05 07:15:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-11 22:23:43 +02:00
|
|
|
impl ChannelDescriptor {
|
|
|
|
|
fn new_static(id: u16, slug: &str, display_name: &str, description: &str) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
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
|
|
|
|
|
)),
|
2025-11-05 07:15:16 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-11 22:23:43 +02:00
|
|
|
}
|
2025-11-05 07:15:16 +00:00
|
|
|
|
2026-07-11 22:23:43 +02:00
|
|
|
/// 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,
|
2025-11-05 07:15:16 +00:00
|
|
|
}
|
2026-07-11 22:23:43 +02:00
|
|
|
}
|
2025-11-05 07:15:16 +00:00
|
|
|
|
2026-07-11 22:23:43 +02:00
|
|
|
/// 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"),
|
|
|
|
|
]
|
|
|
|
|
}
|
2025-11-05 07:15:16 +00:00
|
|
|
|
2026-07-11 22:23:43 +02:00
|
|
|
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()
|
2025-11-05 07:15:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-11 22:23:43 +02:00
|
|
|
/// 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()
|
|
|
|
|
}
|
2025-11-05 07:15:16 +00:00
|
|
|
|
2026-07-11 22:23:43 +02:00
|
|
|
/// 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);
|
2025-11-05 07:15:16 +00:00
|
|
|
}
|
2026-07-11 22:23:43 +02:00
|
|
|
channel_by_slug(&s.to_ascii_lowercase())
|
2025-11-05 07:15:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-11 22:23:43 +02:00
|
|
|
/// 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>,
|
2025-11-05 07:15:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-11 22:23:43 +02:00
|
|
|
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;
|
2025-11-05 07:15:16 +00:00
|
|
|
}
|
2026-07-11 22:23:43 +02:00
|
|
|
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,
|
|
|
|
|
})
|
2025-11-05 07:15:16 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 22:23:43 +02:00
|
|
|
/// 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)
|
2025-11-05 07:15:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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]
|
2026-07-11 22:23:43 +02:00
|
|
|
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");
|
2025-11-05 07:15:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-07-11 22:23:43 +02:00
|
|
|
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"));
|
2025-11-05 07:15:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-07-11 22:23:43 +02:00
|
|
|
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());
|
2025-11-05 07:15:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-07-11 22:23:43 +02:00
|
|
|
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());
|
2025-11-05 07:15:16 +00:00
|
|
|
}
|
|
|
|
|
}
|