1 Commits

22 changed files with 273 additions and 708 deletions

2
Cargo.lock generated
View File

@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "PMOMusic"
version = "0.3.62"
version = "0.3.59"
dependencies = [
"axum 0.8.7",
"console-subscriber",

View File

@@ -1,6 +1,6 @@
[package]
name = "PMOMusic"
version = "0.3.62"
version = "0.3.59"
edition = "2024"
[dependencies]

View File

@@ -15,7 +15,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// #[cfg(tokio_unstable)]
// console_subscriber::init();
let server = Server::create_upnp_server().await?;
let server = Server::create_upnp_server().await?; // Routes personnalisées de l'application
server
.write()
.await
.add_route("/info", || async {
serde_json::json!({"version": "1.0.0"})
})
.await;
// Initialiser le système de gestion des sources musicales avec API REST
info!("📡 Initializing music sources management system...");
@@ -84,21 +91,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialiser les ProtocolInfo du MediaServer
server_instance.init_protocol_info();
let local_server_id = server_instance.udn().to_string();
// Exposer les informations de base de l'instance locale
{
let local_server_id_clone = local_server_id.clone();
server
.write()
.await
.add_route("/info", move || {
let id = local_server_id_clone.clone();
async move { serde_json::json!({"version": "1.0.0", "local_server_id": id}) }
})
.await;
}
info!(
"✅ MediaServer ready at {}{}",
server_instance.base_url(),

View File

@@ -10,17 +10,7 @@
</template>
<script setup lang="ts">
import { watch } from 'vue'
import NotificationToast from '@/components/NotificationToast.vue'
import { useShareTarget } from '@/composables/useShareTarget'
import { useUIStore } from '@/stores/ui'
const ui = useUIStore()
const { shareError } = useShareTarget()
watch(shareError, (err) => {
if (err) ui.notifyError(err)
})
</script>
<style scoped>

View File

@@ -579,8 +579,6 @@ export function useRenderers() {
volumeUp,
volumeDown,
toggleMute,
// Selection
selectedRendererId,
// Playlist binding
attachPlaylist,
detachPlaylist,

View File

@@ -1,90 +0,0 @@
import { ref, onMounted } from 'vue'
import { searchSource } from '@/services/pmosource'
import { useRenderers } from '@/composables/useRenderers'
export interface ShareTargetResult {
url: string
title: string | null
containerId: string
}
const pendingShare = ref<ShareTargetResult | null>(null)
const shareError = ref<string | null>(null)
let localServerId: string | null = null
async function fetchLocalServerId(): Promise<string | null> {
if (localServerId) return localServerId
try {
const resp = await fetch('/api/info')
if (!resp.ok) return null
const data = await resp.json()
localServerId = data.local_server_id ?? null
return localServerId
} catch {
return null
}
}
export function useShareTarget() {
const { selectedRendererId, attachAndPlayPlaylist } = useRenderers()
async function handleShareIfPresent() {
const params = new URLSearchParams(window.location.search)
const sharedUrl = params.get('share_url') ?? params.get('share_text') ?? null
const sharedTitle = params.get('share_title')
if (!sharedUrl) return
const clean = new URL(window.location.href)
clean.searchParams.delete('share_url')
clean.searchParams.delete('share_title')
clean.searchParams.delete('share_text')
window.history.replaceState({}, '', clean.toString())
try {
shareError.value = null
const result = await searchSource('url', sharedUrl)
if (result.total === 0) {
shareError.value = `Aucun contenu trouvé pour : ${sharedUrl}`
return
}
const container = result.containers[0] ?? null
const containerId = container?.id ?? result.items[0]?.id
if (!containerId) {
shareError.value = 'Contenu résolu mais sans identifiant jouable'
return
}
const serverId = await fetchLocalServerId()
const rendererId = selectedRendererId.value
if (!serverId || !rendererId) {
// Pas de renderer sélectionné ou serveur inconnu : stocker pour affichage manuel
pendingShare.value = { url: sharedUrl, title: sharedTitle, containerId }
return
}
await attachAndPlayPlaylist(rendererId, serverId, containerId)
} catch (e) {
shareError.value = e instanceof Error ? e.message : 'Erreur lors de la résolution'
}
}
function clearShare() {
pendingShare.value = null
shareError.value = null
}
onMounted(() => {
handleShareIfPresent()
})
return {
pendingShare,
shareError,
clearShare,
}
}

View File

@@ -171,18 +171,6 @@ export function getSourceImageUrl(sourceId: string): string {
return `${API_BASE}/${sourceId}/image`
}
/**
* Recherche dans une source musicale (URL, texte libre…)
*/
export async function searchSource(sourceId: string, query: string): Promise<BrowseResponse> {
const params = new URLSearchParams({ q: query })
const response = await fetch(`${API_BASE}/${sourceId}/search?${params.toString()}`)
if (!response.ok) {
throw new Error(`Search failed: ${response.status} ${response.statusText}`)
}
return response.json()
}
/**
* Récupère les capacités d'une source
*/

View File

@@ -32,15 +32,6 @@ export default defineConfig({
purpose: 'any maskable',
},
],
share_target: {
action: '/app/',
method: 'GET',
params: {
url: 'share_url',
title: 'share_title',
text: 'share_text',
},
},
},
workbox: {
navigateFallback: '/app/index.html',

View File

@@ -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::{ChannelDescriptor, channels},
channels::{ALL_CHANNELS, ChannelDescriptor},
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 connu du registre (main, mellow, rock, eclectic, beyond, ...) :
/// Pour chaque canal (main, mellow, rock, eclectic) :
/// - `/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 (registre rafraîchi par le manager)
// Ajouter les routes pour chaque canal
info!("🌐 Registering streaming routes...");
for descriptor in channels().iter() {
let slug = descriptor.slug.as_str();
for descriptor in ALL_CHANNELS.iter() {
let slug = descriptor.slug;
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: u16,
channel_id: u8,
) -> 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: u16,
channel_id: u8,
) -> 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: u16,
channel_id: u8,
) -> 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: u16,
channel_id: u8,
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: u16,
channel_id: u8,
client_id: String,
) -> Result<Response, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
@@ -347,8 +347,10 @@ fn spawn_playlist_event_handler(manager: Arc<ParadiseChannelManager>) {
});
}
fn channel_from_live_playlist(playlist_id: &str) -> Option<ChannelDescriptor> {
fn channel_from_live_playlist(playlist_id: &str) -> Option<&'static ChannelDescriptor> {
const PREFIX: &str = "radio-paradise-live-";
let slug = playlist_id.strip_prefix(PREFIX)?;
pmoparadise::channels::channel_by_slug(slug)
ALL_CHANNELS
.iter()
.find(|descriptor| descriptor.slug == slug)
}

View File

@@ -53,7 +53,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
std::process::exit(1);
}
let channel_id: u16 = match args[1].parse() {
let channel_id: u8 = match args[1].parse() {
Ok(id) => id,
Err(_) => {
eprintln!("Error: channel_id must be a number between 0 and 3");

View File

@@ -74,7 +74,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
std::process::exit(1);
}
let channel_id: u16 = match args[1].parse() {
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");

View File

@@ -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::channels, ParadiseChannelManager, ParadiseHistoryBuilder};
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;
@@ -80,8 +80,8 @@ async fn main() -> anyhow::Result<()> {
let mut server = ServerBuilder::new("RadioParadiseChannels", "http://localhost", 8080).build();
for descriptor in channels().iter() {
let slug = descriptor.slug.as_str();
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);
@@ -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 channels().iter() {
for descriptor in ALL_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: u16,
channel_id: u8,
) -> 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: u16,
channel_id: u8,
) -> 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: u16,
channel_id: u8,
) -> 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: u16,
channel_id: u8,
) -> 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: u16,
channel_id: u8,
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: u16,
channel_id: u8,
client_id: String,
) -> Result<Response, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;

View File

@@ -25,7 +25,7 @@ use pmocovers::{
new_cache_with_consolidation as new_cover_cache, register_cover_cache, Cache as CoverCache,
};
use pmoparadise::{
channels::{channels, resolve_channel, ChannelDescriptor},
channels::{ChannelDescriptor, ALL_CHANNELS},
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.clone(),
descriptor,
channel_config,
Some(cover_cache.clone()),
Some(history_opts),
@@ -227,8 +227,15 @@ async fn get_cover(
fn pick_descriptor(arg: Option<String>) -> anyhow::Result<ChannelDescriptor> {
if let Some(token) = arg {
return resolve_channel(&token)
.ok_or_else(|| anyhow::anyhow!("Unknown channel identifier: {token}"));
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(channels()[0].clone())
Ok(ALL_CHANNELS[0])
}

View File

@@ -150,7 +150,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
std::process::exit(1);
}
let channel_id: u16 = match args[1].parse() {
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");

View File

@@ -1,187 +1,103 @@
//! Radio Paradise channel definitions
//!
//! 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`].
//! This module defines the available Radio Paradise channels and their metadata.
use std::sync::{Arc, RwLock};
use std::str::FromStr;
use once_cell::sync::Lazy;
use serde::Deserialize;
/// 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, PartialEq, Eq)]
#[derive(Debug, Clone, Copy)]
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>,
pub kind: ParadiseChannelKind,
pub id: u8,
pub slug: &'static str,
pub display_name: &'static str,
pub description: &'static str,
}
impl ChannelDescriptor {
fn new_static(id: u16, slug: &str, display_name: &str, description: &str) -> Self {
pub const fn new(kind: ParadiseChannelKind) -> 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
)),
id: kind.id(),
slug: kind.slug(),
display_name: kind.display_name(),
description: kind.description(),
kind,
}
}
}
/// 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,
}
}
/// 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),
];
/// 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)
/// 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
@@ -195,63 +111,33 @@ mod tests {
use super::*;
#[test]
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");
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_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"));
fn test_max_channel_id() {
assert_eq!(max_channel_id(), 3);
}
#[test]
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());
fn test_all_channels_length() {
assert_eq!(ALL_CHANNELS.len(), 4);
}
#[test]
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());
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());
}
}

View File

@@ -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: u16 = 0;
pub const DEFAULT_CHANNEL: u8 = 0;
/// Radio Paradise HTTP client
///
@@ -55,7 +55,7 @@ pub const DEFAULT_CHANNEL: u16 = 0;
pub struct RadioParadiseClient {
pub(crate) client: Client,
api_base: String,
channel: u16,
channel: u8,
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) -> u16 {
pub fn channel(&self) -> u8 {
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: u16) -> Self {
pub fn clone_with_channel(&self, channel: u8) -> Self {
let mut cloned = self.clone();
cloned.channel = channel;
cloned.next_block_url = None;
@@ -234,36 +234,6 @@ 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
@@ -271,7 +241,7 @@ impl RadioParadiseClient {
pub struct ClientBuilder {
client: Option<Client>,
api_base: String,
channel: u16,
channel: u8,
request_timeout: Duration,
block_timeout: Duration,
user_agent: String,
@@ -310,8 +280,8 @@ impl ClientBuilder {
self
}
/// Set the channel (see `channels::channels()` for the available IDs)
pub fn channel(mut self, channel: u16) -> 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
}

View File

@@ -21,10 +21,7 @@
//! }
//! ```
use crate::{
channels::{channel_by_id, resolve_channel},
client::DEFAULT_CHANNEL,
};
use crate::{channels::ParadiseChannelKind, client::DEFAULT_CHANNEL};
use anyhow::Result;
use pmoconfig::Config;
use serde_yaml::Value;
@@ -97,10 +94,11 @@ pub trait RadioParadiseConfigExt {
///
/// # Channels disponibles
///
/// 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.
/// 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
///
@@ -116,13 +114,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<u16>;
fn get_paradise_default_channel(&self) -> Result<u8>;
/// Définit le channel par défaut
///
/// # Arguments
///
/// * `channel` - L'ID du channel (doit exister dans le registre de canaux)
/// * `channel` - Le channel (0-3)
///
/// La valeur est stockée sous forme de nom convivial ("main", "mellow", etc.)
/// dans le fichier de configuration.
@@ -130,10 +128,14 @@ 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: u16) -> Result<()>;
fn set_paradise_default_channel(&self, channel: u8) -> Result<()>;
}
impl RadioParadiseConfigExt for Config {
@@ -155,13 +157,13 @@ impl RadioParadiseConfigExt for Config {
)
}
fn get_paradise_default_channel(&self) -> Result<u16> {
fn get_paradise_default_channel(&self) -> Result<u8> {
match self.get_value(&["sources", "radio_paradise", "default_channel"]) {
Ok(Value::String(s)) => {
// Slug ("main", "mellow", ...) ou ID numérique en chaîne
match resolve_channel(&s) {
Some(descriptor) => Ok(descriptor.id),
None => {
// 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)
@@ -169,18 +171,19 @@ impl RadioParadiseConfigExt for Config {
}
}
Ok(Value::Number(n)) => {
// 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 => {
// 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)
}
}
_ => {
@@ -194,14 +197,19 @@ impl RadioParadiseConfigExt for Config {
}
}
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))?;
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(descriptor.slug),
Value::String(channel_name.to_string()),
)
}
}

View File

@@ -3,7 +3,7 @@
//! Ce module fournit un trait d'extension pour ajouter facilement l'API Radio Paradise
//! à un serveur pmoserver.
use crate::channels::{channel_by_id, channels, refresh_channels, ChannelDescriptor};
use crate::channels::{max_channel_id, ChannelDescriptor, ALL_CHANNELS};
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<u16>,
channel: Option<u8>,
}
impl RadioParadiseState {
@@ -35,15 +35,6 @@ 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)),
})
@@ -61,7 +52,7 @@ impl RadioParadiseState {
let mut client = base_client;
if let Some(channel) = params.channel {
if channel_by_id(channel).is_none() {
if channel > max_channel_id() {
tracing::warn!("Invalid Radio Paradise channel requested: {}", channel);
return Err(StatusCode::BAD_REQUEST);
}
@@ -75,26 +66,20 @@ impl RadioParadiseState {
/// Information sur un canal Radio Paradise
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ChannelInfo {
/// 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,
/// ID du canal (0-3)
pub id: u8,
/// 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,
slug: descriptor.slug.clone(),
name: descriptor.display_name.clone(),
description: descriptor.description.clone(),
image: descriptor.image.clone(),
name: descriptor.display_name.to_string(),
description: descriptor.description.to_string(),
}
}
}
@@ -266,7 +251,7 @@ impl From<NowPlaying> for NowPlayingResponse {
get,
path = "/now-playing",
params(
("channel" = Option<u16>, Query, description = "Channel ID (voir /channels)")
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
),
responses(
(status = 200, description = "Morceau en cours", body = NowPlayingResponse),
@@ -292,7 +277,7 @@ async fn get_now_playing(
get,
path = "/block/current",
params(
("channel" = Option<u16>, Query, description = "Channel ID (voir /channels)")
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
),
responses(
(status = 200, description = "Block actuel", body = BlockResponse),
@@ -319,7 +304,7 @@ async fn get_current_block(
path = "/block/{event_id}",
params(
("event_id" = u64, Path, description = "Event ID du block"),
("channel" = Option<u16>, Query, description = "Channel ID (voir /channels)")
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
),
responses(
(status = 200, description = "Block demandé", body = BlockResponse),
@@ -355,27 +340,8 @@ async fn get_block_by_id(
tag = "Radio Paradise"
)]
async fn get_channels() -> Json<Vec<ChannelInfo>> {
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)
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
@@ -385,7 +351,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<u16>, Query, description = "Channel ID (voir /channels)")
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
),
responses(
(status = 200, description = "Morceau demandé", body = SongInfo),
@@ -438,7 +404,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<u16>, Query, description = "Channel ID (voir /channels)")
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
),
responses(
(status = 200, description = "URL de la pochette avec fallback automatique", body = CoverUrlResponse),
@@ -490,7 +456,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<u16>, Query, description = "Channel ID (voir /channels)")
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
),
responses(
(status = 200, description = "URL de streaming", body = StreamUrlResponse),
@@ -534,15 +500,17 @@ 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 de tous les canaux Radio Paradise (liste dynamique)
- **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
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).
- **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

View File

@@ -1,10 +1,9 @@
//! RadioParadiseSource - Implementation of MusicSource for Radio Paradise
//!
//! This module provides a UPnP ContentDirectory source for Radio Paradise,
//! exposing live streams and historical playlists for every channel known
//! to the dynamic channel registry (see `crate::channels`).
//! exposing live streams and historical playlists for all 4 channels.
use crate::channels::{channel_by_slug, channels, ChannelDescriptor};
use crate::channels::{ChannelDescriptor, ALL_CHANNELS};
use pmosource::pmodidl::{Container, Item, Resource};
use pmosource::{
async_trait, AudioFormat, BrowseResult, MusicSource, MusicSourceError, Result,
@@ -28,7 +27,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 every channel in the registry (Main, Mellow, Rock, Eclectic, Beyond, ...)
/// - Live FLAC streams for all 4 channels (Main, Mellow, Rock, Eclectic)
/// - Historical playlists (FIFO) for each channel
///
/// # Object ID Schema
@@ -117,12 +116,12 @@ impl RadioParadiseSource {
use pmoplaylist::PlaylistManager;
// Préparer les IDs de playlists à surveiller (live + history pour chaque canal)
let ids: Vec<String> = channels()
let ids: Vec<String> = ALL_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();
@@ -152,21 +151,20 @@ 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
known_channels
ALL_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
known_channels
ALL_CHANNELS
.iter()
.find(|ch| pid.ends_with(&ch.slug))
.find(|ch| pid.ends_with(ch.slug))
.map(|ch| {
vec![format!(
"radio-paradise:channel:{}:liveplaylist",
@@ -194,26 +192,6 @@ 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);
@@ -234,27 +212,11 @@ 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 mut cover_url = cover_pk
let cover_url = cover_pk
.as_ref()
.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()));
.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()));
// Parse duration from JSON (in seconds as a float)
let duration = json["duration"]
@@ -356,8 +318,8 @@ impl RadioParadiseSource {
}
/// Get channel descriptor by slug
fn get_channel_by_slug(slug: &str) -> Option<ChannelDescriptor> {
channel_by_slug(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
@@ -394,7 +356,7 @@ impl RadioParadiseSource {
}
/// Build a channel container
async fn build_channel_container(&self, descriptor: &ChannelDescriptor) -> Container {
fn build_channel_container(&self, descriptor: &ChannelDescriptor) -> Container {
Container {
id: format!("radio-paradise:channel:{}", descriptor.slug),
parent_id: "radio-paradise".to_string(),
@@ -404,14 +366,14 @@ impl RadioParadiseSource {
title: descriptor.display_name.to_string(),
class: "object.container".to_string(),
artist: None,
album_art: Some(self.channel_art_url(descriptor).await),
album_art: None,
containers: vec![],
items: vec![],
}
}
/// Build the live playlist container for a channel
async fn build_live_playlist_container(&self, descriptor: &ChannelDescriptor) -> Container {
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),
@@ -421,15 +383,15 @@ impl RadioParadiseSource {
title: format!("{} - Live Playlist", descriptor.display_name),
class: "object.container.playlistContainer".to_string(),
artist: None,
album_art: Some(self.channel_art_url(descriptor).await),
album_art: None,
containers: vec![],
items: vec![],
}
}
/// Build a live stream item for a channel
async fn build_live_stream_item(&self, descriptor: &ChannelDescriptor) -> Item {
let stream_url = self.build_live_url(&descriptor.slug);
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),
@@ -441,7 +403,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.channel_art_url(descriptor).await),
album_art: Some(self.default_cover_url()),
album_art_pk: None,
date: None,
original_track_number: None,
@@ -460,7 +422,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![],
@@ -468,7 +430,7 @@ impl RadioParadiseSource {
}
/// Build a history container for a channel
async fn build_history_container(&self, descriptor: &ChannelDescriptor) -> Container {
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),
@@ -479,7 +441,7 @@ impl RadioParadiseSource {
// Expose l'historique comme une playlist jouable
class: "object.container.playlistContainer".to_string(),
artist: None,
album_art: Some(self.channel_art_url(descriptor).await),
album_art: None,
containers: vec![],
items: vec![],
}
@@ -491,10 +453,10 @@ impl RadioParadiseSource {
&self,
descriptor: &ChannelDescriptor,
) -> Container {
let mut container = self.build_history_container(descriptor).await;
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 playlist_id = Self::history_playlist_id(descriptor.slug);
let manager = pmoplaylist::PlaylistManager();
if let Ok(reader) = manager.get_read_handle(&playlist_id).await {
@@ -701,11 +663,11 @@ impl MusicSource for RadioParadiseSource {
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
match Self::parse_object_id(object_id) {
ObjectIdType::Root => {
// Return one container per known channel
let mut containers = Vec::new();
for ch in channels().iter() {
containers.push(self.build_channel_container(ch).await);
}
// Return the 4 channel containers
let containers: Vec<Container> = ALL_CHANNELS
.iter()
.map(|ch| self.build_channel_container(ch))
.collect();
Ok(BrowseResult::Containers(containers))
}
@@ -716,13 +678,13 @@ impl MusicSource for RadioParadiseSource {
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
})?;
let live_item = self.build_live_stream_item(&descriptor).await;
let live_playlist_container = self.build_live_playlist_container(&descriptor).await;
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;
let history_container = self.build_history_container_with_count(descriptor).await;
#[cfg(not(feature = "playlist"))]
let history_container = self.build_history_container(&descriptor).await;
let history_container = self.build_history_container(descriptor);
Ok(BrowseResult::Mixed {
containers: vec![live_playlist_container, history_container],
@@ -740,7 +702,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],
@@ -751,7 +713,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).await;
let history_container = self.build_history_container(descriptor);
Ok(BrowseResult::Containers(vec![history_container]))
}
}
@@ -761,7 +723,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).await;
let item = self.build_live_stream_item(descriptor);
Ok(BrowseResult::Items(vec![item]))
}
@@ -773,7 +735,7 @@ impl MusicSource for RadioParadiseSource {
#[cfg(feature = "playlist")]
{
let container = self.build_live_playlist_container(&descriptor).await;
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],
@@ -783,7 +745,7 @@ impl MusicSource for RadioParadiseSource {
#[cfg(not(feature = "playlist"))]
{
let container = self.build_live_playlist_container(&descriptor).await;
let container = self.build_live_playlist_container(descriptor);
Ok(BrowseResult::Containers(vec![container]))
}
}
@@ -913,7 +875,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).await)
Ok(self.build_live_stream_item(descriptor))
}
ObjectIdType::HistoryTrack { slug, pk } => {

View File

@@ -16,7 +16,7 @@ use std::{
};
use crate::{
channels::{channels, refresh_channels, ChannelDescriptor},
channels::{ChannelDescriptor, ParadiseChannelKind, ALL_CHANNELS},
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_slug: &str) -> Self {
pub fn from_config(cfg: &pmoconfig::Config, channel: ParadiseChannelKind) -> 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.clone();
let channel_display_name = descriptor.display_name;
let state = Arc::new(ChannelState {
descriptor: descriptor.clone(),
descriptor,
config,
client,
feeder: feeder.clone(),
@@ -401,7 +401,7 @@ impl ParadiseStreamChannel {
}
pub fn descriptor(&self) -> ChannelDescriptor {
self.descriptor.clone()
self.descriptor
}
/// 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<u16, Arc<ParadiseStreamChannel>>,
channels: HashMap<u8, Arc<ParadiseStreamChannel>>,
}
impl ParadiseChannelManager {
pub fn new(channels: HashMap<u16, Arc<ParadiseStreamChannel>>) -> Self {
pub fn new(channels: HashMap<u8, Arc<ParadiseStreamChannel>>) -> Self {
Self { channels }
}
@@ -901,32 +901,13 @@ 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={:?})",
channel_list.len(),
ALL_CHANNELS.len(),
server_base_url
);
let mut map = HashMap::new();
for descriptor in channel_list.iter().cloned() {
for descriptor in ALL_CHANNELS.iter().copied() {
let mut config = ParadiseStreamChannelConfig::default();
config.server_base_url = server_base_url.clone();
@@ -959,12 +940,7 @@ impl ParadiseChannelManager {
);
let channel = match tokio::time::timeout(
Duration::from_secs(20),
ParadiseStreamChannel::new(
descriptor.clone(),
config,
cover_cache.clone(),
history_opts,
),
ParadiseStreamChannel::new(descriptor, config, cover_cache.clone(), history_opts),
)
.await
{
@@ -1004,7 +980,7 @@ impl ParadiseChannelManager {
Self::with_defaults_with_cover_cache(None, None, None).await
}
pub fn get(&self, id: u16) -> Option<Arc<ParadiseStreamChannel>> {
pub fn get(&self, id: u8) -> Option<Arc<ParadiseStreamChannel>> {
self.channels.get(&id).cloned()
}
@@ -1012,7 +988,7 @@ impl ParadiseChannelManager {
self.channels.values()
}
pub async fn prefetch_until_horizon(&self, channel_id: u16) -> Result<()> {
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))?;

View File

@@ -1179,88 +1179,6 @@ async fn unregister_source_handler(Path(id): Path<String>) -> impl IntoResponse
}
}
/// Paramètres pour la recherche dans une source
#[cfg(feature = "server")]
#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
struct SearchParams {
/// Texte de recherche (URL ou termes)
q: String,
}
/// Recherche dans une source musicale
#[cfg(feature = "server")]
#[utoipa::path(
get,
path = "/{id}/search",
params(
("id" = String, Path, description = "ID de la source"),
SearchParams
),
responses(
(status = 200, description = "Résultats de la recherche", body = SourceBrowseResponse),
(status = 404, description = "Source introuvable", body = ErrorResponse),
(status = 500, description = "Erreur lors de la recherche", body = ErrorResponse),
),
tag = "sources"
)]
async fn search_source(
Path(id): Path<String>,
Query(params): Query<SearchParams>,
) -> impl IntoResponse {
match get_source(&id).await {
Some(source) => {
let query = crate::SearchQuery {
text: params.q,
media_type: crate::MediaSearchType::All,
scope: crate::SearchScope::Catalog,
limit: 50,
offset: 0,
};
match source.search(&query).await {
Ok(result) => {
let (containers_raw, items_raw) = match result {
crate::BrowseResult::Containers(c) => (c, Vec::new()),
crate::BrowseResult::Items(i) => (Vec::new(), i),
crate::BrowseResult::Mixed { containers, items } => (containers, items),
};
let containers: Vec<BrowseContainerInfo> =
containers_raw.iter().map(BrowseContainerInfo::from).collect();
let items: Vec<BrowseItemInfo> =
items_raw.iter().map(BrowseItemInfo::from).collect();
let returned_containers = containers.len();
let returned_items = items.len();
let total = returned_containers + returned_items;
let update_id = source.update_id().await;
let response = SourceBrowseResponse {
object_id: source.id().to_string(),
containers,
items,
returned_containers,
returned_items,
total,
update_id,
};
(StatusCode::OK, Json(response)).into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Search failed: {}", e),
}),
)
.into_response(),
}
}
None => (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Source '{}' not found", id),
}),
)
.into_response(),
}
}
/// Crée le router pour l'API des sources (endpoints de lecture uniquement)
///
/// # Returns
@@ -1298,7 +1216,6 @@ pub fn create_sources_router() -> Router {
.route("/{id}/cache/status", get(get_source_cache_status))
.route("/{id}/cache", post(request_source_cache))
.route("/{id}/formats", get(get_source_formats))
.route("/{id}/search", get(search_source))
}
/// Structure pour la documentation OpenAPI de base

View File

@@ -1 +1 @@
0.3.62
0.3.59