From 23c6d8b7a7a4ff1b855818098aa798508f6e647e Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 19 Oct 2025 01:01:33 +0200 Subject: [PATCH] Ajount d'un viewer radio paradise --- Cargo.lock | 2 + PMOMusic/Cargo.toml | 3 +- PMOMusic/src/main.rs | 2 +- pmoapp/webapp/src/App.vue | 16 +- .../src/components/RadioParadiseExplorer.vue | 516 ++++++++++++++++++ pmoapp/webapp/src/router/index.ts | 2 + pmomediaserver/Cargo.toml | 2 + pmomediaserver/src/sources.rs | 13 +- pmoparadise/Cargo.toml | 10 +- pmoparadise/src/lib.rs | 6 + pmoparadise/src/pmoserver_ext.rs | 407 ++++++++++++++ 11 files changed, 972 insertions(+), 7 deletions(-) create mode 100644 pmoapp/webapp/src/components/RadioParadiseExplorer.vue create mode 100644 pmoparadise/src/pmoserver_ext.rs diff --git a/Cargo.lock b/Cargo.lock index 6564ce9f..36e2f19e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2375,6 +2375,7 @@ name = "pmoparadise" version = "0.1.0" dependencies = [ "anyhow", + "axum", "bytes", "claxon", "futures", @@ -2396,6 +2397,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "utoipa", "uuid", "wiremock", ] diff --git a/PMOMusic/Cargo.toml b/PMOMusic/Cargo.toml index c8698c1a..e175068c 100644 --- a/PMOMusic/Cargo.toml +++ b/PMOMusic/Cargo.toml @@ -7,14 +7,13 @@ edition = "2024" pmoconfig = { path = "../pmoconfig" } pmoupnp = { path = "../pmoupnp"} pmomediarenderer = { path = "../pmomediarenderer" } -pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "api"] } +pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "api"] } pmosource = { path = "../pmosource", features = ["server"] } pmoserver = { path = "../pmoserver" } pmocovers = { path = "../pmocovers", features = ["pmoserver"] } pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"]} pmoapp = { path = "../pmoapp", features = ["pmoserver"] } - tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] } tracing = "0.1.41" tracing-subscriber = "0.3.20" diff --git a/PMOMusic/src/main.rs b/PMOMusic/src/main.rs index 334cba88..41f8dd54 100644 --- a/PMOMusic/src/main.rs +++ b/PMOMusic/src/main.rs @@ -35,7 +35,7 @@ async fn main() -> Result<(), Box> { // tracing::warn!("⚠️ Failed to register Qobuz: {}", e); // } - // Enregistrer Radio Paradise + // Enregistrer Radio Paradise (inclut l'initialisation de l'API) if let Err(e) = server.register_paradise().await { tracing::warn!("⚠️ Failed to register Radio Paradise: {}", e); } diff --git a/pmoapp/webapp/src/App.vue b/pmoapp/webapp/src/App.vue index d36820bc..f78be3a9 100644 --- a/pmoapp/webapp/src/App.vue +++ b/pmoapp/webapp/src/App.vue @@ -15,6 +15,9 @@ 🎨 Cover Cache 🎵 Audio Cache 🚀 API Dashboard + + + 📻 Radio Paradise @@ -32,7 +35,7 @@ const showDebugMenu = ref(false) const route = useRoute() const isDebugRoute = computed(() => { - return ['/logs', '/upnp', '/covers-cache', '/audio-cache', '/api-dashboard'].includes(route.path) + return ['/logs', '/upnp', '/covers-cache', '/audio-cache', '/api-dashboard', '/radio-paradise'].includes(route.path) }) @@ -154,6 +157,17 @@ const isDebugRoute = computed(() => { font-weight: bold; } +.submenu-divider { + padding: 0.5rem 1rem; + margin-top: 0.5rem; + border-top: 1px solid #555; + color: #999; + font-size: 0.85em; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.5px; +} + .main-content { flex: 1; width: 100%; diff --git a/pmoapp/webapp/src/components/RadioParadiseExplorer.vue b/pmoapp/webapp/src/components/RadioParadiseExplorer.vue new file mode 100644 index 00000000..615f6c0b --- /dev/null +++ b/pmoapp/webapp/src/components/RadioParadiseExplorer.vue @@ -0,0 +1,516 @@ + + + + + diff --git a/pmoapp/webapp/src/router/index.ts b/pmoapp/webapp/src/router/index.ts index 8be42c12..a9bc198f 100644 --- a/pmoapp/webapp/src/router/index.ts +++ b/pmoapp/webapp/src/router/index.ts @@ -5,6 +5,7 @@ import CoverCacheManager from "../components/CoverCacheManager.vue"; import AudioCacheManager from "../components/AudioCacheManager.vue"; import UpnpExplorer from "../components/UpnpExplorer.vue"; import APIDashboard from "../components/APIDashboard.vue"; +import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue"; const routes = [ { path: "/", name: "home", component: HelloWorld }, @@ -13,6 +14,7 @@ const routes = [ { path: "/audio-cache", name: "audio-cache", component: AudioCacheManager }, { path: "/upnp", name: "upnp", component: UpnpExplorer }, { path: "/api-dashboard", name: "api-dashboard", component: APIDashboard }, + { path: "/radio-paradise", name: "radio-paradise", component: RadioParadiseExplorer }, ]; const router = createRouter({ diff --git a/pmomediaserver/Cargo.toml b/pmomediaserver/Cargo.toml index eb376dbf..64327c8a 100644 --- a/pmomediaserver/Cargo.toml +++ b/pmomediaserver/Cargo.toml @@ -34,3 +34,5 @@ api = ["dep:axum", "dep:utoipa", "pmosource/server"] qobuz = ["api", "dep:pmoqobuz", "dep:pmoconfig", "pmoqobuz/server"] # Feature pour activer le support Radio Paradise paradise = ["api", "dep:pmoparadise", "pmoparadise/server"] +# Feature pour activer l'API REST de Radio Paradise (en plus de la source UPnP) +paradise-api = ["paradise", "pmoparadise/pmoserver"] diff --git a/pmomediaserver/src/sources.rs b/pmomediaserver/src/sources.rs index 552e4928..20bdf183 100644 --- a/pmomediaserver/src/sources.rs +++ b/pmomediaserver/src/sources.rs @@ -165,7 +165,7 @@ impl SourcesExt for Server { #[cfg(feature = "paradise")] async fn register_paradise(&mut self) -> Result<()> { - use pmoparadise::{RadioParadiseClient, RadioParadiseSource}; + use pmoparadise::{RadioParadiseClient, RadioParadiseSource, RadioParadiseExt}; tracing::info!("Initializing Radio Paradise source..."); @@ -184,6 +184,17 @@ impl SourcesExt for Server { tracing::info!("✅ Radio Paradise source registered successfully"); + // Initialiser l'API REST Radio Paradise + #[cfg(feature = "paradise-api")] + { + tracing::info!("📻 Initializing Radio Paradise API..."); + if let Err(e) = self.init_radioparadise().await { + tracing::warn!("⚠️ Failed to initialize Radio Paradise API: {}", e); + } else { + tracing::info!("✅ Radio Paradise API initialized"); + } + } + Ok(()) } } diff --git a/pmoparadise/Cargo.toml b/pmoparadise/Cargo.toml index a3a1a562..3f3a8e4a 100644 --- a/pmoparadise/Cargo.toml +++ b/pmoparadise/Cargo.toml @@ -55,14 +55,20 @@ pmoplaylist = { path = "../pmoplaylist" } pmocovers = { path = "../pmocovers" } pmoaudiocache = { path = "../pmoaudiocache" } +# OpenAPI/Swagger support (pour pmoserver extension) +utoipa = { version = "5.4.0", optional = true } +axum = { version = "0.8.4", optional = true } + [features] default = ["metadata-only"] # Mode métadonnées seules (pas de décodage FLAC) metadata-only = [] # Active le décodage FLAC par-track per-track = ["dep:claxon", "dep:hound", "dep:tempfile"] -# Active le media server UPnP -mediaserver = ["dep:pmoupnp", "dep:pmoserver", "dep:pmodidl", "dep:uuid"] +# Active l'API REST pmoserver +pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum"] +# Active le media server UPnP (includes pmoserver) +mediaserver = ["dep:pmoupnp", "dep:pmodidl", "dep:uuid", "pmoserver"] # Feature pour activer le support serveur (cache registry) server = ["pmosource/server"] # Feature cache (deprecated - toujours actif maintenant) diff --git a/pmoparadise/src/lib.rs b/pmoparadise/src/lib.rs index 91ab62ba..9ee5ab47 100644 --- a/pmoparadise/src/lib.rs +++ b/pmoparadise/src/lib.rs @@ -255,6 +255,9 @@ pub mod track; #[cfg(feature = "mediaserver")] pub mod mediaserver; +#[cfg(feature = "pmoserver")] +pub mod pmoserver_ext; + // Re-exports for convenience pub use client::{ClientBuilder, RadioParadiseClient}; pub use error::{Error, Result}; @@ -268,6 +271,9 @@ pub use track::{TrackMetadata, TrackStream}; #[cfg(feature = "mediaserver")] pub use mediaserver::{RadioParadiseMediaServer, MediaServerBuilder}; +#[cfg(feature = "pmoserver")] +pub use pmoserver_ext::{RadioParadiseExt, RadioParadiseState, RadioParadiseApiDoc, create_api_router}; + // Version information pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/pmoparadise/src/pmoserver_ext.rs b/pmoparadise/src/pmoserver_ext.rs new file mode 100644 index 00000000..b974b7fc --- /dev/null +++ b/pmoparadise/src/pmoserver_ext.rs @@ -0,0 +1,407 @@ +//! Extension pmoserver pour Radio Paradise +//! +//! Ce module fournit un trait d'extension pour ajouter facilement l'API Radio Paradise +//! à un serveur pmoserver. + +use crate::{RadioParadiseClient, Block, NowPlaying}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + routing::get, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::RwLock; +use utoipa::{OpenApi, ToSchema}; + +/// État partagé pour l'API Radio Paradise +#[derive(Clone)] +pub struct RadioParadiseState { + client: Arc>, +} + +impl RadioParadiseState { + pub async fn new() -> anyhow::Result { + let client = RadioParadiseClient::new() + .await + .map_err(|e| anyhow::anyhow!("Failed to create RadioParadise client: {}", e))?; + Ok(Self { + client: Arc::new(RwLock::new(client)), + }) + } +} + +/// Information sur un canal Radio Paradise +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ChannelInfo { + /// ID du canal (0-3) + pub id: u8, + /// Nom du canal + pub name: String, + /// Description + pub description: String, +} + +/// Réponse avec informations étendues sur le morceau en cours +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct NowPlayingResponse { + /// Event ID du block actuel + pub event: u64, + /// Event ID du prochain block + pub end_event: u64, + /// URL de streaming du block + pub stream_url: String, + /// Durée totale du block en ms + pub block_length_ms: u64, + /// Index du morceau actuel + pub current_song_index: Option, + /// Morceau actuel + pub current_song: Option, + /// Tous les morceaux du block + pub songs: Vec, +} + +/// Information sur un morceau +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SongInfo { + /// Index dans le block + pub index: usize, + /// Artiste + pub artist: String, + /// Titre + pub title: String, + /// Album + pub album: String, + /// Année + pub year: Option, + /// Temps écoulé depuis le début du block (ms) + pub elapsed_ms: u64, + /// Durée du morceau (ms) + pub duration_ms: u64, + /// URL de la pochette + pub cover_url: Option, + /// Note (0-10) + pub rating: Option, +} + +/// Réponse pour un block +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct BlockResponse { + /// Event ID du block + pub event: u64, + /// Event ID du prochain block + pub end_event: u64, + /// URL de streaming + pub url: String, + /// Durée totale (ms) + pub length_ms: u64, + /// Morceaux du block + pub songs: Vec, +} + +impl From for BlockResponse { + fn from(block: Block) -> Self { + let songs = block + .songs_ordered() + .into_iter() + .map(|(index, song)| SongInfo { + index, + artist: song.artist.clone(), + title: song.title.clone(), + album: song.album.clone(), + year: song.year, + elapsed_ms: song.elapsed, + duration_ms: song.duration, + cover_url: song.cover.as_ref().and_then(|c| block.cover_url(c)), + rating: song.rating, + }) + .collect(); + + Self { + event: block.event, + end_event: block.end_event, + url: block.url, + length_ms: block.length, + songs, + } + } +} + +impl From for NowPlayingResponse { + fn from(np: NowPlaying) -> Self { + let songs: Vec = np + .block + .songs_ordered() + .into_iter() + .map(|(index, song)| SongInfo { + index, + artist: song.artist.clone(), + title: song.title.clone(), + album: song.album.clone(), + year: song.year, + elapsed_ms: song.elapsed, + duration_ms: song.duration, + cover_url: song.cover.as_ref().and_then(|c| np.block.cover_url(c)), + rating: song.rating, + }) + .collect(); + + let current_song = np.current_song.as_ref().and_then(|song| { + let index = np.current_song_index?; + Some(SongInfo { + index, + artist: song.artist.clone(), + title: song.title.clone(), + album: song.album.clone(), + year: song.year, + elapsed_ms: song.elapsed, + duration_ms: song.duration, + cover_url: song.cover.as_ref().and_then(|c| np.block.cover_url(c)), + rating: song.rating, + }) + }); + + Self { + event: np.block.event, + end_event: np.block.end_event, + stream_url: np.block.url, + block_length_ms: np.block.length, + current_song_index: np.current_song_index, + current_song, + songs, + } + } +} + +/// GET /now-playing - Récupère le morceau en cours +#[utoipa::path( + get, + path = "/now-playing", + responses( + (status = 200, description = "Morceau en cours", body = NowPlayingResponse), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_now_playing( + State(state): State, +) -> Result, StatusCode> { + let client = state.client.read().await; + let now_playing = client + .now_playing() + .await + .map_err(|e| { + tracing::error!("Failed to fetch now playing from Radio Paradise: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(now_playing.into())) +} + +/// GET /block/current - Récupère le block actuel +#[utoipa::path( + get, + path = "/block/current", + responses( + (status = 200, description = "Block actuel", body = BlockResponse), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_current_block( + State(state): State, +) -> Result, StatusCode> { + let client = state.client.read().await; + let block = client + .get_block(None) + .await + .map_err(|e| { + tracing::error!("Failed to fetch current block from Radio Paradise: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(block.into())) +} + +/// GET /block/{event_id} - Récupère un block spécifique +#[utoipa::path( + get, + path = "/block/{event_id}", + params( + ("event_id" = u64, Path, description = "Event ID du block") + ), + responses( + (status = 200, description = "Block demandé", body = BlockResponse), + (status = 500, description = "Erreur serveur") + ), + tag = "Radio Paradise" +)] +async fn get_block_by_id( + State(state): State, + Path(event_id): Path, +) -> Result, StatusCode> { + let client = state.client.read().await; + let block = client + .get_block(Some(event_id)) + .await + .map_err(|e| { + tracing::error!("Failed to fetch block {} from Radio Paradise: {}", event_id, e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(block.into())) +} + +/// GET /channels - Liste les canaux disponibles +#[utoipa::path( + get, + path = "/channels", + responses( + (status = 200, description = "Liste des canaux", body = Vec) + ), + tag = "Radio Paradise" +)] +async fn get_channels() -> Json> { + let channels = vec![ + ChannelInfo { + id: 0, + name: "Main Mix".to_string(), + description: "Eclectic mix of rock, world, electronica, and more".to_string(), + }, + ChannelInfo { + id: 1, + name: "Mellow Mix".to_string(), + description: "Mellower, less aggressive music".to_string(), + }, + ChannelInfo { + id: 2, + name: "Rock Mix".to_string(), + description: "Heavier, more guitar-driven music".to_string(), + }, + ChannelInfo { + id: 3, + name: "World/Etc Mix".to_string(), + description: "Global beats and world music".to_string(), + }, + ]; + + Json(channels) +} + +/// Information sur un bitrate +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct BitrateInfo { + /// ID du bitrate (0-4) + pub id: u8, + /// Nom/description + pub name: String, +} + +/// GET /bitrates - Liste les bitrates disponibles +#[utoipa::path( + get, + path = "/bitrates", + responses( + (status = 200, description = "Liste des bitrates disponibles", body = Vec) + ), + tag = "Radio Paradise" +)] +async fn get_bitrates() -> Json> { + let bitrates = vec![ + BitrateInfo { + id: 0, + name: "MP3 128 kbps".to_string(), + }, + BitrateInfo { + id: 1, + name: "AAC 64 kbps".to_string(), + }, + BitrateInfo { + id: 2, + name: "AAC 128 kbps".to_string(), + }, + BitrateInfo { + id: 3, + name: "AAC 320 kbps".to_string(), + }, + BitrateInfo { + id: 4, + name: "FLAC Lossless".to_string(), + }, + ]; + + Json(bitrates) +} + +/// Documentation OpenAPI pour l'API Radio Paradise +#[derive(OpenApi)] +#[openapi( + info( + title = "Radio Paradise API", + version = "1.0.0", + description = "API REST pour accéder aux métadonnées et streams de Radio Paradise" + ), + paths( + get_now_playing, + get_current_block, + get_block_by_id, + get_channels, + get_bitrates + ), + components(schemas( + NowPlayingResponse, + BlockResponse, + SongInfo, + ChannelInfo, + BitrateInfo + )), + tags( + (name = "Radio Paradise", description = "Endpoints pour Radio Paradise streaming") + ) +)] +pub struct RadioParadiseApiDoc; + +/// Crée le router pour l'API Radio Paradise +pub fn create_api_router(state: RadioParadiseState) -> Router { + Router::new() + .route("/now-playing", get(get_now_playing)) + .route("/block/current", get(get_current_block)) + .route("/block/{event_id}", get(get_block_by_id)) + .route("/channels", get(get_channels)) + .route("/bitrates", get(get_bitrates)) + .with_state(state) +} + +/// Trait d'extension pour pmoserver::Server +/// +/// Permet d'initialiser Radio Paradise avec routes HTTP complètes +#[cfg(feature = "pmoserver")] +pub trait RadioParadiseExt { + /// Initialise l'API Radio Paradise + /// + /// # Routes créées + /// + /// - API: `/api/radioparadise/*` + /// - Swagger: `/swagger-ui/radioparadise` + async fn init_radioparadise(&mut self) -> anyhow::Result; +} + +#[cfg(feature = "pmoserver")] +impl RadioParadiseExt for pmoserver::Server { + async fn init_radioparadise(&mut self) -> anyhow::Result { + let state = RadioParadiseState::new().await?; + + // Créer le router API + let api_router = create_api_router(state.clone()); + + // L'enregistrer avec OpenAPI + self.add_openapi( + api_router, + RadioParadiseApiDoc::openapi(), + "radioparadise" + ).await; + + Ok(state) + } +}