12 Commits

Author SHA1 Message Date
ec56e479ba 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
Reviewed-on: #110
2026-07-11 22:58:23 +02:00
fc49c79b34 feat(pmoparadise): migrate channel IDs to u16 and use dynamic registry
Replaces the static ALL_CHANNELS array with a dynamic, thread-safe registry fetched via the Radio Paradise API. Migrates all channel identifiers from u8 to u16 across client, config, server, and example modules to support expanded ID ranges. Updates validation to runtime lookups, converts builders to async, and adds local cover caching. Bumps version from 0.3.61 to 0.3.62.
2026-07-11 22:57:49 +02:00
884b092af8 Merge pull request 'feat: add web share target support and implement catalog search API' (#109) from push-vsvqwqmmouro into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m58s
Reviewed-on: #109
2026-06-28 23:50:33 +02:00
472211012b feat: add web share target support and implement catalog search API
This patch release bumps the version to 0.3.61 and introduces several key improvements across the stack. The backend `/info` route registration is deferred until after UPnP initialization to ensure the local server ID is correctly exposed. A new `GET /{id}/search` endpoint has been added to the pmosource API for querying music catalogs. On the frontend, Web Share Target support is enabled via Vite configuration and a dedicated composable that handles incoming URL parameters, playback state, and error notifications. Renderer selection state is also now exposed for UI synchronization.
2026-06-28 23:40:35 +02:00
ce9c779bb2 Merge pull request 'chore: bump version to 0.3.60 and handle webmanifest MIME types' (#108) from push-oywlvnwootxq into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m57s
Reviewed-on: #108
2026-06-28 12:37:33 +02:00
070ca8f3e0 chore: bump version to 0.3.60 and handle webmanifest MIME types
Update PMOMusic/Cargo.toml and version.txt from 0.3.59 to 0.3.60. Add explicit application/manifest+json detection for .webmanifest files in serve_embed.rs to compensate for mime_guess limitations, converting the MIME value to an owned String and updating the CONTENT_TYPE header reference accordingly.
2026-06-28 12:37:09 +02:00
01f49670e9 Merge pull request 'feat: add PWA support and bump project version to 0.3.59' (#107) from push-qmqrwyqslsxn into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
Reviewed-on: #107
2026-06-28 12:14:43 +02:00
ed9b8046e4 feat: add PWA support and bump project version to 0.3.59
Integrate vite-plugin-pwa to enable service worker generation, offline caching, and standalone display mode. Configure manifest metadata, 192px/512px icons, theme color, and iOS status bar styling. Bump project version to 0.3.59 across Cargo.toml, Cargo.lock, and version.txt, and regenerate package-lock.json.
2026-06-28 12:04:32 +02:00
f5d9e2590e Merge pull request 'push-nlvvpvkumokx' (#105) from push-nlvvpvkumokx into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m50s
Reviewed-on: #105
2026-06-21 19:22:52 +02:00
ad45e40900 chore: resolve merge conflicts and update version to 0.3.56
Updates Cargo.toml, Cargo.lock, and version.txt from 0.3.53 to 0.3.56. Resolves divergent branch changes across configuration files and preserves the macOS .DS_Store binary structure during integration.
2026-06-21 19:22:32 +02:00
da4d9008ad Merge pull request 'push-zwznsplyvnyp' (#103) from push-zwznsplyvnyp into main
Some checks failed
Build and Push Docker Image / build (push) Failing after 3m27s
Reviewed-on: #103
2026-06-21 18:41:46 +02:00
f757e10734 chore: bump version to 0.3.54 and configure Serena project
Update PMOMusic crate version in Cargo.toml and the project version.txt from 0.3.53 to 0.3.54. Add Serena project configuration by introducing .serena/.gitignore to exclude local cache files, and create .serena/project.yml to set Rust as the language server target with UTF-8 encoding.
2026-06-21 18:05:13 +02:00
31 changed files with 5613 additions and 298 deletions

BIN
.DS_Store vendored

Binary file not shown.

2
Cargo.lock generated
View File

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

BIN
PMOMusic-A-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
PMOMusic-A-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

View File

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

View File

@@ -15,14 +15,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// #[cfg(tokio_unstable)] // #[cfg(tokio_unstable)]
// console_subscriber::init(); // console_subscriber::init();
let server = Server::create_upnp_server().await?; // Routes personnalisées de l'application let server = Server::create_upnp_server().await?;
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 // Initialiser le système de gestion des sources musicales avec API REST
info!("📡 Initializing music sources management system..."); info!("📡 Initializing music sources management system...");
@@ -91,6 +84,21 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialiser les ProtocolInfo du MediaServer // Initialiser les ProtocolInfo du MediaServer
server_instance.init_protocol_info(); 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!( info!(
"✅ MediaServer ready at {}{}", "✅ MediaServer ready at {}{}",
server_instance.base_url(), server_instance.base_url(),

View File

@@ -2,9 +2,15 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/png" sizes="192x192" href="/app/icons/icon-192.png" />
<link rel="apple-touch-icon" href="/app/icons/icon-192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>webapp</title> <meta name="theme-color" content="#111827" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="PMOMusic" />
<title>PMOMusic</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^7.1.7", "vite": "^7.1.7",
"vite-plugin-pwa": "^1.3.0",
"vue-tsc": "^3.0.7" "vue-tsc": "^3.0.7"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

View File

@@ -10,7 +10,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { watch } from 'vue'
import NotificationToast from '@/components/NotificationToast.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> </script>
<style scoped> <style scoped>

View File

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

View File

@@ -0,0 +1,90 @@
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,6 +171,18 @@ export function getSourceImageUrl(sourceId: string): string {
return `${API_BASE}/${sourceId}/image` 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 * Récupère les capacités d'une source
*/ */

View File

@@ -1,10 +1,67 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
base: '/app/',
manifest: {
name: 'PMOMusic',
short_name: 'PMOMusic',
description: 'Contrôleur UPnP/DLNA pour votre musique',
start_url: '/app/',
display: 'standalone',
orientation: 'any',
theme_color: '#111827',
background_color: '#111827',
icons: [
{
src: '/app/icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/app/icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable',
},
],
share_target: {
action: '/app/',
method: 'GET',
params: {
url: 'share_url',
title: 'share_title',
text: 'share_text',
},
},
},
workbox: {
navigateFallback: '/app/index.html',
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^\/api\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 5,
},
},
{
urlPattern: /^\/audio\//,
handler: 'NetworkOnly',
},
],
},
}),
],
base: '/app/', // Base path pour le déploiement base: '/app/', // Base path pour le déploiement
resolve: { resolve: {
alias: { alias: {

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 pmocovers::{CoverCacheExt, get_cover_cache, register_cover_cache};
use pmoparadise::{ use pmoparadise::{
ParadiseChannelManager, ParadiseHistoryBuilder, ParadiseChannelManager, ParadiseHistoryBuilder,
channels::{ALL_CHANNELS, ChannelDescriptor}, channels::{ChannelDescriptor, channels},
stream_channel::register_global_channel_manager, stream_channel::register_global_channel_manager,
}; };
use pmoplaylist::register_audio_cache as register_playlist_audio_cache; use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
@@ -47,7 +47,7 @@ pub trait ParadiseStreamingExt {
/// ///
/// # Routes créées /// # 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}/flac` - Stream FLAC live
/// - `/radioparadise/stream/{slug}/ogg` - Stream OGG live /// - `/radioparadise/stream/{slug}/ogg` - Stream OGG live
/// - `/radioparadise/stream/{slug}/historic/{client_id}/flac` - Historique FLAC /// - `/radioparadise/stream/{slug}/historic/{client_id}/flac` - Historique FLAC
@@ -157,10 +157,10 @@ impl ParadiseStreamingExt for pmoserver::Server {
manager: manager.clone(), 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..."); info!("🌐 Registering streaming routes...");
for descriptor in ALL_CHANNELS.iter() { for descriptor in channels().iter() {
let slug = descriptor.slug; let slug = descriptor.slug.as_str();
let channel_id = descriptor.id; let channel_id = descriptor.id;
// Route FLAC live // Route FLAC live
@@ -243,7 +243,7 @@ impl ParadiseStreamingExt for pmoserver::Server {
async fn stream_flac( async fn stream_flac(
manager: Arc<ParadiseChannelManager>, manager: Arc<ParadiseChannelManager>,
channel_id: u8, channel_id: u16,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
let stream = channel.subscribe_flac(); let stream = channel.subscribe_flac();
@@ -259,7 +259,7 @@ async fn stream_flac(
async fn stream_ogg( async fn stream_ogg(
manager: Arc<ParadiseChannelManager>, manager: Arc<ParadiseChannelManager>,
channel_id: u8, channel_id: u16,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
let stream = channel.subscribe_ogg(); let stream = channel.subscribe_ogg();
@@ -275,7 +275,7 @@ async fn stream_ogg(
async fn get_metadata( async fn get_metadata(
manager: Arc<ParadiseChannelManager>, manager: Arc<ParadiseChannelManager>,
channel_id: u8, channel_id: u16,
) -> Result<impl IntoResponse, StatusCode> { ) -> Result<impl IntoResponse, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
let metadata = channel.metadata().await; let metadata = channel.metadata().await;
@@ -284,7 +284,7 @@ async fn get_metadata(
async fn stream_history_flac( async fn stream_history_flac(
manager: Arc<ParadiseChannelManager>, manager: Arc<ParadiseChannelManager>,
channel_id: u8, channel_id: u16,
client_id: String, client_id: String,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; 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( async fn stream_history_ogg(
manager: Arc<ParadiseChannelManager>, manager: Arc<ParadiseChannelManager>,
channel_id: u8, channel_id: u16,
client_id: String, client_id: String,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; 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-"; const PREFIX: &str = "radio-paradise-live-";
let slug = playlist_id.strip_prefix(PREFIX)?; let slug = playlist_id.strip_prefix(PREFIX)?;
ALL_CHANNELS pmoparadise::channels::channel_by_slug(slug)
.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); std::process::exit(1);
} }
let channel_id: u8 = match args[1].parse() { let channel_id: u16 = match args[1].parse() {
Ok(id) => id, Ok(id) => id,
Err(_) => { Err(_) => {
eprintln!("Error: channel_id must be a number between 0 and 3"); 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); 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, Ok(id) if id <= 3 => id,
_ => { _ => {
eprintln!("Error: channel_id must be a number between 0 and 3"); 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, register_audio_cache as register_global_audio_cache,
}; };
use pmocovers::{new_cache_with_consolidation as new_cover_cache, register_cover_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 pmoplaylist::register_audio_cache as register_playlist_audio_cache;
use pmoserver::{init_logging, ServerBuilder}; use pmoserver::{init_logging, ServerBuilder};
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
@@ -80,8 +80,8 @@ async fn main() -> anyhow::Result<()> {
let mut server = ServerBuilder::new("RadioParadiseChannels", "http://localhost", 8080).build(); let mut server = ServerBuilder::new("RadioParadiseChannels", "http://localhost", 8080).build();
for descriptor in ALL_CHANNELS.iter() { for descriptor in channels().iter() {
let slug = descriptor.slug; let slug = descriptor.slug.as_str();
let flac_path = format!("/radioparadise/stream/{}/flac", slug); let flac_path = format!("/radioparadise/stream/{}/flac", slug);
let ogg_path = format!("/radioparadise/stream/{}/ogg", slug); let ogg_path = format!("/radioparadise/stream/{}/ogg", slug);
let icy_path = format!("/radioparadise/stream/{}/icy", slug); let icy_path = format!("/radioparadise/stream/{}/icy", slug);
@@ -161,7 +161,7 @@ async fn main() -> anyhow::Result<()> {
info!("========================================"); info!("========================================");
info!("Radio Paradise streaming server running on http://localhost:8080"); info!("Radio Paradise streaming server running on http://localhost:8080");
info!("Available channels:"); info!("Available channels:");
for descriptor in ALL_CHANNELS.iter() { for descriptor in channels().iter() {
info!( info!(
" {}: /radioparadise/stream/{}/flac (also /ogg, /icy, metadata, /historic/<client_id>/(flac|ogg))", " {}: /radioparadise/stream/{}/flac (also /ogg, /icy, metadata, /historic/<client_id>/(flac|ogg))",
descriptor.display_name, descriptor.slug descriptor.display_name, descriptor.slug
@@ -177,7 +177,7 @@ async fn main() -> anyhow::Result<()> {
async fn stream_flac( async fn stream_flac(
manager: Arc<ParadiseChannelManager>, manager: Arc<ParadiseChannelManager>,
channel_id: u8, channel_id: u16,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
let stream = channel.subscribe_flac(); let stream = channel.subscribe_flac();
@@ -193,7 +193,7 @@ async fn stream_flac(
async fn stream_ogg( async fn stream_ogg(
manager: Arc<ParadiseChannelManager>, manager: Arc<ParadiseChannelManager>,
channel_id: u8, channel_id: u16,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
let stream = channel.subscribe_ogg(); let stream = channel.subscribe_ogg();
@@ -209,7 +209,7 @@ async fn stream_ogg(
async fn stream_icy( async fn stream_icy(
manager: Arc<ParadiseChannelManager>, manager: Arc<ParadiseChannelManager>,
channel_id: u8, channel_id: u16,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
let stream = channel.subscribe_icy(); let stream = channel.subscribe_icy();
@@ -226,7 +226,7 @@ async fn stream_icy(
async fn get_metadata( async fn get_metadata(
manager: Arc<ParadiseChannelManager>, manager: Arc<ParadiseChannelManager>,
channel_id: u8, channel_id: u16,
) -> Result<impl IntoResponse, StatusCode> { ) -> Result<impl IntoResponse, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
let metadata = channel.metadata().await; let metadata = channel.metadata().await;
@@ -235,7 +235,7 @@ async fn get_metadata(
async fn stream_history_flac( async fn stream_history_flac(
manager: Arc<ParadiseChannelManager>, manager: Arc<ParadiseChannelManager>,
channel_id: u8, channel_id: u16,
client_id: String, client_id: String,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; 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( async fn stream_history_ogg(
manager: Arc<ParadiseChannelManager>, manager: Arc<ParadiseChannelManager>,
channel_id: u8, channel_id: u16,
client_id: String, client_id: String,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?; 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, new_cache_with_consolidation as new_cover_cache, register_cover_cache, Cache as CoverCache,
}; };
use pmoparadise::{ use pmoparadise::{
channels::{ChannelDescriptor, ALL_CHANNELS}, channels::{channels, resolve_channel, ChannelDescriptor},
ParadiseHistoryBuilder, ParadiseStreamChannel, ParadiseStreamChannelConfig, ParadiseHistoryBuilder, ParadiseStreamChannel, ParadiseStreamChannelConfig,
}; };
use pmoplaylist::register_audio_cache as register_playlist_audio_cache; use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
@@ -90,7 +90,7 @@ async fn main() -> anyhow::Result<()> {
let channel = Arc::new( let channel = Arc::new(
ParadiseStreamChannel::new( ParadiseStreamChannel::new(
descriptor, descriptor.clone(),
channel_config, channel_config,
Some(cover_cache.clone()), Some(cover_cache.clone()),
Some(history_opts), Some(history_opts),
@@ -227,15 +227,8 @@ async fn get_cover(
fn pick_descriptor(arg: Option<String>) -> anyhow::Result<ChannelDescriptor> { fn pick_descriptor(arg: Option<String>) -> anyhow::Result<ChannelDescriptor> {
if let Some(token) = arg { if let Some(token) = arg {
if let Some(desc) = ALL_CHANNELS.iter().find(|c| c.slug == token) { return resolve_channel(&token)
return Ok(*desc); .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())
} }

View File

@@ -150,7 +150,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
std::process::exit(1); 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, Ok(id) if id <= 3 => id,
_ => { _ => {
eprintln!("Error: channel_id must be a number between 0 and 3"); eprintln!("Error: channel_id must be a number between 0 and 3");

View File

@@ -1,103 +1,187 @@
//! Radio Paradise channel definitions //! 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. use once_cell::sync::Lazy;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] use serde::Deserialize;
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. /// Metadata descriptor for a channel.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChannelDescriptor { pub struct ChannelDescriptor {
pub kind: ParadiseChannelKind, /// Channel ID as used by the RP API (`chan` parameter). Not contiguous.
pub id: u8, pub id: u16,
pub slug: &'static str, /// Stable identifier used in playlist IDs, config paths, routes and UPnP
pub display_name: &'static str, /// object IDs. Legacy slugs are preserved for channels 0-3 so existing
pub description: &'static str, /// 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 { impl ChannelDescriptor {
pub const fn new(kind: ParadiseChannelKind) -> Self { fn new_static(id: u16, slug: &str, display_name: &str, description: &str) -> Self {
Self { Self {
id: kind.id(), id,
slug: kind.slug(), slug: slug.to_string(),
display_name: kind.display_name(), display_name: display_name.to_string(),
description: kind.description(), description: description.to_string(),
kind, // 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 /// Legacy slugs for the historical channels (0-3).
pub const ALL_CHANNELS: [ChannelDescriptor; 4] = [ ///
ChannelDescriptor::new(ParadiseChannelKind::Main), /// Playlist IDs, config paths and UPnP object IDs are derived from the slug,
ChannelDescriptor::new(ParadiseChannelKind::Mellow), /// so the original slugs must be preserved even though the API now reports
ChannelDescriptor::new(ParadiseChannelKind::Rock), /// different `stream_name`s ("main-mix", "global", ...).
ChannelDescriptor::new(ParadiseChannelKind::Eclectic), 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 /// Built-in channel list, used as fallback when the API cannot be reached.
pub const fn max_channel_id() -> u8 { ///
(ALL_CHANNELS.len() - 1) as u8 /// 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 /// Default maximum number of tracks to keep in history
@@ -111,33 +195,63 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_channel_ids() { fn test_default_channels_have_legacy_slugs() {
assert_eq!(ParadiseChannelKind::Main.id(), 0); let channels = default_channels();
assert_eq!(ParadiseChannelKind::Mellow.id(), 1); assert_eq!(channels[0].slug, "main");
assert_eq!(ParadiseChannelKind::Rock.id(), 2); assert_eq!(channels[1].slug, "mellow");
assert_eq!(ParadiseChannelKind::Eclectic.id(), 3); assert_eq!(channels[2].slug, "rock");
assert_eq!(channels[3].slug, "eclectic");
} }
#[test] #[test]
fn test_max_channel_id() { fn test_default_channels_include_new_channels() {
assert_eq!(max_channel_id(), 3); 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] #[test]
fn test_all_channels_length() { fn test_resolve_channel() {
assert_eq!(ALL_CHANNELS.len(), 4); 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] #[test]
fn test_channel_from_str() { fn test_api_channel_conversion() {
assert!(matches!( let api = ApiChannel {
"main".parse::<ParadiseChannelKind>(), chan: "3".to_string(),
Ok(ParadiseChannelKind::Main) title: "The Globe".to_string(),
)); stream_name: "global".to_string(),
assert!(matches!( channel_type: "block".to_string(),
"0".parse::<ParadiseChannelKind>(), image: None,
Ok(ParadiseChannelKind::Main) };
)); let desc = api.into_descriptor().unwrap();
assert!("invalid".parse::<ParadiseChannelKind>().is_err()); // 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());
} }
} }

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"; pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0";
/// Default channel (0 = main mix) /// Default channel (0 = main mix)
pub const DEFAULT_CHANNEL: u8 = 0; pub const DEFAULT_CHANNEL: u16 = 0;
/// Radio Paradise HTTP client /// Radio Paradise HTTP client
/// ///
@@ -55,7 +55,7 @@ pub const DEFAULT_CHANNEL: u8 = 0;
pub struct RadioParadiseClient { pub struct RadioParadiseClient {
pub(crate) client: Client, pub(crate) client: Client,
api_base: String, api_base: String,
channel: u8, channel: u16,
pub(crate) request_timeout: Duration, pub(crate) request_timeout: Duration,
pub(crate) block_timeout: Duration, pub(crate) block_timeout: Duration,
next_block_url: Option<String>, next_block_url: Option<String>,
@@ -92,7 +92,7 @@ impl RadioParadiseClient {
} }
/// Get the current channel (0 = main mix) /// Get the current channel (0 = main mix)
pub fn channel(&self) -> u8 { pub fn channel(&self) -> u16 {
self.channel self.channel
} }
@@ -102,7 +102,7 @@ impl RadioParadiseClient {
} }
/// Clone the client with a different channel while preserving other settings. /// 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(); let mut cloned = self.clone();
cloned.channel = channel; cloned.channel = channel;
cloned.next_block_url = None; cloned.next_block_url = None;
@@ -234,6 +234,36 @@ impl RadioParadiseClient {
pub fn http_client(&self) -> &Client { pub fn http_client(&self) -> &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 /// Builder for configuring a RadioParadiseClient
@@ -241,7 +271,7 @@ impl RadioParadiseClient {
pub struct ClientBuilder { pub struct ClientBuilder {
client: Option<Client>, client: Option<Client>,
api_base: String, api_base: String,
channel: u8, channel: u16,
request_timeout: Duration, request_timeout: Duration,
block_timeout: Duration, block_timeout: Duration,
user_agent: String, user_agent: String,
@@ -280,8 +310,8 @@ impl ClientBuilder {
self self
} }
/// Set the channel (0 = main mix, 1 = mellow, 2 = rock, 3 = world/etc) /// Set the channel (see `channels::channels()` for the available IDs)
pub fn channel(mut self, channel: u8) -> Self { pub fn channel(mut self, channel: u16) -> Self {
self.channel = channel; self.channel = channel;
self self
} }

View File

@@ -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 anyhow::Result;
use pmoconfig::Config; use pmoconfig::Config;
use serde_yaml::Value; use serde_yaml::Value;
@@ -94,11 +97,10 @@ pub trait RadioParadiseConfigExt {
/// ///
/// # Channels disponibles /// # Channels disponibles
/// ///
/// Peut être configuré comme chaîne de caractères ou nombre : /// Peut être configuré comme chaîne de caractères (slug) ou nombre (ID).
/// - "main" ou 0 = Main Mix (eclectic, diverse mix) /// La liste des canaux est dynamique (voir `channels::channels()`) :
/// - "mellow" ou 1 = Mellow Mix (smooth, chilled music) /// par exemple "main"/0, "mellow"/1, "rock"/2, "eclectic"/3, "beyond"/5,
/// - "rock" ou 2 = Rock Mix (classic & modern rock) /// "serenity"/42, "kfat"/945.
/// - "eclectic" ou 3 = Eclectic Mix (global sounds)
/// ///
/// # Exemple de configuration YAML /// # Exemple de configuration YAML
/// ///
@@ -114,13 +116,13 @@ pub trait RadioParadiseConfigExt {
/// let channel = config.get_paradise_default_channel()?; /// let channel = config.get_paradise_default_channel()?;
/// let client = RadioParadiseClient::builder().channel(channel).build().await?; /// 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 /// Définit le channel par défaut
/// ///
/// # Arguments /// # 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.) /// La valeur est stockée sous forme de nom convivial ("main", "mellow", etc.)
/// dans le fichier de configuration. /// dans le fichier de configuration.
@@ -128,14 +130,10 @@ pub trait RadioParadiseConfigExt {
/// # Exemple /// # Exemple
/// ///
/// ```rust,ignore /// ```rust,ignore
/// use pmoparadise::channels::ParadiseChannelKind;
///
/// // Use Mellow Mix by default /// // Use Mellow Mix by default
/// config.set_paradise_default_channel(ParadiseChannelKind::Mellow.id())?;
/// // Or simply:
/// config.set_paradise_default_channel(1)?; /// 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 { 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"]) { match self.get_value(&["sources", "radio_paradise", "default_channel"]) {
Ok(Value::String(s)) => { Ok(Value::String(s)) => {
// Try to parse as channel name (e.g., "main", "mellow", etc.) // Slug ("main", "mellow", ...) ou ID numérique en chaîne
match s.parse::<ParadiseChannelKind>() { match resolve_channel(&s) {
Ok(kind) => Ok(kind.id()), Some(descriptor) => Ok(descriptor.id),
Err(_) => { None => {
// Invalid channel name, use default // Invalid channel name, use default
self.set_paradise_default_channel(DEFAULT_CHANNEL)?; self.set_paradise_default_channel(DEFAULT_CHANNEL)?;
Ok(DEFAULT_CHANNEL) Ok(DEFAULT_CHANNEL)
@@ -171,19 +169,18 @@ impl RadioParadiseConfigExt for Config {
} }
} }
Ok(Value::Number(n)) => { Ok(Value::Number(n)) => {
// Accept numeric channel ID (0-3) // Accept numeric channel ID (must exist in the registry)
if let Some(ch) = n.as_u64() { match n
if ch <= 3 { .as_u64()
Ok(ch as u8) .and_then(|ch| u16::try_from(ch).ok())
} else { .and_then(channel_by_id)
{
Some(descriptor) => Ok(descriptor.id),
None => {
// Invalid channel number, use default // Invalid channel number, use default
self.set_paradise_default_channel(DEFAULT_CHANNEL)?; self.set_paradise_default_channel(DEFAULT_CHANNEL)?;
Ok(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<()> { fn set_paradise_default_channel(&self, channel: u16) -> Result<()> {
// Convert channel ID to user-friendly string name // Convert channel ID to user-friendly slug
let channel_name = match channel { let descriptor = channel_by_id(channel)
0 => "main", .ok_or_else(|| anyhow::anyhow!("Invalid channel ID: {}", channel))?;
1 => "mellow",
2 => "rock",
3 => "eclectic",
_ => return Err(anyhow::anyhow!("Invalid channel ID: {}", channel)),
};
self.set_value( self.set_value(
&["sources", "radio_paradise", "default_channel"], &["sources", "radio_paradise", "default_channel"],
Value::String(channel_name.to_string()), Value::String(descriptor.slug),
) )
} }
} }

View File

@@ -3,7 +3,7 @@
//! Ce module fournit un trait d'extension pour ajouter facilement l'API Radio Paradise //! Ce module fournit un trait d'extension pour ajouter facilement l'API Radio Paradise
//! à un serveur pmoserver. //! à 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 crate::{Block, NowPlaying, RadioParadiseClient};
use async_trait::async_trait; use async_trait::async_trait;
use axum::{ use axum::{
@@ -26,7 +26,7 @@ pub struct RadioParadiseState {
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
#[serde(default)] #[serde(default)]
struct ParadiseQuery { struct ParadiseQuery {
channel: Option<u8>, channel: Option<u16>,
} }
impl RadioParadiseState { impl RadioParadiseState {
@@ -35,6 +35,15 @@ impl RadioParadiseState {
.await .await
.map_err(|e| anyhow::anyhow!("Failed to create RadioParadise client: {}", e))?; .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 { Ok(Self {
client: Arc::new(RwLock::new(client)), client: Arc::new(RwLock::new(client)),
}) })
@@ -52,7 +61,7 @@ impl RadioParadiseState {
let mut client = base_client; let mut client = base_client;
if let Some(channel) = params.channel { 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); tracing::warn!("Invalid Radio Paradise channel requested: {}", channel);
return Err(StatusCode::BAD_REQUEST); return Err(StatusCode::BAD_REQUEST);
} }
@@ -66,20 +75,26 @@ impl RadioParadiseState {
/// Information sur un canal Radio Paradise /// Information sur un canal Radio Paradise
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ChannelInfo { pub struct ChannelInfo {
/// ID du canal (0-3) /// ID du canal (attention : IDs non contigus, ex. 0, 1, 2, 3, 5, 42, 945)
pub id: u8, pub id: u16,
/// Slug du canal ("main", "mellow", "beyond", ...)
pub slug: String,
/// Nom du canal /// Nom du canal
pub name: String, pub name: String,
/// Description /// Description
pub description: String, pub description: String,
/// Route locale de l'image du canal (servie par le cache covers)
pub image: Option<String>,
} }
impl From<&ChannelDescriptor> for ChannelInfo { impl From<&ChannelDescriptor> for ChannelInfo {
fn from(descriptor: &ChannelDescriptor) -> Self { fn from(descriptor: &ChannelDescriptor) -> Self {
Self { Self {
id: descriptor.id, id: descriptor.id,
name: descriptor.display_name.to_string(), slug: descriptor.slug.clone(),
description: descriptor.description.to_string(), name: descriptor.display_name.clone(),
description: descriptor.description.clone(),
image: descriptor.image.clone(),
} }
} }
} }
@@ -251,7 +266,7 @@ impl From<NowPlaying> for NowPlayingResponse {
get, get,
path = "/now-playing", path = "/now-playing",
params( params(
("channel" = Option<u8>, Query, description = "Channel ID (0-3)") ("channel" = Option<u16>, Query, description = "Channel ID (voir /channels)")
), ),
responses( responses(
(status = 200, description = "Morceau en cours", body = NowPlayingResponse), (status = 200, description = "Morceau en cours", body = NowPlayingResponse),
@@ -277,7 +292,7 @@ async fn get_now_playing(
get, get,
path = "/block/current", path = "/block/current",
params( params(
("channel" = Option<u8>, Query, description = "Channel ID (0-3)") ("channel" = Option<u16>, Query, description = "Channel ID (voir /channels)")
), ),
responses( responses(
(status = 200, description = "Block actuel", body = BlockResponse), (status = 200, description = "Block actuel", body = BlockResponse),
@@ -304,7 +319,7 @@ async fn get_current_block(
path = "/block/{event_id}", path = "/block/{event_id}",
params( params(
("event_id" = u64, Path, description = "Event ID du block"), ("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( responses(
(status = 200, description = "Block demandé", body = BlockResponse), (status = 200, description = "Block demandé", body = BlockResponse),
@@ -340,8 +355,27 @@ async fn get_block_by_id(
tag = "Radio Paradise" tag = "Radio Paradise"
)] )]
async fn get_channels() -> Json<Vec<ChannelInfo>> { async fn get_channels() -> Json<Vec<ChannelInfo>> {
let channels: Vec<ChannelInfo> = ALL_CHANNELS.iter().map(Into::into).collect(); let cover_cache = pmocovers::get_cover_cache();
Json(channels) 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 /// 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( params(
("event_id" = u64, Path, description = "Event ID du block"), ("event_id" = u64, Path, description = "Event ID du block"),
("index" = usize, Path, description = "Index du morceau (0-based)"), ("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( responses(
(status = 200, description = "Morceau demandé", body = SongInfo), (status = 200, description = "Morceau demandé", body = SongInfo),
@@ -404,7 +438,7 @@ async fn get_song_by_index(
params( params(
("event_id" = u64, Path, description = "Event ID du block"), ("event_id" = u64, Path, description = "Event ID du block"),
("song_index" = usize, Path, description = "Index du morceau (0-based)"), ("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( responses(
(status = 200, description = "URL de la pochette avec fallback automatique", body = CoverUrlResponse), (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}", path = "/stream-url/{event_id}",
params( params(
("event_id" = u64, Path, description = "Event ID du block (None pour le block actuel)"), ("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( responses(
(status = 200, description = "URL de streaming", body = StreamUrlResponse), (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 ## Fonctionnalités
- **Métadonnées en temps réel** : Récupération du morceau en cours et des blocks - **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é - **Streaming FLAC** : Accès direct aux URLs de streaming haute qualité
- **Pochettes d'albums** : URLs complètes des couvertures (petite et grande taille) - **Pochettes d'albums** : URLs complètes des couvertures (petite et grande taille)
- **Historique** : Accès aux blocks passés via event_id - **Historique** : Accès aux blocks passés via event_id
## Canaux disponibles ## Canaux disponibles
- **0: Main Mix** - Eclectic mix of rock, world, electronica, and more La liste des canaux est récupérée dynamiquement depuis l'API Radio Paradise
- **1: Mellow Mix** - Mellower, less aggressive music (`GET /channels`). Attention : les IDs ne sont pas contigus (ex. 0, 1, 2, 3, 5, 42, 945).
- **2: Rock Mix** - Heavier, more guitar-driven music
- **3: Eclectic Mix** - Curated worldwide selection
## Format des données ## Format des données

View File

@@ -1,9 +1,10 @@
//! RadioParadiseSource - Implementation of MusicSource for Radio Paradise //! RadioParadiseSource - Implementation of MusicSource for Radio Paradise
//! //!
//! This module provides a UPnP ContentDirectory source 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::pmodidl::{Container, Item, Resource};
use pmosource::{ use pmosource::{
async_trait, AudioFormat, BrowseResult, MusicSource, MusicSourceError, Result, 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 /// RadioParadiseSource - UPnP ContentDirectory source for Radio Paradise
/// ///
/// Provides access to: /// 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 /// - Historical playlists (FIFO) for each channel
/// ///
/// # Object ID Schema /// # Object ID Schema
@@ -116,12 +117,12 @@ impl RadioParadiseSource {
use pmoplaylist::PlaylistManager; use pmoplaylist::PlaylistManager;
// Préparer les IDs de playlists à surveiller (live + history pour chaque canal) // Préparer les IDs de playlists à surveiller (live + history pour chaque canal)
let ids: Vec<String> = ALL_CHANNELS let ids: Vec<String> = channels()
.iter() .iter()
.flat_map(|ch| { .flat_map(|ch| {
vec![ vec![
Self::live_playlist_id(ch.slug), Self::live_playlist_id(&ch.slug),
Self::history_playlist_id(ch.slug), Self::history_playlist_id(&ch.slug),
] ]
}) })
.collect(); .collect();
@@ -151,20 +152,21 @@ impl RadioParadiseSource {
tokio::spawn(async move { tokio::spawn(async move {
strong.bump_update_counter().await; strong.bump_update_counter().await;
// Notifier ContentDirectory des conteneurs concernés // Notifier ContentDirectory des conteneurs concernés
let known_channels = channels();
let containers: Vec<String> = if pid.contains("history") { let containers: Vec<String> = if pid.contains("history") {
// history playlist -> container history // history playlist -> container history
ALL_CHANNELS known_channels
.iter() .iter()
.find(|ch| pid.ends_with(ch.slug)) .find(|ch| pid.ends_with(&ch.slug))
.map(|ch| { .map(|ch| {
vec![format!("radio-paradise:channel:{}:history", ch.slug)] vec![format!("radio-paradise:channel:{}:history", ch.slug)]
}) })
.unwrap_or_default() .unwrap_or_default()
} else { } else {
// live playlist -> container liveplaylist // live playlist -> container liveplaylist
ALL_CHANNELS known_channels
.iter() .iter()
.find(|ch| pid.ends_with(ch.slug)) .find(|ch| pid.ends_with(&ch.slug))
.map(|ch| { .map(|ch| {
vec![format!( vec![format!(
"radio-paradise:channel:{}:liveplaylist", "radio-paradise:channel:{}:liveplaylist",
@@ -192,6 +194,26 @@ impl RadioParadiseSource {
format!("{}/api/sources/{}/image", self.base_url, self.id()) 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 /// Fetch current metadata from the live stream
async fn fetch_live_metadata(&self, slug: &str) -> Result<Option<Item>> { async fn fetch_live_metadata(&self, slug: &str) -> Result<Option<Item>> {
let metadata_url = format!("{}/radioparadise/metadata/{}", self.base_url, slug); 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 // Préférer l'URL de cache si cover_pk est fourni par le pipeline
let cover_pk = json["cover_pk"].as_str().map(|s| s.to_string()); let cover_pk = json["cover_pk"].as_str().map(|s| s.to_string());
// Stocker la route relative (le handler REST appliquera base_url.url_for()) // 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() .as_ref()
.map(|pk| pmocache::covers_route_for(pk, None)) .map(|pk| pmocache::covers_route_for(pk, None));
.or_else(|| json["cover_url"].as_str().map(|s| s.to_string())) if cover_url.is_none() {
.or_else(|| Some(self.default_cover_url())); // 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) // Parse duration from JSON (in seconds as a float)
let duration = json["duration"] let duration = json["duration"]
@@ -318,8 +356,8 @@ impl RadioParadiseSource {
} }
/// Get channel descriptor by slug /// Get channel descriptor by slug
fn get_channel_by_slug(slug: &str) -> Option<&'static ChannelDescriptor> { fn get_channel_by_slug(slug: &str) -> Option<ChannelDescriptor> {
ALL_CHANNELS.iter().find(|ch| ch.slug == slug) channel_by_slug(slug)
} }
/// Parse an object ID into its components /// Parse an object ID into its components
@@ -356,7 +394,7 @@ impl RadioParadiseSource {
} }
/// Build a channel container /// Build a channel container
fn build_channel_container(&self, descriptor: &ChannelDescriptor) -> Container { async fn build_channel_container(&self, descriptor: &ChannelDescriptor) -> Container {
Container { Container {
id: format!("radio-paradise:channel:{}", descriptor.slug), id: format!("radio-paradise:channel:{}", descriptor.slug),
parent_id: "radio-paradise".to_string(), parent_id: "radio-paradise".to_string(),
@@ -366,14 +404,14 @@ impl RadioParadiseSource {
title: descriptor.display_name.to_string(), title: descriptor.display_name.to_string(),
class: "object.container".to_string(), class: "object.container".to_string(),
artist: None, artist: None,
album_art: None, album_art: Some(self.channel_art_url(descriptor).await),
containers: vec![], containers: vec![],
items: vec![], items: vec![],
} }
} }
/// Build the live playlist container for a channel /// 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 { Container {
id: format!("radio-paradise:channel:{}:liveplaylist", descriptor.slug), id: format!("radio-paradise:channel:{}:liveplaylist", descriptor.slug),
parent_id: format!("radio-paradise:channel:{}", descriptor.slug), parent_id: format!("radio-paradise:channel:{}", descriptor.slug),
@@ -383,15 +421,15 @@ impl RadioParadiseSource {
title: format!("{} - Live Playlist", descriptor.display_name), title: format!("{} - Live Playlist", descriptor.display_name),
class: "object.container.playlistContainer".to_string(), class: "object.container.playlistContainer".to_string(),
artist: None, artist: None,
album_art: None, album_art: Some(self.channel_art_url(descriptor).await),
containers: vec![], containers: vec![],
items: vec![], items: vec![],
} }
} }
/// Build a live stream item for a channel /// Build a live stream item for a channel
fn build_live_stream_item(&self, descriptor: &ChannelDescriptor) -> Item { async fn build_live_stream_item(&self, descriptor: &ChannelDescriptor) -> Item {
let stream_url = self.build_live_url(descriptor.slug); let stream_url = self.build_live_url(&descriptor.slug);
Item { Item {
id: format!("radio-paradise:channel:{}:live", descriptor.slug), id: format!("radio-paradise:channel:{}:live", descriptor.slug),
@@ -403,7 +441,7 @@ impl RadioParadiseSource {
artist: Some("Radio Paradise".to_string()), artist: Some("Radio Paradise".to_string()),
album: Some(descriptor.display_name.to_string()), album: Some(descriptor.display_name.to_string()),
genre: Some("Radio".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, album_art_pk: None,
date: None, date: None,
original_track_number: None, original_track_number: None,
@@ -422,7 +460,7 @@ impl RadioParadiseSource {
sample_frequency: Some("44100".to_string()), sample_frequency: Some("44100".to_string()),
nr_audio_channels: Some("2".to_string()), nr_audio_channels: Some("2".to_string()),
duration: None, duration: None,
url: self.build_live_ogg_url(descriptor.slug), url: self.build_live_ogg_url(&descriptor.slug),
}, },
], ],
descriptions: vec![], descriptions: vec![],
@@ -430,7 +468,7 @@ impl RadioParadiseSource {
} }
/// Build a history container for a channel /// 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 { Container {
id: format!("radio-paradise:channel:{}:history", descriptor.slug), id: format!("radio-paradise:channel:{}:history", descriptor.slug),
parent_id: format!("radio-paradise:channel:{}", descriptor.slug), parent_id: format!("radio-paradise:channel:{}", descriptor.slug),
@@ -441,7 +479,7 @@ impl RadioParadiseSource {
// Expose l'historique comme une playlist jouable // Expose l'historique comme une playlist jouable
class: "object.container.playlistContainer".to_string(), class: "object.container.playlistContainer".to_string(),
artist: None, artist: None,
album_art: None, album_art: Some(self.channel_art_url(descriptor).await),
containers: vec![], containers: vec![],
items: vec![], items: vec![],
} }
@@ -453,10 +491,10 @@ impl RadioParadiseSource {
&self, &self,
descriptor: &ChannelDescriptor, descriptor: &ChannelDescriptor,
) -> Container { ) -> 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 // 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(); let manager = pmoplaylist::PlaylistManager();
if let Ok(reader) = manager.get_read_handle(&playlist_id).await { 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> { async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
match Self::parse_object_id(object_id) { match Self::parse_object_id(object_id) {
ObjectIdType::Root => { ObjectIdType::Root => {
// Return the 4 channel containers // Return one container per known channel
let containers: Vec<Container> = ALL_CHANNELS let mut containers = Vec::new();
.iter() for ch in channels().iter() {
.map(|ch| self.build_channel_container(ch)) containers.push(self.build_channel_container(ch).await);
.collect(); }
Ok(BrowseResult::Containers(containers)) Ok(BrowseResult::Containers(containers))
} }
@@ -678,13 +716,13 @@ impl MusicSource for RadioParadiseSource {
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
})?; })?;
let live_item = self.build_live_stream_item(descriptor); let live_item = self.build_live_stream_item(&descriptor).await;
let live_playlist_container = self.build_live_playlist_container(descriptor); let live_playlist_container = self.build_live_playlist_container(&descriptor).await;
#[cfg(feature = "playlist")] #[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"))] #[cfg(not(feature = "playlist"))]
let history_container = self.build_history_container(descriptor); let history_container = self.build_history_container(&descriptor).await;
Ok(BrowseResult::Mixed { Ok(BrowseResult::Mixed {
containers: vec![live_playlist_container, history_container], containers: vec![live_playlist_container, history_container],
@@ -702,7 +740,7 @@ impl MusicSource for RadioParadiseSource {
#[cfg(feature = "playlist")] #[cfg(feature = "playlist")]
{ {
let history_container = 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?; let items = self.get_history_items(&slug, 0, 100).await?;
Ok(BrowseResult::Mixed { Ok(BrowseResult::Mixed {
containers: vec![history_container], containers: vec![history_container],
@@ -713,7 +751,7 @@ impl MusicSource for RadioParadiseSource {
#[cfg(not(feature = "playlist"))] #[cfg(not(feature = "playlist"))]
{ {
// If playlist feature is disabled, return just the container // 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])) 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(|| { let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| {
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) 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])) Ok(BrowseResult::Items(vec![item]))
} }
@@ -735,7 +773,7 @@ impl MusicSource for RadioParadiseSource {
#[cfg(feature = "playlist")] #[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?; let items = self.get_live_playlist_items(&slug, 0, 100).await?;
Ok(BrowseResult::Mixed { Ok(BrowseResult::Mixed {
containers: vec![container], containers: vec![container],
@@ -745,7 +783,7 @@ impl MusicSource for RadioParadiseSource {
#[cfg(not(feature = "playlist"))] #[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])) Ok(BrowseResult::Containers(vec![container]))
} }
} }
@@ -875,7 +913,7 @@ impl MusicSource for RadioParadiseSource {
let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| { let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| {
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug)) 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 } => { ObjectIdType::HistoryTrack { slug, pk } => {

View File

@@ -16,7 +16,7 @@ use std::{
}; };
use crate::{ use crate::{
channels::{ChannelDescriptor, ParadiseChannelKind, ALL_CHANNELS}, channels::{channels, refresh_channels, ChannelDescriptor},
client::RadioParadiseClient, client::RadioParadiseClient,
models::{Block, EventId}, models::{Block, EventId},
playlist_feeder::RadioParadisePlaylistFeeder, playlist_feeder::RadioParadisePlaylistFeeder,
@@ -133,13 +133,13 @@ impl Default for ParadiseHistoryBuilder {
#[cfg(feature = "pmoconfig")] #[cfg(feature = "pmoconfig")]
impl ParadiseStreamChannelConfig { 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; use serde_yaml::Value;
let path = [ let path = [
"sources", "sources",
"radio_paradise", "radio_paradise",
"channels", "channels",
channel.slug(), channel_slug,
"max_lead_seconds", "max_lead_seconds",
]; ];
match cfg.get_value(&path) { match cfg.get_value(&path) {
@@ -303,10 +303,10 @@ impl ParadiseStreamChannel {
// 6. Lancer le pipeline audio // 6. Lancer le pipeline audio
let stop_token = CancellationToken::new(); let stop_token = CancellationToken::new();
let pipeline_stop = stop_token.clone(); 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 { let state = Arc::new(ChannelState {
descriptor, descriptor: descriptor.clone(),
config, config,
client, client,
feeder: feeder.clone(), feeder: feeder.clone(),
@@ -401,7 +401,7 @@ impl ParadiseStreamChannel {
} }
pub fn descriptor(&self) -> ChannelDescriptor { pub fn descriptor(&self) -> ChannelDescriptor {
self.descriptor self.descriptor.clone()
} }
/// Lance un pipeline dédié pour rejouer l'historique (FLAC pur) pour un client. /// 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. /// Gestionnaire multi-canaux.
pub struct ParadiseChannelManager { pub struct ParadiseChannelManager {
channels: HashMap<u8, Arc<ParadiseStreamChannel>>, channels: HashMap<u16, Arc<ParadiseStreamChannel>>,
} }
impl ParadiseChannelManager { impl ParadiseChannelManager {
pub fn new(channels: HashMap<u8, Arc<ParadiseStreamChannel>>) -> Self { pub fn new(channels: HashMap<u16, Arc<ParadiseStreamChannel>>) -> Self {
Self { channels } Self { channels }
} }
@@ -901,13 +901,32 @@ impl ParadiseChannelManager {
history_builder: Option<ParadiseHistoryBuilder>, history_builder: Option<ParadiseHistoryBuilder>,
server_base_url: Option<String>, server_base_url: Option<String>,
) -> Result<Self> { ) -> 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!( tracing::info!(
"➡️ Entering with_defaults_with_cover_cache ({} channels, base_url={:?})", "➡️ Entering with_defaults_with_cover_cache ({} channels, base_url={:?})",
ALL_CHANNELS.len(), channel_list.len(),
server_base_url server_base_url
); );
let mut map = HashMap::new(); let mut map = HashMap::new();
for descriptor in ALL_CHANNELS.iter().copied() { for descriptor in channel_list.iter().cloned() {
let mut config = ParadiseStreamChannelConfig::default(); let mut config = ParadiseStreamChannelConfig::default();
config.server_base_url = server_base_url.clone(); config.server_base_url = server_base_url.clone();
@@ -940,7 +959,12 @@ impl ParadiseChannelManager {
); );
let channel = match tokio::time::timeout( let channel = match tokio::time::timeout(
Duration::from_secs(20), Duration::from_secs(20),
ParadiseStreamChannel::new(descriptor, config, cover_cache.clone(), history_opts), ParadiseStreamChannel::new(
descriptor.clone(),
config,
cover_cache.clone(),
history_opts,
),
) )
.await .await
{ {
@@ -980,7 +1004,7 @@ impl ParadiseChannelManager {
Self::with_defaults_with_cover_cache(None, None, None).await 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() self.channels.get(&id).cloned()
} }
@@ -988,7 +1012,7 @@ impl ParadiseChannelManager {
self.channels.values() 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 let channel = self
.get(channel_id) .get(channel_id)
.ok_or_else(|| anyhow!("Unknown channel id {}", channel_id))?; .ok_or_else(|| anyhow!("Unknown channel id {}", channel_id))?;

View File

@@ -50,10 +50,17 @@ impl<E: RustEmbed> ServeEmbed<E> {
for candidate in candidates { for candidate in candidates {
if let Some(content) = E::get(candidate) { if let Some(content) = E::get(candidate) {
let mime = mime_guess::from_path(candidate).first_or_octet_stream(); // mime_guess ne connaît pas .webmanifest (trop récent)
let mime = if candidate.ends_with(".webmanifest") {
"application/manifest+json".to_string()
} else {
mime_guess::from_path(candidate)
.first_or_octet_stream()
.to_string()
};
return Some( return Some(
( (
[(header::CONTENT_TYPE, mime.as_ref())], [(header::CONTENT_TYPE, mime.as_str())],
content.data.into_owned(), content.data.into_owned(),
) )
.into_response(), .into_response(),

View File

@@ -1179,6 +1179,88 @@ 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) /// Crée le router pour l'API des sources (endpoints de lecture uniquement)
/// ///
/// # Returns /// # Returns
@@ -1216,6 +1298,7 @@ pub fn create_sources_router() -> Router {
.route("/{id}/cache/status", get(get_source_cache_status)) .route("/{id}/cache/status", get(get_source_cache_status))
.route("/{id}/cache", post(request_source_cache)) .route("/{id}/cache", post(request_source_cache))
.route("/{id}/formats", get(get_source_formats)) .route("/{id}/formats", get(get_source_formats))
.route("/{id}/search", get(search_source))
} }
/// Structure pour la documentation OpenAPI de base /// Structure pour la documentation OpenAPI de base

View File

@@ -1 +1 @@
0.3.55 0.3.62