From f82c1183a836f404f5811a280156e8e3bf6c7c93 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 19 Oct 2025 23:48:56 +0200 Subject: [PATCH] lastest correction on webapp --- .../src/components/RadioParadiseExplorer.vue | 267 ++++++++++++++++-- pmoparadise/examples/show_source_image.rs | 25 +- pmoparadise/src/lib.rs | 27 +- pmoparadise/src/models.rs | 83 +++++- pmoparadise/src/pmoserver_ext.rs | 12 +- pmoparadise/src/source.rs | 75 +++-- pmosource/src/api.rs | 265 ++++++++++++++++- 7 files changed, 680 insertions(+), 74 deletions(-) diff --git a/pmoapp/webapp/src/components/RadioParadiseExplorer.vue b/pmoapp/webapp/src/components/RadioParadiseExplorer.vue index 1bf8a677..491a578d 100644 --- a/pmoapp/webapp/src/components/RadioParadiseExplorer.vue +++ b/pmoapp/webapp/src/components/RadioParadiseExplorer.vue @@ -166,6 +166,68 @@ +
+
+

🎧 Channel Tracks (Source API)

+ +
+ +
+ ❌ {{ channelBrowseError }} +
+
+ ⏳ Loading channel tracks… +
+
+
+ {{ channelBrowse.containers.length }} sub container(s) available. +
+
+
+
+
{{ item.title }}
+
{{ item.artist || item.creator || 'Unknown artist' }}
+
+
+ {{ item.album }} + + ⏱ {{ item.resources[0].duration }} + +
+
+ + + Open resource + +
+
+
+
+ No cached tracks yet for this channel. Refresh after playback starts. +
+
+
+

⏭️ Next Block Preview

@@ -284,6 +346,8 @@ import { ref, onMounted, onUnmounted, nextTick } from 'vue' const API_BASE = '/api/radioparadise' +const SOURCE_API_BASE = '/api/sources' +const SOURCE_ID = 'radio-paradise' const loading = ref(false) const error = ref(null) @@ -295,6 +359,7 @@ const selectedBitrate = ref(null) const audioPlayer = ref(null) const isPlaying = ref(false) const audioError = ref('') +const activeTrackId = ref(null) const lastUpdated = ref(null) const upcomingBlock = ref(null) const upcomingLoading = ref(false) @@ -305,6 +370,17 @@ const blockSearchLoading = ref(false) const blockSearchError = ref('') const channelsError = ref('') const bitratesError = ref('') +const channelBrowse = ref({ + object_id: '', + containers: [], + items: [], + returned_containers: 0, + returned_items: 0, + total: 0, + update_id: 0 +}) +const channelBrowseLoading = ref(false) +const channelBrowseError = ref('') let refreshTimerId = null // Format duration from milliseconds to MM:SS @@ -351,6 +427,35 @@ async function fetchBlockByEvent(eventId) { return await response.json() } +function channelObjectId(channelId) { + return `${SOURCE_ID}:channel:${channelId}` +} + +function playAudio(url) { + if (!url) { + audioError.value = 'No audio URL available' + isPlaying.value = false + return + } + + audioError.value = '' + isPlaying.value = true + + nextTick(() => { + const player = audioPlayer.value + if (!player) { + return + } + player.src = url + player.play().catch((e) => { + console.error('Failed to start playback:', e) + audioError.value = `Cannot play stream: ${e.message}` + isPlaying.value = false + activeTrackId.value = null + }) + }) +} + // Fetch now playing info async function refreshNowPlaying() { loading.value = true @@ -367,7 +472,7 @@ async function refreshNowPlaying() { upcomingBlock.value = null upcomingError.value = '' - if (isPlaying.value) { + if (isPlaying.value && !activeTrackId.value) { playStream() } } catch (e) { @@ -378,6 +483,38 @@ async function refreshNowPlaying() { } } +async function fetchChannelTracks() { + channelBrowseLoading.value = true + channelBrowseError.value = '' + + try { + const params = new URLSearchParams() + params.set('object_id', channelObjectId(selectedChannel.value)) + params.set('requested_count', '0') + + const response = await fetch(`${SOURCE_API_BASE}/${SOURCE_ID}/browse?${params.toString()}`) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = await response.json() + channelBrowse.value = { + object_id: data.object_id || channelObjectId(selectedChannel.value), + containers: data.containers ?? [], + items: data.items ?? [], + returned_containers: data.returned_containers ?? (data.containers?.length ?? 0), + returned_items: data.returned_items ?? (data.items?.length ?? 0), + total: data.total ?? ((data.containers?.length ?? 0) + (data.items?.length ?? 0)), + update_id: data.update_id ?? 0 + } + } catch (e) { + channelBrowseError.value = `Failed to load channel tracks: ${e.message}` + console.error('Error fetching channel tracks:', e) + } finally { + channelBrowseLoading.value = false + } +} + // Fetch available channels async function fetchChannels() { try { @@ -433,12 +570,14 @@ function selectChannel(channelId) { async function changeChannel() { await refreshNowPlaying() + await fetchChannelTracks() blockSearchResult.value = null blockSearchError.value = '' } async function changeBitrate() { await refreshNowPlaying() + await fetchChannelTracks() blockSearchResult.value = null blockSearchError.value = '' } @@ -447,24 +586,12 @@ function playStream() { if (!nowPlaying.value?.stream_url) { audioError.value = 'No stream URL available' isPlaying.value = false + activeTrackId.value = null return } - audioError.value = '' - isPlaying.value = true - - nextTick(() => { - const player = audioPlayer.value - if (!player) { - return - } - player.src = nowPlaying.value.stream_url - player.play().catch((e) => { - console.error('Failed to start playback:', e) - audioError.value = `Cannot play stream: ${e.message}` - isPlaying.value = false - }) - }) + activeTrackId.value = null + playAudio(nowPlaying.value.stream_url) } function stopPlayback() { @@ -475,6 +602,7 @@ function stopPlayback() { } isPlaying.value = false audioError.value = '' + activeTrackId.value = null } function togglePlayback() { @@ -487,6 +615,7 @@ function togglePlayback() { function handleAudioEnded() { isPlaying.value = false + activeTrackId.value = null } function handleAudioError() { @@ -497,6 +626,7 @@ function handleAudioError() { audioError.value = 'Unknown audio playback error' } isPlaying.value = false + activeTrackId.value = null } async function loadUpcomingBlock() { @@ -548,11 +678,24 @@ function clearBlockSearch() { blockSearchError.value = '' } +function playTrackItem(item) { + const resource = item?.resources?.find(res => res.url) + if (!resource) { + audioError.value = 'No audio resource available for this track' + isPlaying.value = false + return + } + + activeTrackId.value = item.id + playAudio(resource.url) +} + // Initialize on mount onMounted(async () => { await fetchChannels() await fetchBitrates() await refreshNowPlaying() + await fetchChannelTracks() // Auto-refresh every 30 seconds refreshTimerId = window.setInterval(() => { @@ -1047,6 +1190,14 @@ onUnmounted(() => { border: 1px solid #333; } +.channel-tracks-section { + margin-top: 32px; + padding: 20px; + border-radius: 8px; + border: 1px solid #333; + background: #141414; +} + .channels-section h3 { margin-top: 0; color: #00d4ff; @@ -1089,6 +1240,90 @@ onUnmounted(() => { font-size: 0.9em; } +.section-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 8px; + font-size: 0.85rem; + color: #9aa0a6; +} + +.loading-message { + margin-top: 16px; + color: #9aa0a6; +} + +.empty-placeholder { + margin-top: 16px; + padding: 16px; + border: 1px dashed #444; + border-radius: 6px; + color: #9aa0a6; + text-align: center; +} + +.sub-container-notice { + margin-top: 12px; + font-size: 0.8rem; + color: #9aa0a6; +} + +.track-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; + margin-top: 18px; +} + +.track-card { + background: rgba(0, 212, 255, 0.08); + border: 1px solid rgba(0, 212, 255, 0.2); + border-radius: 8px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.track-card.active { + border-color: rgba(46, 204, 113, 0.8); + box-shadow: 0 0 12px rgba(46, 204, 113, 0.25); +} + +.track-headline { + display: flex; + flex-direction: column; + gap: 4px; +} + +.track-title { + font-weight: 600; + color: #f5f5f5; +} + +.track-artist { + color: #9aa0a6; + font-size: 0.9rem; +} + +.track-meta { + display: flex; + gap: 12px; + flex-wrap: wrap; + font-size: 0.85rem; + color: #9aa0a6; +} + +.track-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + .audio-player-container { margin: 12px 0 24px; padding: 16px; diff --git a/pmoparadise/examples/show_source_image.rs b/pmoparadise/examples/show_source_image.rs index 66902a16..14c9ff9b 100644 --- a/pmoparadise/examples/show_source_image.rs +++ b/pmoparadise/examples/show_source_image.rs @@ -5,16 +5,39 @@ //! - Accessing the embedded WebP image //! - Optionally saving it to a file +use pmoaudiocache::cache as audio_cache; +use pmocovers::cache as covers_cache; use pmoparadise::{RadioParadiseClient, RadioParadiseSource}; use pmosource::MusicSource; use std::fs; use std::io::Write; +use std::sync::Arc; #[tokio::main] async fn main() -> Result<(), Box> { // Create the client and source let client = RadioParadiseClient::new().await?; - let source = RadioParadiseSource::new_default(client, "http://localhost:8080"); + + // Build lightweight caches under the system temp dir for this example + let base_dir = std::env::temp_dir().join(format!( + "pmoparadise_show_source_image_{}", + std::process::id() + )); + let covers_dir = base_dir.join("covers"); + let audio_dir = base_dir.join("audio"); + std::fs::create_dir_all(&covers_dir)?; + std::fs::create_dir_all(&audio_dir)?; + + let cover_cache = Arc::new(covers_cache::new_cache( + covers_dir.to_string_lossy().as_ref(), + 32, + )?); + let audio_cache = Arc::new(audio_cache::new_cache( + audio_dir.to_string_lossy().as_ref(), + 32, + )?); + + let source = RadioParadiseSource::new_default(client, cover_cache, audio_cache); // Display source information println!("Music Source Information"); diff --git a/pmoparadise/src/lib.rs b/pmoparadise/src/lib.rs index 886fb4d6..30333e4a 100644 --- a/pmoparadise/src/lib.rs +++ b/pmoparadise/src/lib.rs @@ -28,7 +28,9 @@ //! //! if let Some(song) = &now_playing.current_song { //! println!("Now Playing: {} - {}", song.artist, song.title); -//! println!("Album: {}", song.album); +//! if let Some(album) = &song.album { +//! println!("Album: {}", album); +//! } //! } //! //! // Get all songs in the current block @@ -189,33 +191,24 @@ //! # #[cfg(feature = "cache")] //! # { //! use pmoparadise::{RadioParadiseClient, RadioParadiseSource}; -//! use pmocovers::Cache as CoverCache; -//! use pmoaudiocache::AudioCache; //! use std::sync::Arc; //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { //! // Create caches -//! let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?); -//! let audio_cache = Arc::new(AudioCache::new("./cache/audio", 100)?); +//! let cover_cache = Arc::new(pmocovers::cache::new_cache("./cache/covers", 500)?); +//! let audio_cache = Arc::new(pmoaudiocache::cache::new_cache("./cache/audio", 100)?); //! //! // Create client and source with caching //! let client = RadioParadiseClient::new().await?; -//! let source = RadioParadiseSource::new_with_cache( -//! client.clone(), -//! "http://localhost:8080", +//! let source = RadioParadiseSource::new( +//! client, //! 50, -//! Some(cover_cache), -//! Some(audio_cache), +//! cover_cache, +//! audio_cache, //! ); //! -//! // Add songs - they will be automatically cached -//! let now_playing = client.now_playing().await?; -//! if let Some(song) = &now_playing.current_song { -//! let block = Arc::new(now_playing.block.clone()); -//! source.add_song(block, song, 0).await?; -//! // Cover and audio are now cached! -//! } +//! println!("Source ready: {}", source.name()); //! //! Ok(()) //! } diff --git a/pmoparadise/src/models.rs b/pmoparadise/src/models.rs index e7ec3ff4..5eccc536 100644 --- a/pmoparadise/src/models.rs +++ b/pmoparadise/src/models.rs @@ -1,6 +1,7 @@ //! Data models for Radio Paradise API responses use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Number; use std::collections::HashMap; /// Deserialize a string or number into a u64 @@ -34,17 +35,31 @@ where #[serde(untagged)] enum StringOrNumber { String(String), - Float(f64), - Int(u64), + Number(Number), + } + + fn to_milliseconds(value: f64) -> u64 { + if value >= 100_000.0 { + value.round() as u64 + } else { + (value * 1000.0).round() as u64 + } } match StringOrNumber::deserialize(deserializer)? { StringOrNumber::String(s) => { - let seconds = s.parse::().map_err(D::Error::custom)?; - Ok((seconds * 1000.0) as u64) + let value = s.parse::().map_err(D::Error::custom)?; + Ok(to_milliseconds(value)) + } + StringOrNumber::Number(n) => { + if let Some(int_value) = n.as_u64() { + Ok(to_milliseconds(int_value as f64)) + } else if let Some(float_value) = n.as_f64() { + Ok(to_milliseconds(float_value)) + } else { + Err(D::Error::custom("Invalid number for block length")) + } } - StringOrNumber::Float(f) => Ok((f * 1000.0) as u64), - StringOrNumber::Int(i) => Ok(i), } } @@ -435,4 +450,60 @@ mod tests { assert_eq!(idx, 1); assert_eq!(song.title, "Giant Steps"); } + + #[test] + fn test_block_length_from_seconds_string() { + let json = serde_json::json!({ + "event": 1, + "end_event": 2, + "length": "1715.54", + "url": "https://example.com/block.flac", + "song": {} + }); + + let block: Block = serde_json::from_value(json).unwrap(); + assert_eq!(block.length, 1_715_540); + } + + #[test] + fn test_block_length_from_seconds_integer() { + let json = serde_json::json!({ + "event": 1, + "end_event": 2, + "length": 1800, + "url": "https://example.com/block.flac", + "song": {} + }); + + let block: Block = serde_json::from_value(json).unwrap(); + assert_eq!(block.length, 1_800_000); + } + + #[test] + fn test_block_length_from_milliseconds_integer() { + let json = serde_json::json!({ + "event": 1, + "end_event": 2, + "length": 900_000, + "url": "https://example.com/block.flac", + "song": {} + }); + + let block: Block = serde_json::from_value(json).unwrap(); + assert_eq!(block.length, 900_000); + } + + #[test] + fn test_block_length_from_milliseconds_float() { + let json = serde_json::json!({ + "event": 1, + "end_event": 2, + "length": 900_000.0, + "url": "https://example.com/block.flac", + "song": {} + }); + + let block: Block = serde_json::from_value(json).unwrap(); + assert_eq!(block.length, 900_000); + } } diff --git a/pmoparadise/src/pmoserver_ext.rs b/pmoparadise/src/pmoserver_ext.rs index 051f25c6..7de0e084 100644 --- a/pmoparadise/src/pmoserver_ext.rs +++ b/pmoparadise/src/pmoserver_ext.rs @@ -217,6 +217,10 @@ impl From for NowPlayingResponse { #[utoipa::path( get, path = "/now-playing", + params( + ("channel" = Option, Query, description = "Channel ID (0-3)"), + ("bitrate" = Option, Query, description = "Bitrate ID (0-4)") + ), responses( (status = 200, description = "Morceau en cours", body = NowPlayingResponse), (status = 500, description = "Erreur serveur") @@ -240,6 +244,10 @@ async fn get_now_playing( #[utoipa::path( get, path = "/block/current", + params( + ("channel" = Option, Query, description = "Channel ID (0-3)"), + ("bitrate" = Option, Query, description = "Bitrate ID (0-4)") + ), responses( (status = 200, description = "Block actuel", body = BlockResponse), (status = 500, description = "Erreur serveur") @@ -264,7 +272,9 @@ async fn get_current_block( get, path = "/block/{event_id}", params( - ("event_id" = u64, Path, description = "Event ID du block") + ("event_id" = u64, Path, description = "Event ID du block"), + ("channel" = Option, Query, description = "Channel ID (0-3)"), + ("bitrate" = Option, Query, description = "Bitrate ID (0-4)") ), responses( (status = 200, description = "Block demandé", body = BlockResponse), diff --git a/pmoparadise/src/source.rs b/pmoparadise/src/source.rs index 2e3f2a86..d1bc5def 100644 --- a/pmoparadise/src/source.rs +++ b/pmoparadise/src/source.rs @@ -114,13 +114,26 @@ fn parse_track_identifier(track_id: &str) -> Option<(u8, u64, usize)> { /// # Examples /// /// ```no_run -/// use pmoparadise::{RadioParadiseSource, RadioParadiseClient}; +/// use pmoparadise::{RadioParadiseClient, RadioParadiseSource}; /// use pmosource::MusicSource; +/// use std::sync::Arc; /// /// #[tokio::main] /// async fn main() -> Result<(), Box> { /// let client = RadioParadiseClient::new().await?; -/// let source = RadioParadiseSource::new(client, "http://localhost:8080", 50); +/// +/// let base_dir = std::env::temp_dir().join("pmoparadise_doc_source"); +/// let cover_dir = base_dir.join("covers"); +/// let audio_dir = base_dir.join("audio"); +/// std::fs::create_dir_all(&cover_dir)?; +/// std::fs::create_dir_all(&audio_dir)?; +/// +/// let cover_dir_str = cover_dir.to_string_lossy().into_owned(); +/// let audio_dir_str = audio_dir.to_string_lossy().into_owned(); +/// let cover_cache = Arc::new(pmocovers::cache::new_cache(&cover_dir_str, 32)?); +/// let audio_cache = Arc::new(pmoaudiocache::cache::new_cache(&audio_dir_str, 32)?); +/// +/// let source = RadioParadiseSource::new(client, 50, cover_cache, audio_cache); /// /// println!("Source: {}", source.name()); /// println!("Supports FIFO: {}", source.supports_fifo()); @@ -431,14 +444,13 @@ impl RadioParadiseSource { .map_err(|e| MusicSourceError::CacheError(e.to_string()))?; let audio_source_uri = format!("{}#{}", block.url, song_index); - let reader = Cursor::new(flac_data.clone()); + let data_len = flac_data.len() as u64; + let reader = Cursor::new(flac_data); let audio_pk: String = channel .cache_manager - .cache_audio_from_reader(&audio_source_uri, reader, Some(flac_data.len() as u64)) + .cache_audio_from_reader(&audio_source_uri, reader, Some(data_len)) .await?; - channel.cache_manager.wait_audio_ready(&audio_pk).await?; - let cached_cover_pk = if let Some(ref image_base) = block.image_base { if let Some(ref cover) = song.cover { let image_url = format!("{}{}", image_base, cover); @@ -461,6 +473,19 @@ impl RadioParadiseSource { None }; + let metadata_cover_pk = cached_cover_pk.clone(); + channel + .cache_manager + .update_metadata( + track_id.clone(), + pmosource::TrackMetadata { + original_uri: block.url.clone(), + cached_audio_pk: Some(audio_pk.clone()), + cached_cover_pk: metadata_cover_pk, + }, + ) + .await; + let playback_url = channel.cache_manager.resolve_uri(&track_id).await?; let mut track = Track::new(track_id.clone(), song.title.clone(), playback_url); @@ -487,19 +512,33 @@ impl RadioParadiseSource { } } - channel - .cache_manager - .update_metadata( - track_id.clone(), - pmosource::TrackMetadata { - original_uri: block.url.clone(), - cached_audio_pk: Some(audio_pk.clone()), - cached_cover_pk, - }, - ) - .await; - channel.playlist.append_track(track).await; + + let channel_for_wait = channel.clone(); + let track_id_for_wait = track_id.clone(); + let audio_pk_for_wait = audio_pk.clone(); + tokio::spawn(async move { + if let Err(e) = channel_for_wait + .cache_manager + .wait_audio_ready(&audio_pk_for_wait) + .await + { + tracing::error!( + "Failed to finalize audio {} on channel {}: {}", + track_id_for_wait, + channel_for_wait.descriptor.name, + e + ); + channel_for_wait + .cache_manager + .remove_track(&track_id_for_wait) + .await; + channel_for_wait + .playlist + .remove_by_id(&track_id_for_wait) + .await; + } + }); } tracing::info!( diff --git a/pmosource/src/api.rs b/pmosource/src/api.rs index 9b7400c3..c4ea5898 100644 --- a/pmosource/src/api.rs +++ b/pmosource/src/api.rs @@ -21,7 +21,7 @@ #[cfg(feature = "server")] use axum::{ - extract::Path, + extract::{Path, Query}, http::{header, StatusCode}, response::{IntoResponse, Response}, routing::{delete, get}, @@ -37,6 +37,9 @@ use crate::{MusicSource, SourceCapabilities, SourceStatistics}; #[cfg(feature = "server")] use std::sync::Arc; +#[cfg(feature = "server")] +use pmodidl::{Container as DidlContainer, Item as DidlItem, Resource as DidlResource}; + /// Information sur une source musicale #[cfg(feature = "server")] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] @@ -139,6 +142,132 @@ pub struct ErrorResponse { pub error: String, } +/// Paramètres de navigation pour `browse` +#[cfg(feature = "server")] +#[derive(Debug, Default, Deserialize, utoipa::IntoParams)] +#[into_params(parameter_in = Query)] +pub struct BrowseParams { + /// ID de l'objet à parcourir (container ou item) + #[serde(default)] + pub object_id: Option, + + /// Index de départ (pagination) + #[serde(default)] + pub starting_index: Option, + + /// Nombre d'éléments demandés (0 = tous) + #[serde(default)] + pub requested_count: Option, +} + +/// Réponse JSON pour un browse de source +#[cfg(feature = "server")] +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct SourceBrowseResponse { + /// ObjectID parcouru + pub object_id: String, + /// Containers renvoyés + pub containers: Vec, + /// Items renvoyés + pub items: Vec, + /// Nombre de containers retournés + pub returned_containers: usize, + /// Nombre d'items retournés + pub returned_items: usize, + /// Nombre total combiné containers + items + pub total: usize, + /// Update ID de la source + pub update_id: u32, +} + +/// Informations simplifiées de container pour l'API browse +#[cfg(feature = "server")] +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct BrowseContainerInfo { + /// ID du container + pub id: String, + /// ID du parent + pub parent_id: String, + /// Titre du container + pub title: String, + /// Classe UPnP + pub class: String, + /// Nombre d'enfants (si connu) + pub child_count: Option, + /// Flag restricted + pub restricted: Option, +} + +/// Informations sur une ressource audio +#[cfg(feature = "server")] +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct BrowseItemResourceInfo { + pub url: String, + pub protocol_info: String, + pub duration: Option, +} + +/// Informations simplifiées d'item audio +#[cfg(feature = "server")] +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct BrowseItemInfo { + pub id: String, + pub parent_id: String, + pub title: String, + pub class: String, + pub artist: Option, + pub album: Option, + pub creator: Option, + pub album_art: Option, + pub resources: Vec, +} + +#[cfg(feature = "server")] +impl From<&DidlContainer> for BrowseContainerInfo { + fn from(container: &DidlContainer) -> Self { + Self { + id: container.id.clone(), + parent_id: container.parent_id.clone(), + title: container.title.clone(), + class: container.class.clone(), + child_count: container.child_count.clone(), + restricted: container.restricted.clone(), + } + } +} + +#[cfg(feature = "server")] +impl From<&DidlResource> for BrowseItemResourceInfo { + fn from(res: &DidlResource) -> Self { + Self { + url: res.url.clone(), + protocol_info: res.protocol_info.clone(), + duration: res.duration.clone(), + } + } +} + +#[cfg(feature = "server")] +impl From<&DidlItem> for BrowseItemInfo { + fn from(item: &DidlItem) -> Self { + Self { + id: item.id.clone(), + parent_id: item.parent_id.clone(), + title: item.title.clone(), + class: item.class.clone(), + artist: item.artist.clone(), + album: item.album.clone(), + creator: item.creator.clone(), + album_art: item.album_art.clone(), + resources: item + .resources + .iter() + .map(BrowseItemResourceInfo::from) + .collect(), + } + } +} + // ============= Gestionnaire de registre global ============= #[cfg(feature = "server")] @@ -194,7 +323,7 @@ pub async fn get_source(source_id: &str) -> Option> { #[cfg(feature = "server")] #[utoipa::path( get, - path = "/sources", + path = "/", responses( (status = 200, description = "Liste des sources", body = SourcesList), ), @@ -230,7 +359,7 @@ async fn list_sources() -> impl IntoResponse { #[cfg(feature = "server")] #[utoipa::path( get, - path = "/sources/{id}", + path = "/{id}", params( ("id" = String, Path, description = "ID de la source") ), @@ -268,7 +397,7 @@ async fn get_source_info(Path(id): Path) -> impl IntoResponse { #[cfg(feature = "server")] #[utoipa::path( get, - path = "/sources/{id}/capabilities", + path = "/{id}/capabilities", params( ("id" = String, Path, description = "ID de la source") ), @@ -300,7 +429,7 @@ async fn get_source_capabilities(Path(id): Path) -> impl IntoResponse { #[cfg(feature = "server")] #[utoipa::path( get, - path = "/sources/{id}/statistics", + path = "/{id}/statistics", params( ("id" = String, Path, description = "ID de la source") ), @@ -342,7 +471,7 @@ async fn get_source_statistics(Path(id): Path) -> impl IntoResponse { #[cfg(feature = "server")] #[utoipa::path( get, - path = "/sources/{id}/root", + path = "/{id}/root", params( ("id" = String, Path, description = "ID de la source") ), @@ -390,7 +519,7 @@ async fn get_source_root(Path(id): Path) -> impl IntoResponse { #[cfg(feature = "server")] #[utoipa::path( get, - path = "/sources/{id}/image", + path = "/{id}/image", params( ("id" = String, Path, description = "ID de la source") ), @@ -424,13 +553,111 @@ async fn get_source_image(Path(id): Path) -> Response { } } +/// Parcourt une source musicale (containers et items) +#[cfg(feature = "server")] +#[utoipa::path( + get, + path = "/{id}/browse", + params( + ("id" = String, Path, description = "ID de la source"), + BrowseParams + ), + responses( + (status = 200, description = "Résultat du browse", body = SourceBrowseResponse), + (status = 404, description = "Source ou objet introuvable", body = ErrorResponse), + (status = 500, description = "Erreur lors du browse", body = ErrorResponse), + ), + tag = "sources" +)] +async fn browse_source( + Path(id): Path, + Query(params): Query, +) -> impl IntoResponse { + match get_source(&id).await { + Some(source) => { + let object_id = params + .object_id + .clone() + .unwrap_or_else(|| source.id().to_string()); + + let offset = params.starting_index.unwrap_or(0); + let requested = params.requested_count.unwrap_or(0); + + let browse_result = if requested > 0 { + source.browse_paginated(&object_id, offset, requested).await + } else if offset > 0 { + source + .browse_paginated(&object_id, offset, usize::MAX) + .await + } else { + source.browse(&object_id).await + }; + + match browse_result { + 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 = containers_raw + .iter() + .map(BrowseContainerInfo::from) + .collect(); + let items: Vec = + 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, + containers, + items, + returned_containers, + returned_items, + total, + update_id, + }; + + (StatusCode::OK, Json(response)).into_response() + } + Err(crate::MusicSourceError::ObjectNotFound(_)) => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "Object not found".to_string(), + }), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Browse failed: {}", e), + }), + ) + .into_response(), + } + } + None => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Source '{}' not found", id), + }), + ) + .into_response(), + } +} + /// Désenregistre une source musicale /// /// Supprime une source du registre par son ID. #[cfg(feature = "server")] #[utoipa::path( delete, - path = "/sources/{id}", + path = "/{id}", params( ("id" = String, Path, description = "ID de la source à supprimer") ), @@ -481,13 +708,16 @@ async fn unregister_source_handler(Path(id): Path) -> impl IntoResponse #[cfg(feature = "server")] pub fn create_sources_router() -> Router { Router::new() - .route("/sources", get(list_sources)) - .route("/sources/{id}", get(get_source_info)) - .route("/sources/{id}", delete(unregister_source_handler)) - .route("/sources/{id}/capabilities", get(get_source_capabilities)) - .route("/sources/{id}/statistics", get(get_source_statistics)) - .route("/sources/{id}/root", get(get_source_root)) - .route("/sources/{id}/image", get(get_source_image)) + .route("/", get(list_sources)) + .route( + "/{id}", + get(get_source_info).delete(unregister_source_handler), + ) + .route("/{id}/capabilities", get(get_source_capabilities)) + .route("/{id}/statistics", get(get_source_statistics)) + .route("/{id}/root", get(get_source_root)) + .route("/{id}/browse", get(browse_source)) + .route("/{id}/image", get(get_source_image)) } /// Structure pour la documentation OpenAPI de base @@ -504,6 +734,7 @@ pub fn create_sources_router() -> Router { get_source_capabilities, get_source_statistics, get_source_root, + browse_source, get_source_image, unregister_source_handler, ), @@ -514,6 +745,10 @@ pub fn create_sources_router() -> Router { SourceStatisticsInfo, SourcesList, SourceRootContainer, + BrowseContainerInfo, + BrowseItemResourceInfo, + BrowseItemInfo, + SourceBrowseResponse, ErrorResponse, ) ),