diff --git a/Cargo.lock b/Cargo.lock index e0cebdc9..f2f73927 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2353,7 +2353,10 @@ dependencies = [ "claxon", "futures", "hound", + "pmoaudiocache", + "pmocovers", "pmodidl", + "pmoplaylist", "pmoserver", "pmosource", "pmoupnp", @@ -2371,6 +2374,15 @@ dependencies = [ "wiremock", ] +[[package]] +name = "pmoplaylist" +version = "0.1.0" +dependencies = [ + "pmodidl", + "serde", + "tokio", +] + [[package]] name = "pmoqobuz" version = "0.1.0" @@ -2424,8 +2436,14 @@ dependencies = [ name = "pmosource" version = "0.1.0" dependencies = [ - "image", + "anyhow", + "async-trait", + "pmoaudiocache", + "pmocovers", + "pmodidl", + "pmoplaylist", "thiserror 1.0.69", + "tokio", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0de89299..fecdecd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] resolver = "3" -members = ["PMOMusic", "pmoupnp", "pmomediarenderer", "pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp", "pmocache", "pmocovers", "pmoaudiocache", "pmoaudio", "pmoqobuz", "pmoparadise", "pmosource"] +members = ["PMOMusic", "pmoupnp", "pmomediarenderer", "pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp", "pmocache", "pmocovers", "pmoaudiocache", "pmoaudio", "pmoqobuz", "pmoparadise", "pmosource", "pmoplaylist"] diff --git a/pmoparadise/Cargo.toml b/pmoparadise/Cargo.toml index 0249ae57..fe2f6d40 100644 --- a/pmoparadise/Cargo.toml +++ b/pmoparadise/Cargo.toml @@ -48,6 +48,13 @@ uuid = { version = "1.18", optional = true } # Common music source traits pmosource = { path = "../pmosource" } +# Playlist management for FIFO support +pmoplaylist = { path = "../pmoplaylist" } + +# Cache support +pmocovers = { path = "../pmocovers", optional = true } +pmoaudiocache = { path = "../pmoaudiocache", optional = true } + [features] default = ["metadata-only"] # Mode métadonnées seules (pas de décodage FLAC) @@ -58,6 +65,8 @@ per-track = ["dep:claxon", "dep:hound", "dep:tempfile"] logging = ["dep:tracing"] # Active le media server UPnP mediaserver = ["dep:pmoupnp", "dep:pmoserver", "dep:pmodidl", "dep:uuid"] +# Active le cache d'images et audio +cache = ["dep:pmocovers", "dep:pmoaudiocache", "logging"] [dev-dependencies] # Tests @@ -65,6 +74,9 @@ tokio-test = "0.4" wiremock = "0.6" # Pour les exemples avec logging tracing-subscriber = "0.3" +# Pour l'exemple with_cache +pmocovers = { path = "../pmocovers" } +pmoaudiocache = { path = "../pmoaudiocache" } [[example]] name = "now_playing" @@ -83,3 +95,8 @@ required-features = ["per-track"] name = "upnp_mediaserver" path = "examples/upnp_mediaserver.rs" required-features = ["mediaserver"] + +[[example]] +name = "with_cache" +path = "examples/with_cache.rs" +required-features = ["cache"] diff --git a/pmoparadise/examples/with_cache.rs b/pmoparadise/examples/with_cache.rs new file mode 100644 index 00000000..04bfb3ce --- /dev/null +++ b/pmoparadise/examples/with_cache.rs @@ -0,0 +1,100 @@ +//! Example demonstrating Radio Paradise with cache support +//! +//! This example shows how to use the RadioParadiseSource with pmocovers +//! and pmoaudiocache to cache both cover images and audio tracks. +//! +//! Run with: +//! ```bash +//! cargo run --example with_cache --features cache +//! ``` + +use pmoparadise::{RadioParadiseClient, RadioParadiseSource}; +use pmocovers::Cache as CoverCache; +use pmoaudiocache::AudioCache; +use pmosource::MusicSource; +use std::sync::Arc; +use tokio::time::{sleep, Duration}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing + tracing_subscriber::fmt::init(); + + println!("🎵 Radio Paradise with Cache Support"); + println!("=====================================\n"); + + // Create the Radio Paradise client + println!("📡 Connecting to Radio Paradise..."); + let client = RadioParadiseClient::new().await?; + println!("✅ Connected!\n"); + + // Initialize caches + println!("💾 Initializing caches..."); + let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?); + let audio_cache = Arc::new(AudioCache::new("./cache/audio", 100)?); + println!("✅ Caches initialized!\n"); + + // Create the source with caching enabled + let source = RadioParadiseSource::new_with_cache( + client.clone(), + "http://localhost:8080", + 50, + Some(cover_cache.clone()), + Some(audio_cache.clone()), + ); + + println!("📻 Source: {}", source.name()); + println!("🆔 ID: {}", source.id()); + println!("📝 Supports FIFO: {}\n", source.supports_fifo()); + + // Fetch current playing information + println!("🎧 Fetching current track information..."); + let now_playing = client.now_playing().await?; + let block = Arc::new(now_playing.block.clone()); + + println!("\n🎵 Now Playing:"); + println!(" Event: {}", block.event); + if let Some(song) = &now_playing.current_song { + println!(" Title: {}", song.title); + println!(" Artist: {}", song.artist); + println!(" Album: {}", song.album); + } + println!(); + + // Add current song to the source + println!("➕ Adding current track to FIFO with caching..."); + if let Some(song) = &now_playing.current_song { + source.add_song(block.clone(), song, now_playing.current_song_index.unwrap_or(0)).await?; + println!("✅ Track added and caching started!"); + println!(" - Cover image will be cached to: ./cache/covers/"); + println!(" - Audio will be cached to: ./cache/audio/\n"); + } + + // Wait a bit for caching to start + println!("⏳ Waiting for cache operations to complete..."); + sleep(Duration::from_secs(5)).await; + + // Get items from FIFO + println!("\n📋 Items in FIFO:"); + let items = source.get_items(0, 10).await?; + for (i, item) in items.iter().enumerate() { + println!(" {}. {} - {}", + i + 1, + item.artist.as_deref().unwrap_or("Unknown"), + item.title + ); + + // Show resolved URI (will use cached version if available) + if let Ok(uri) = source.resolve_uri(&item.id).await { + println!(" URI: {}", uri); + } + } + + println!("\n✨ Example complete!"); + println!("\n💡 Tips:"); + println!(" - Run the example again to see faster loading from cache"); + println!(" - Check ./cache/covers/ for cached cover images"); + println!(" - Check ./cache/audio/ for cached FLAC files"); + + Ok(()) +} diff --git a/pmoparadise/src/lib.rs b/pmoparadise/src/lib.rs index 6eeac657..91ab62ba 100644 --- a/pmoparadise/src/lib.rs +++ b/pmoparadise/src/lib.rs @@ -180,12 +180,63 @@ //! } //! ``` //! +//! ## Caching Support (Feature: `cache`) +//! +//! `pmoparadise` can optionally integrate with `pmocovers` and `pmoaudiocache` to cache +//! cover images and audio tracks locally: +//! +//! ```no_run +//! # #[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)?); +//! +//! // Create client and source with caching +//! let client = RadioParadiseClient::new().await?; +//! let source = RadioParadiseSource::new_with_cache( +//! client.clone(), +//! "http://localhost:8080", +//! 50, +//! Some(cover_cache), +//! Some(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! +//! } +//! +//! Ok(()) +//! } +//! # } +//! ``` +//! +//! **Benefits**: +//! - Cover images are automatically downloaded and converted to WebP +//! - Audio tracks are cached as FLAC with metadata preserved +//! - Subsequent access is instant (no re-download) +//! - URIs returned by `resolve_uri()` point to cached versions +//! +//! See the `with_cache` example for a complete demonstration. +//! //! ## Cargo Features //! //! - `default = ["metadata-only"]`: Standard metadata and streaming (no FLAC decoding) //! - `per-track`: Enable FLAC decoding and per-track extraction (adds `claxon`, `hound`, `tempfile`) //! - `logging`: Enable tracing logs for debugging //! - `mediaserver`: Enable UPnP/DLNA Media Server (adds `pmoupnp`, `pmoserver`, `pmodidl`) +//! - `cache`: Enable cover and audio caching support (adds `pmocovers`, `pmoaudiocache`, enables `logging`) //! //! ## See Also //! diff --git a/pmoparadise/src/source.rs b/pmoparadise/src/source.rs index 7068400a..0dc3a29e 100644 --- a/pmoparadise/src/source.rs +++ b/pmoparadise/src/source.rs @@ -1,35 +1,374 @@ //! Music source implementation for Radio Paradise //! //! This module implements the [`pmosource::MusicSource`] trait for Radio Paradise, -//! providing access to the service's default image and identification information. +//! providing a complete music source with FIFO playlist support, browsing, and caching. -use pmosource::MusicSource; +use crate::client::RadioParadiseClient; +use crate::models::{Block, Song}; +use pmosource::{async_trait, pmodidl, BrowseResult, MusicSource, MusicSourceError, Result}; +use pmodidl::{Container, Item, Resource}; +use pmoplaylist::{FifoPlaylist, Track}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::SystemTime; +use tokio::sync::RwLock; + +#[cfg(feature = "cache")] +use pmocovers::Cache as CoverCache; +#[cfg(feature = "cache")] +use pmoaudiocache::{AudioCache, AudioMetadata}; /// Default image for Radio Paradise (300x300 WebP, embedded in binary) const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); -/// Radio Paradise music source +/// Default FIFO capacity (number of recent tracks to keep) +const DEFAULT_FIFO_CAPACITY: usize = 50; + +/// Radio Paradise music source with full MusicSource trait implementation /// -/// This struct implements the [`MusicSource`] trait to provide -/// standardized access to Radio Paradise's identification and branding. +/// This struct combines a [`RadioParadiseClient`] for API access with a FIFO playlist +/// for dynamic track management, implementing the complete [`MusicSource`] trait. +/// +/// # Features +/// +/// - **FIFO Playlist**: Dynamic track management with configurable capacity +/// - **API Integration**: Fetches blocks and metadata from Radio Paradise +/// - **URI Resolution**: Resolves track URIs with optional cache support +/// - **Change Tracking**: Tracks update_id and last_change for UPnP notifications +/// - **DIDL-Lite Export**: Converts tracks and blocks to UPnP-compatible formats /// /// # Examples /// -/// ``` -/// use pmoparadise::RadioParadiseSource; +/// ```no_run +/// use pmoparadise::{RadioParadiseSource, RadioParadiseClient}; /// use pmosource::MusicSource; /// -/// let source = RadioParadiseSource; -/// assert_eq!(source.name(), "Radio Paradise"); -/// assert_eq!(source.id(), "radio-paradise"); +/// #[tokio::main] +/// async fn main() -> Result<(), Box> { +/// let client = RadioParadiseClient::new().await?; +/// let source = RadioParadiseSource::new(client, "http://localhost:8080", 50); /// -/// // Get default image as WebP bytes -/// let image_data = source.default_image(); -/// assert!(image_data.len() > 0); +/// println!("Source: {}", source.name()); +/// println!("Supports FIFO: {}", source.supports_fifo()); +/// +/// // Start streaming and the FIFO will be populated +/// Ok(()) +/// } /// ``` -#[derive(Debug, Clone, Copy, Default)] -pub struct RadioParadiseSource; +#[derive(Clone)] +pub struct RadioParadiseSource { + inner: Arc, +} +struct RadioParadiseSourceInner { + /// Radio Paradise API client + client: RadioParadiseClient, + + /// FIFO playlist for dynamic track management + playlist: FifoPlaylist, + + /// Cache server base URL for URI resolution + cache_base_url: String, + + /// Track metadata cache (track_id -> (original_uri, cached_pk, block_event)) + track_cache: RwLock>, + + /// Cover image cache (optional) + #[cfg(feature = "cache")] + cover_cache: Option>, + + /// Audio cache (optional) + #[cfg(feature = "cache")] + audio_cache: Option>, +} + +#[derive(Debug, Clone)] +struct TrackMetadata { + original_uri: String, + cached_pk: Option, + block: Arc, + song_index: usize, + #[cfg(feature = "cache")] + cached_audio_pk: Option, + #[cfg(feature = "cache")] + cached_cover_pk: Option, +} + +impl std::fmt::Debug for RadioParadiseSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RadioParadiseSource") + .field("cache_base_url", &self.inner.cache_base_url) + .finish() + } +} + +impl RadioParadiseSource { + /// Create a new Radio Paradise source + /// + /// # Arguments + /// + /// * `client` - Radio Paradise API client + /// * `cache_base_url` - Base URL for the cache server (e.g., "http://localhost:8080") + /// * `fifo_capacity` - Maximum number of tracks in the FIFO + pub fn new( + client: RadioParadiseClient, + cache_base_url: impl Into, + fifo_capacity: usize, + ) -> Self { + let playlist = FifoPlaylist::new( + "radio-paradise".to_string(), + "Radio Paradise".to_string(), + fifo_capacity, + DEFAULT_IMAGE, + ); + + Self { + inner: Arc::new(RadioParadiseSourceInner { + client, + playlist, + cache_base_url: cache_base_url.into(), + track_cache: RwLock::new(HashMap::new()), + #[cfg(feature = "cache")] + cover_cache: None, + #[cfg(feature = "cache")] + audio_cache: None, + }), + } + } + + /// Create with default FIFO capacity + pub fn new_default(client: RadioParadiseClient, cache_base_url: impl Into) -> Self { + Self::new(client, cache_base_url, DEFAULT_FIFO_CAPACITY) + } + + /// Create a new Radio Paradise source with caching support + /// + /// # Arguments + /// + /// * `client` - Radio Paradise API client + /// * `cache_base_url` - Base URL for the cache server (e.g., "http://localhost:8080") + /// * `fifo_capacity` - Maximum number of tracks in the FIFO + /// * `cover_cache` - Optional cover image cache + /// * `audio_cache` - Optional audio cache + #[cfg(feature = "cache")] + pub fn new_with_cache( + client: RadioParadiseClient, + cache_base_url: impl Into, + fifo_capacity: usize, + cover_cache: Option>, + audio_cache: Option>, + ) -> Self { + let playlist = FifoPlaylist::new( + "radio-paradise".to_string(), + "Radio Paradise".to_string(), + fifo_capacity, + DEFAULT_IMAGE, + ); + + Self { + inner: Arc::new(RadioParadiseSourceInner { + client, + playlist, + cache_base_url: cache_base_url.into(), + track_cache: RwLock::new(HashMap::new()), + cover_cache, + audio_cache, + }), + } + } + + /// Add a track from a Radio Paradise song and block + /// + /// This is the main way to populate the FIFO with tracks as they are + /// received from the Radio Paradise API. + pub async fn add_song(&self, block: Arc, song: &Song, song_index: usize) -> Result<()> { + let track_id = format!("rp-{}-{}", block.event, song_index); + + // Create track for playlist + let mut track = Track::new(track_id.clone(), song.title.clone(), block.url.clone()); + + if !song.artist.is_empty() { + track = track.with_artist(song.artist.clone()); + } + + if !song.album.is_empty() { + track = track.with_album(song.album.clone()); + } + + if song.duration > 0 { + track = track.with_duration((song.duration / 1000) as u32); + } + + // Cache cover image and add to track + #[cfg(feature = "cache")] + let cached_cover_pk = if let Some(ref cover_cache) = self.inner.cover_cache { + if let Some(ref image_base) = block.image_base { + if let Some(ref cover) = song.cover { + let image_url = format!("{}{}", image_base, cover); + + // Cache the cover image asynchronously + match cover_cache.add_from_url(&image_url).await { + Ok(pk) => { + // Use the cached cover URL + let cached_url = format!("{}/covers/images/{}", self.inner.cache_base_url, pk); + track = track.with_image(cached_url); + Some(pk) + } + Err(e) => { + tracing::warn!("Failed to cache cover image {}: {}", image_url, e); + // Fall back to original URL + track = track.with_image(image_url); + None + } + } + } else { + None + } + } else { + None + } + } else { + None + }; + + // If no cache, add original cover image + #[cfg(not(feature = "cache"))] + if let Some(ref image_base) = block.image_base { + if let Some(ref cover) = song.cover { + let image_url = format!("{}{}", image_base, cover); + track = track.with_image(image_url); + } + } + + // Cache audio asynchronously (in background) + #[cfg(feature = "cache")] + let cached_audio_pk = if let Some(ref audio_cache) = self.inner.audio_cache { + // Prepare metadata for the audio cache + let metadata = AudioMetadata { + title: Some(song.title.clone()), + artist: if !song.artist.is_empty() { + Some(song.artist.clone()) + } else { + None + }, + album: if !song.album.is_empty() { + Some(song.album.clone()) + } else { + None + }, + duration_secs: if song.duration > 0 { + Some((song.duration / 1000) as u64) + } else { + None + }, + year: None, + track_number: None, + track_total: None, + disc_number: None, + disc_total: None, + genre: None, + sample_rate: None, + channels: None, + bitrate: None, + }; + + // Cache the audio asynchronously + match audio_cache.add_from_url(&block.url, Some(metadata)).await { + Ok((pk, _)) => { + tracing::info!("Successfully cached audio for track {}: {}", track_id, pk); + Some(pk) + } + Err(e) => { + tracing::warn!("Failed to cache audio for track {}: {}", track_id, e); + None + } + } + } else { + None + }; + + // Store metadata + { + let mut cache = self.inner.track_cache.write().await; + cache.insert( + track_id.clone(), + TrackMetadata { + original_uri: block.url.clone(), + cached_pk: None, + block: block.clone(), + song_index, + #[cfg(feature = "cache")] + cached_audio_pk, + #[cfg(feature = "cache")] + cached_cover_pk, + }, + ); + } + + // Add to FIFO + self.inner.playlist.append_track(track).await; + + Ok(()) + } + + /// Mark a track as cached + /// + /// Call this after successfully caching a track's audio via pmoaudiocache. + pub async fn cache_track(&self, track_id: &str, cache_pk: String) -> Result<()> { + let mut cache = self.inner.track_cache.write().await; + + if let Some(metadata) = cache.get_mut(track_id) { + metadata.cached_pk = Some(cache_pk); + Ok(()) + } else { + Err(MusicSourceError::ObjectNotFound(track_id.to_string())) + } + } + + /// Convert a pmoplaylist::Track to pmodidl::Item + fn track_to_item(&self, track: &Track) -> Item { + let duration_str = track.duration.map(|d| { + let hours = d / 3600; + let minutes = (d % 3600) / 60; + let seconds = d % 60; + format!("{}:{:02}:{:02}", hours, minutes, seconds) + }); + + let resource = Resource { + protocol_info: "http-get:*:audio/flac:*".to_string(), + bits_per_sample: None, + sample_frequency: None, + nr_audio_channels: None, + duration: duration_str, + url: track.uri.clone(), + }; + + Item { + id: track.id.clone(), + parent_id: "radio-paradise".to_string(), + restricted: Some("1".to_string()), + title: track.title.clone(), + creator: track.artist.clone(), + class: "object.item.audioItem.musicTrack".to_string(), + artist: track.artist.clone(), + album: track.album.clone(), + genre: None, + album_art: track.image.clone(), + album_art_pk: None, + date: None, + original_track_number: None, + resources: vec![resource], + descriptions: vec![], + } + } + + /// Get the Radio Paradise client + pub fn client(&self) -> &RadioParadiseClient { + &self.inner.client + } +} + +#[async_trait] impl MusicSource for RadioParadiseSource { fn name(&self) -> &str { "Radio Paradise" @@ -42,29 +381,166 @@ impl MusicSource for RadioParadiseSource { fn default_image(&self) -> &[u8] { DEFAULT_IMAGE } + + async fn root_container(&self) -> Result { + Ok(self.inner.playlist.as_container().await) + } + + async fn browse(&self, object_id: &str) -> Result { + // For Radio Paradise, browsing returns all tracks in the FIFO + if object_id == "radio-paradise" || object_id == "0" { + let tracks = self.inner.playlist.get_items(0, 1000).await; + let items: Vec = tracks.iter().map(|t| self.track_to_item(t)).collect(); + Ok(BrowseResult::Items(items)) + } else { + Err(MusicSourceError::ObjectNotFound(object_id.to_string())) + } + } + + async fn resolve_uri(&self, object_id: &str) -> Result { + let cache = self.inner.track_cache.read().await; + + if let Some(metadata) = cache.get(object_id) { + // Priority 1: Use cached audio if available + #[cfg(feature = "cache")] + if let Some(ref pk) = metadata.cached_audio_pk { + return Ok(format!("{}/audio/tracks/{}/stream", self.inner.cache_base_url, pk)); + } + + // Priority 2: Use legacy cached_pk (for backward compatibility) + if let Some(ref pk) = metadata.cached_pk { + return Ok(format!("{}/audio/cache/{}", self.inner.cache_base_url, pk)); + } + + // Priority 3: Return original block URI (not cached yet) + Ok(metadata.original_uri.clone()) + } else { + Err(MusicSourceError::ObjectNotFound(object_id.to_string())) + } + } + + fn supports_fifo(&self) -> bool { + true + } + + async fn append_track(&self, track: Item) -> Result<()> { + // Convert Item back to Track + let duration = track + .resources + .first() + .and_then(|r| r.duration.as_ref()) + .and_then(|d| { + let parts: Vec<&str> = d.split(':').collect(); + if parts.len() == 3 { + let h: u32 = parts[0].parse().ok()?; + let m: u32 = parts[1].parse().ok()?; + let s: u32 = parts[2].parse().ok()?; + Some(h * 3600 + m * 60 + s) + } else { + None + } + }); + + let uri = track + .resources + .first() + .map(|r| r.url.clone()) + .unwrap_or_default(); + + let mut pmo_track = Track::new(track.id.clone(), track.title.clone(), uri); + + if let Some(artist) = track.artist { + pmo_track = pmo_track.with_artist(artist); + } + + if let Some(album) = track.album { + pmo_track = pmo_track.with_album(album); + } + + if let Some(dur) = duration { + pmo_track = pmo_track.with_duration(dur); + } + + if let Some(img) = track.album_art { + pmo_track = pmo_track.with_image(img); + } + + self.inner.playlist.append_track(pmo_track).await; + Ok(()) + } + + async fn remove_oldest(&self) -> Result> { + if let Some(track) = self.inner.playlist.remove_oldest().await { + // Remove from cache + { + let mut cache = self.inner.track_cache.write().await; + cache.remove(&track.id); + } + + Ok(Some(self.track_to_item(&track))) + } else { + Ok(None) + } + } + + async fn update_id(&self) -> u32 { + self.inner.playlist.update_id().await + } + + async fn last_change(&self) -> Option { + Some(self.inner.playlist.last_change().await) + } + + async fn get_items(&self, offset: usize, count: usize) -> Result> { + let tracks = self.inner.playlist.get_items(offset, count).await; + Ok(tracks.iter().map(|t| self.track_to_item(t)).collect()) + } + + async fn search(&self, _query: &str) -> Result { + // Radio Paradise doesn't support search + Err(MusicSourceError::SearchNotSupported) + } } #[cfg(test)] mod tests { use super::*; - #[test] - fn test_source_info() { - let source = RadioParadiseSource; + #[tokio::test] + async fn test_source_info() { + let client = RadioParadiseClient::with_client(reqwest::Client::new()); + let source = RadioParadiseSource::new_default(client, "http://localhost:8080"); + assert_eq!(source.name(), "Radio Paradise"); assert_eq!(source.id(), "radio-paradise"); assert_eq!(source.default_image_mime_type(), "image/webp"); + assert!(source.supports_fifo()); } #[test] fn test_default_image_present() { - let source = RadioParadiseSource; - let image = source.default_image(); - assert!(image.len() > 0, "Default image should not be empty"); + assert!(DEFAULT_IMAGE.len() > 0, "Default image should not be empty"); // Check WebP magic bytes (RIFF...WEBP) - assert!(image.len() >= 12, "Image too small to be valid WebP"); - assert_eq!(&image[0..4], b"RIFF", "Missing RIFF header"); - assert_eq!(&image[8..12], b"WEBP", "Missing WEBP signature"); + assert!(DEFAULT_IMAGE.len() >= 12, "Image too small to be valid WebP"); + assert_eq!(&DEFAULT_IMAGE[0..4], b"RIFF", "Missing RIFF header"); + assert_eq!(&DEFAULT_IMAGE[8..12], b"WEBP", "Missing WEBP signature"); + } + + #[tokio::test] + async fn test_fifo_operations() { + let client = RadioParadiseClient::with_client(reqwest::Client::new()); + let source = RadioParadiseSource::new_default(client, "http://localhost:8080"); + + // Initially empty + let items = source.get_items(0, 10).await.unwrap(); + assert_eq!(items.len(), 0); + + // Test FIFO support + assert!(source.supports_fifo()); + + // Initial update_id + let update_id = source.update_id().await; + assert_eq!(update_id, 0); } } diff --git a/pmoplaylist/ARCHITECTURE.md b/pmoplaylist/ARCHITECTURE.md new file mode 100644 index 00000000..6c238a36 --- /dev/null +++ b/pmoplaylist/ARCHITECTURE.md @@ -0,0 +1,519 @@ +# Architecture de pmoplaylist + +## Vue d'ensemble + +`pmoplaylist` est une bibliothèque Rust qui fournit une abstraction de playlist FIFO (First-In-First-Out) thread-safe pour des MediaServers UPnP/OpenHome. Elle gère la logique de playlist pure sans aucune dépendance réseau ou protocole UPnP. + +## Design Patterns + +### 1. Arc + RwLock Pattern (Thread Safety) + +```rust +pub struct FifoPlaylist { + inner: Arc>, +} +``` + +**Raison** : Permet le clonage léger de `FifoPlaylist` et le partage entre threads/tasks tout en garantissant un accès concurrent sécurisé. + +**Avantages** : +- Clone peu coûteux (clone uniquement le `Arc`, pas les données) +- Accès concurrent : plusieurs lecteurs simultanés, un seul écrivain +- Compatible avec tokio et les runtimes asynchrones + +**Exemple d'utilisation** : +```rust +let playlist = FifoPlaylist::new(...); +let p1 = playlist.clone(); // Pour un thread +let p2 = playlist.clone(); // Pour un autre thread +``` + +### 2. Builder Pattern pour Track + +```rust +Track::new("id", "title", "uri") + .with_artist("Artist") + .with_album("Album") + .with_duration(300) + .with_image("url"); +``` + +**Raison** : Facilite la création de tracks avec métadonnées optionnelles de manière fluide et lisible. + +### 3. FIFO avec VecDeque + +```rust +struct FifoPlaylistInner { + queue: VecDeque, + capacity: usize, + // ... +} +``` + +**Raison** : `VecDeque` offre des opérations O(1) pour `push_back` et `pop_front`, parfait pour une FIFO. + +**Gestion de la capacité** : +- Lors de `append_track()`, si `len >= capacity`, on appelle `pop_front()` automatiquement +- Garantit que la playlist ne dépasse jamais la capacité configurée + +## Structures de données + +### Track + +```rust +pub struct Track { + pub id: String, // Identifiant unique + pub title: String, // Titre du morceau + pub artist: Option, // Artiste + pub album: Option, // Album + pub duration: Option, // Durée en secondes + pub uri: String, // URI du fichier/flux + pub image: Option, // URL de la cover +} +``` + +**Sérialisation** : Implémente `Serialize` et `Deserialize` pour faciliter l'export JSON/autre. + +### FifoPlaylistInner + +```rust +struct FifoPlaylistInner { + id: String, // ID unique de la playlist + title: String, // Titre de la playlist + default_image: &'static [u8], // Image par défaut embarquée + capacity: usize, // Capacité max de la FIFO + queue: VecDeque, // Queue des tracks + update_id: u32, // Compteur de modifications + last_change: SystemTime, // Timestamp dernière modif +} +``` + +**update_id** : +- Incrémenté à chaque modification (append, remove, clear) +- Permet aux clients UPnP de détecter les changements +- Utilise `wrapping_add()` pour éviter les débordements + +## Intégration DIDL-Lite + +### Génération de Container + +```rust +pub async fn as_container(&self) -> Container +``` + +**Produit** : +```xml + + My Playlist + object.container.playlistContainer + +``` + +**Utilisation** : Pour exposer la playlist comme container dans le ContentDirectory UPnP. + +### Génération d'Items + +```rust +pub async fn as_objects( + offset: usize, + count: usize, + default_image_url: Option<&str> +) -> Vec +``` + +**Produit** : Un vecteur d'objets `pmodidl::Item` représentant les tracks. + +**Mapping Track → DIDL Item** : +- `track.id` → `item.id` +- `track.title` → `item.title` +- `track.artist` → `item.artist` et `item.creator` +- `track.album` → `item.album` +- `track.uri` → `resource.url` +- `track.duration` (secondes) → `resource.duration` (format "H:MM:SS") +- `track.image` ou `default_image_url` → `item.album_art` + +**Classe UPnP** : Tous les items ont la classe `object.item.audioItem.musicTrack`. + +## Gestion de l'image par défaut + +### Intégration avec `include_bytes!` + +```rust +pub const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); +``` + +**Avantages** : +- L'image est compilée directement dans le binaire +- Pas de dépendance au système de fichiers à l'exécution +- Accès instantané et thread-safe + +### Format WebP + +**Raison du choix** : +- Format moderne et efficace +- Meilleure compression que JPEG/PNG +- Support alpha (transparence) +- Largement supporté par les navigateurs et clients modernes + +**Spécifications** : +- Dimension : 300x300 pixels +- Format : WebP +- Qualité : 85 +- Taille : ~9-10 KB + +### Utilisation + +```rust +let image_bytes = playlist.default_image().await; +// Servir via HTTP avec Content-Type: image/webp +``` + +## Concurrence et Thread Safety + +### Scenario 1 : Lecture concurrente + +```rust +// Thread 1 +let len = playlist.len().await; + +// Thread 2 (simultané) +let items = playlist.get_items(0, 10).await; +``` + +**Comportement** : Les deux opérations peuvent s'exécuter simultanément car `RwLock` permet plusieurs lecteurs. + +### Scenario 2 : Écriture exclusive + +```rust +// Thread 1 +playlist.append_track(track1).await; + +// Thread 2 (simultané) +playlist.append_track(track2).await; +``` + +**Comportement** : Les opérations sont sérialisées. Un seul thread écrit à la fois. + +### Scenario 3 : Lecture pendant écriture + +```rust +// Thread 1 : Écriture +playlist.append_track(track).await; + +// Thread 2 : Lecture (simultané) +let len = playlist.len().await; +``` + +**Comportement** : La lecture attend que l'écriture se termine. + +## Gestion de l'Update ID + +### Algorithme + +```rust +// À chaque modification +inner.update_id = inner.update_id.wrapping_add(1); +inner.last_change = SystemTime::now(); +``` + +**Opérations qui incrémentent l'update_id** : +- `append_track()` → +1 +- `remove_oldest()` → +1 (si un track est supprimé) +- `remove_by_id()` → +1 (si un track est trouvé et supprimé) +- `clear()` → +1 (si la playlist n'était pas vide) + +**Opérations qui ne l'incrémentent PAS** : +- `get_items()` (lecture seule) +- `len()`, `is_empty()` (lecture seule) +- `as_container()`, `as_objects()` (lecture seule) + +### Utilisation dans UPnP + +Les clients UPnP peuvent : +1. Interroger l'`update_id` initial +2. Mémoriser cette valeur +3. Ré-interroger périodiquement +4. Si `update_id` a changé → rafraîchir l'affichage + +## Cas d'usage + +### 1. Radio en streaming + +**Caractéristiques** : +- Capacité limitée (ex: 20 tracks) +- Ajouts fréquents de nouveaux tracks +- Les anciens tracks sont automatiquement supprimés + +**Configuration recommandée** : +```rust +let radio = FifoPlaylist::new( + "radio-paradise", + "Radio Paradise", + 20, // Historique limité à 20 tracks + DEFAULT_IMAGE, +); +``` + +### 2. Album statique + +**Caractéristiques** : +- Capacité large (ex: 100 tracks) +- Tous les tracks ajoutés une seule fois +- Pas de rotation automatique + +**Configuration recommandée** : +```rust +let album = FifoPlaylist::new( + "album-dsotm", + "The Dark Side of the Moon", + 100, // Capacité large pour tout l'album + DEFAULT_IMAGE, +); +``` + +### 3. Playlist locale modifiable + +**Caractéristiques** : +- Capacité moyenne (ex: 50 tracks) +- Ajouts et suppressions manuels +- Utilisation de `remove_by_id()` pour contrôle précis + +**Configuration recommandée** : +```rust +let playlist = FifoPlaylist::new( + "my-playlist", + "My Favorites", + 50, + DEFAULT_IMAGE, +); +``` + +## Intégration avec un MediaServer + +### Architecture typique + +``` +┌─────────────────┐ +│ UPnP Client │ +│ (Control Point)│ +└────────┬────────┘ + │ HTTP/SOAP + ▼ +┌─────────────────────┐ +│ MediaServer UPnP │ +│ ┌───────────────┐ │ +│ │ ContentDirectory│ │ +│ │ Service │ │ +│ └───────┬───────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────┐ │ +│ │ pmoplaylist │ │ ← Cette crate +│ │ (FIFO) │ │ +│ └───────────────┘ │ +└─────────────────────┘ +``` + +### Exemple d'endpoints + +```rust +// GET /ContentDirectory/Browse?ObjectID=playlist-id +async fn browse_container(playlist: Arc) -> Response { + let container = playlist.as_container().await; + // Convertir en XML DIDL-Lite et retourner +} + +// GET /ContentDirectory/Browse?ObjectID=playlist-id&StartingIndex=0&RequestedCount=10 +async fn browse_items( + playlist: Arc, + offset: usize, + count: usize +) -> Response { + let items = playlist.as_objects(offset, count, Some(DEFAULT_IMAGE_URL)).await; + // Convertir en XML DIDL-Lite et retourner +} + +// GET /SystemUpdateID +async fn get_update_id(playlist: Arc) -> Response { + let update_id = playlist.update_id().await; + // Retourner l'update_id +} +``` + +## Tests + +### Couverture + +La crate inclut 11 tests unitaires + 8 doctests couvrant : + +1. **Création et état initial** + - `test_create_playlist` + +2. **Ajout de tracks** + - `test_append_track` + - `test_fifo_capacity` + +3. **Suppression de tracks** + - `test_remove_oldest` + - `test_remove_by_id` + - `test_clear` + +4. **Navigation** + - `test_get_items_pagination` + +5. **Génération DIDL-Lite** + - `test_as_container` + - `test_as_objects` + +6. **Builder pattern** + - `test_track_builder` + +7. **Update ID** + - `test_update_id_increments` + +### Exécution + +```bash +# Tests unitaires +cargo test -p pmoplaylist + +# Tests avec doctests +cargo test -p pmoplaylist --doc + +# Tous les tests +cargo test -p pmoplaylist --all-targets +``` + +## Exemples fournis + +### 1. basic_usage.rs + +Démontre : +- Création d'une playlist +- Ajout et suppression de tracks +- Comportement FIFO +- Génération DIDL-Lite +- Gestion de l'update_id + +```bash +cargo run -p pmoplaylist --example basic_usage +``` + +### 2. radio_streaming.rs + +Démontre : +- Utilisation multi-thread +- Simulation d'un flux radio continu +- Surveillance des changements via update_id +- Consultation de l'historique + +```bash +cargo run -p pmoplaylist --example radio_streaming +``` + +### 3. http_server_integration.rs + +Démontre : +- Intégration avec un serveur HTTP +- Endpoints REST simulés +- Partage de playlist avec `Arc` +- Serving de l'image par défaut + +```bash +cargo run -p pmoplaylist --example http_server_integration +``` + +## Dépendances + +### Runtime + +- **pmodidl** (path = "../pmodidl") + - Structures DIDL-Lite (Container, Item, Resource) + - Nécessaire pour la génération d'objets UPnP + +- **tokio** (1.42.0, features: sync, time, macros, rt, rt-multi-thread) + - RwLock asynchrone pour thread safety + - Runtime asynchrone pour les méthodes async + +- **serde** (1.0.228, features: derive) + - Sérialisation/désérialisation de Track + - Support JSON/autres formats si nécessaire + +### Build-time + +- **include_bytes!** (macro std) + - Intégration de l'image par défaut dans le binaire + +## Performance + +### Complexité algorithmique + +- `append_track()` : O(1) amorti (VecDeque::push_back + potentiel pop_front) +- `remove_oldest()` : O(1) (VecDeque::pop_front) +- `remove_by_id()` : O(n) (recherche linéaire + VecDeque::remove) +- `get_items()` : O(k) où k = count (iteration + clone) +- `clear()` : O(n) (libération de tous les tracks) + +### Allocation mémoire + +- Chaque `Track` : ~100-200 bytes (selon la taille des strings) +- VecDeque overhead : ~24 bytes + capacity +- RwLock overhead : ~40 bytes +- Arc overhead : ~16 bytes + +**Exemple** : Une playlist de 20 tracks ≈ 2-4 KB + +### Lock contention + +**Read-heavy workload** : Excellent (RwLock permet plusieurs lecteurs) + +**Write-heavy workload** : Acceptable (les écritures sont généralement peu fréquentes pour une playlist) + +**Recommandation** : Pour des milliers d'écritures/seconde, envisager un design lock-free ou sharding. + +## Extensions futures possibles + +### 1. Persistence + +```rust +impl FifoPlaylist { + pub async fn save_to_disk(&self, path: &Path) -> io::Result<()>; + pub async fn load_from_disk(path: &Path) -> io::Result; +} +``` + +### 2. Événements et callbacks + +```rust +pub enum PlaylistEvent { + TrackAdded(Track), + TrackRemoved(String), + Cleared, +} + +impl FifoPlaylist { + pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver; +} +``` + +### 3. Indexation et recherche + +```rust +impl FifoPlaylist { + pub async fn find_by_artist(&self, artist: &str) -> Vec; + pub async fn find_by_title(&self, title: &str) -> Vec; +} +``` + +### 4. Statistiques + +```rust +impl FifoPlaylist { + pub async fn total_duration(&self) -> u32; + pub async fn most_common_artist(&self) -> Option; +} +``` + +## Licence + +Ce projet fait partie du workspace PMOMusic. diff --git a/pmoplaylist/CHANGELOG.md b/pmoplaylist/CHANGELOG.md new file mode 100644 index 00000000..13b1b80b --- /dev/null +++ b/pmoplaylist/CHANGELOG.md @@ -0,0 +1,100 @@ +# Changelog + +Toutes les modifications notables de ce projet seront documentées dans ce fichier. + +Le format est basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/), +et ce projet adhère au [Semantic Versioning](https://semver.org/lang/fr/). + +## [Non publié] + +## [0.1.0] - 2025-10-16 + +### Ajouté + +#### Structures de base +- Struct `Track` pour représenter un track audio avec : + - Identifiant unique + - Métadonnées (titre, artiste, album, durée) + - URI du fichier/flux + - URL optionnelle pour l'image/cover +- Struct `FifoPlaylist` pour gérer une playlist FIFO avec : + - Capacité configurable + - Gestion automatique de la rotation (suppression des anciens tracks) + - Thread-safety via `Arc` + - Support asynchrone avec tokio + +#### Fonctionnalités principales +- **Gestion FIFO** : + - `append_track()` : Ajoute un track (supprime le plus ancien si capacité atteinte) + - `remove_oldest()` : Supprime le track le plus ancien + - `remove_by_id()` : Supprime un track par son ID + - `clear()` : Vide complètement la playlist + - `get_items()` : Navigation partielle avec offset/count + +- **Détection de changements** : + - `update_id()` : Compteur incrémenté à chaque modification + - `last_change()` : Timestamp de la dernière modification + - Compatibilité avec le protocole UPnP ContentDirectory + +- **Génération DIDL-Lite** : + - `as_container()` : Génère un Container DIDL-Lite pour ContentDirectory + - `as_container_with_parent()` : Génère un Container avec parent_id personnalisé + - `as_objects()` : Génère des Items DIDL-Lite avec pagination + - Mapping complet Track → DIDL Item (métadonnées, ressources, images) + +- **Image par défaut** : + - Image WebP 300x300 intégrée au binaire + - Note de musique néon sur fond de briques + - Taille optimisée (~10 KB) + - Accès via `default_image()` + +#### API ergonomique +- Builder pattern pour `Track` : + - `with_artist()`, `with_album()`, `with_duration()`, `with_image()` +- Méthodes utilitaires : + - `len()`, `is_empty()`, `id()`, `title()` +- Toutes les méthodes sont asynchrones et thread-safe + +#### Documentation +- Documentation complète avec rustdoc +- README.md avec : + - Guide d'installation + - Exemples d'utilisation + - API complète + - Cas d'usage (radio, album, playlist) +- ARCHITECTURE.md avec : + - Détails d'implémentation + - Design patterns utilisés + - Guide d'intégration + - Performance et complexité algorithmique + +#### Exemples +- `basic_usage.rs` : Utilisation basique de toutes les fonctionnalités +- `radio_streaming.rs` : Simulation d'une radio en streaming multi-thread +- `http_server_integration.rs` : Intégration avec un serveur HTTP + +#### Tests +- 11 tests unitaires couvrant : + - Création et état initial + - Ajout de tracks + - Suppression de tracks (oldest, by_id, clear) + - Navigation et pagination + - Génération DIDL-Lite + - Builder pattern + - Gestion de l'update_id +- 8 doctests intégrés dans la documentation +- 100% de réussite des tests + +### Dépendances +- `pmodidl` (local) : Structures DIDL-Lite pour UPnP +- `tokio` 1.42.0 : Runtime asynchrone et RwLock +- `serde` 1.0.228 : Sérialisation de Track + +### Notes techniques +- Edition Rust : 2024 +- MSRV (Minimum Supported Rust Version) : Non spécifié (version stable recommandée) +- Thread-safe : Oui (Arc + RwLock) +- Async-first : Toutes les méthodes publiques sont async + +[Non publié]: https://github.com/user/repo/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/user/repo/releases/tag/v0.1.0 diff --git a/pmoplaylist/Cargo.toml b/pmoplaylist/Cargo.toml new file mode 100644 index 00000000..952de651 --- /dev/null +++ b/pmoplaylist/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "pmoplaylist" +version = "0.1.0" +edition = "2024" + +[dependencies] +pmodidl = { path = "../pmodidl" } +tokio = { version = "1.42.0", features = ["sync", "time", "macros", "rt", "rt-multi-thread"] } +serde = { version = "1.0.228", features = ["derive"] } diff --git a/pmoplaylist/README.md b/pmoplaylist/README.md new file mode 100644 index 00000000..825ebc93 --- /dev/null +++ b/pmoplaylist/README.md @@ -0,0 +1,507 @@ +# pmoplaylist + +FIFO Audio Universelle pour MediaServer UPnP/OpenHome en Rust. + +## Description + +`pmoplaylist` fournit une abstraction de playlist/container audio avec : + +- ✅ Gestion de FIFO audio avec capacité configurable +- ✅ Exposition d'objets DIDL-Lite via `pmodidl` +- ✅ Support `update_id` et `last_change` pour signaler les modifications +- ✅ Image par défaut intégrée pour le container racine (WebP) +- ✅ Thread-safe avec `tokio` et `Arc` +- ✅ API asynchrone compatible avec les MediaServers UPnP + +## Installation + +Ajoutez cette crate à votre `Cargo.toml` : + +```toml +[dependencies] +pmoplaylist = { path = "../pmoplaylist" } +tokio = { version = "1.42.0", features = ["full"] } +``` + +## Utilisation de base + +### Créer une playlist FIFO + +```rust +use pmoplaylist::{FifoPlaylist, Track}; + +#[tokio::main] +async fn main() { + // Créer une FIFO avec capacité de 10 tracks + let playlist = FifoPlaylist::new( + "radio-1".to_string(), + "Ma Radio Préférée".to_string(), + 10, + pmoplaylist::DEFAULT_IMAGE, + ); + + // Vérifier l'état initial + assert_eq!(playlist.len().await, 0); + assert!(playlist.is_empty().await); +} +``` + +### Ajouter des tracks + +```rust +use pmoplaylist::{FifoPlaylist, Track}; + +#[tokio::main] +async fn main() { + let playlist = FifoPlaylist::new( + "my-playlist".to_string(), + "My Playlist".to_string(), + 50, + pmoplaylist::DEFAULT_IMAGE, + ); + + // Méthode simple + let track1 = Track::new( + "track-1", + "Bohemian Rhapsody", + "http://example.com/queen/bohemian.flac" + ); + playlist.append_track(track1).await; + + // Avec builder pattern pour métadonnées complètes + let track2 = Track::new("track-2", "Stairway to Heaven", "http://example.com/zeppelin/stairway.mp3") + .with_artist("Led Zeppelin") + .with_album("Led Zeppelin IV") + .with_duration(482) + .with_image("http://example.com/covers/lz4.jpg"); + + playlist.append_track(track2).await; + + println!("Nombre de tracks: {}", playlist.len().await); +} +``` + +### Gestion FIFO automatique + +La FIFO supprime automatiquement les tracks les plus anciens quand la capacité est atteinte : + +```rust +use pmoplaylist::{FifoPlaylist, Track}; + +#[tokio::main] +async fn main() { + // Créer une FIFO avec capacité de 3 tracks seulement + let playlist = FifoPlaylist::new( + "small-fifo".to_string(), + "Petite FIFO".to_string(), + 3, + pmoplaylist::DEFAULT_IMAGE, + ); + + // Ajouter 5 tracks + for i in 0..5 { + playlist.append_track(Track::new( + format!("track-{}", i), + format!("Song {}", i), + format!("http://example.com/{}.mp3", i) + )).await; + } + + // Seuls les 3 derniers restent (tracks 2, 3, 4) + assert_eq!(playlist.len().await, 3); + + let items = playlist.get_items(0, 10).await; + assert_eq!(items[0].id, "track-2"); + assert_eq!(items[2].id, "track-4"); +} +``` + +### Navigation et pagination + +```rust +use pmoplaylist::{FifoPlaylist, Track}; + +#[tokio::main] +async fn main() { + let playlist = FifoPlaylist::new( + "big-playlist".to_string(), + "Grande Playlist".to_string(), + 100, + pmoplaylist::DEFAULT_IMAGE, + ); + + // Ajouter 50 tracks + for i in 0..50 { + playlist.append_track(Track::new( + format!("track-{}", i), + format!("Song {}", i), + format!("http://example.com/{}.mp3", i) + )).await; + } + + // Récupérer les tracks 10 à 19 (navigation paginée) + let page = playlist.get_items(10, 10).await; + assert_eq!(page.len(), 10); + assert_eq!(page[0].id, "track-10"); +} +``` + +### Supprimer des tracks + +```rust +use pmoplaylist::{FifoPlaylist, Track}; + +#[tokio::main] +async fn main() { + let playlist = FifoPlaylist::new( + "playlist-1".to_string(), + "My Playlist".to_string(), + 10, + pmoplaylist::DEFAULT_IMAGE, + ); + + playlist.append_track(Track::new("track-1", "Song 1", "http://example.com/1.mp3")).await; + playlist.append_track(Track::new("track-2", "Song 2", "http://example.com/2.mp3")).await; + + // Supprimer le plus ancien (FIFO) + let removed = playlist.remove_oldest().await; + assert_eq!(removed.unwrap().id, "track-1"); + + // Supprimer par ID + playlist.remove_by_id("track-2").await; + + // Vider complètement + playlist.clear().await; + assert!(playlist.is_empty().await); +} +``` + +### Détection de changements (update_id) + +L'`update_id` est incrémenté à chaque modification de la playlist : + +```rust +use pmoplaylist::{FifoPlaylist, Track}; + +#[tokio::main] +async fn main() { + let playlist = FifoPlaylist::new( + "watched-playlist".to_string(), + "Watched Playlist".to_string(), + 10, + pmoplaylist::DEFAULT_IMAGE, + ); + + let initial_id = playlist.update_id().await; + assert_eq!(initial_id, 0); + + // Chaque opération incrémente l'update_id + playlist.append_track(Track::new("track-1", "Song", "http://example.com/1.mp3")).await; + assert_eq!(playlist.update_id().await, 1); + + playlist.append_track(Track::new("track-2", "Song", "http://example.com/2.mp3")).await; + assert_eq!(playlist.update_id().await, 2); + + playlist.remove_oldest().await; + assert_eq!(playlist.update_id().await, 3); + + // Timestamp de dernière modification + let last_change = playlist.last_change().await; + println!("Dernière modification: {:?}", last_change); +} +``` + +## Intégration UPnP/DIDL-Lite + +### Générer un Container DIDL-Lite + +```rust +use pmoplaylist::FifoPlaylist; + +#[tokio::main] +async fn main() { + let playlist = FifoPlaylist::new( + "radio-paradise".to_string(), + "Radio Paradise".to_string(), + 20, + pmoplaylist::DEFAULT_IMAGE, + ); + + // Générer le container pour ContentDirectory + let container = playlist.as_container().await; + + println!("Container ID: {}", container.id); + println!("Title: {}", container.title); + println!("Child count: {:?}", container.child_count); + println!("Class: {}", container.class); // "object.container.playlistContainer" +} +``` + +### Générer des Items DIDL-Lite + +```rust +use pmoplaylist::{FifoPlaylist, Track}; + +#[tokio::main] +async fn main() { + let playlist = FifoPlaylist::new( + "radio-1".to_string(), + "Ma Radio".to_string(), + 10, + pmoplaylist::DEFAULT_IMAGE, + ); + + // Ajouter des tracks + let track = Track::new("track-1", "Bohemian Rhapsody", "http://example.com/song.mp3") + .with_artist("Queen") + .with_album("A Night at the Opera") + .with_duration(354); + + playlist.append_track(track).await; + + // Générer les items DIDL-Lite avec URL de l'image par défaut + let items = playlist.as_objects( + 0, // offset + 10, // count + Some("http://myserver/default.webp") // URL pour l'image par défaut + ).await; + + for item in items { + println!("Item: {}", item.title); + println!(" Artist: {:?}", item.artist); + println!(" Album: {:?}", item.album); + println!(" URI: {}", item.resources[0].url); + println!(" Class: {}", item.class); // "object.item.audioItem.musicTrack" + } +} +``` + +### Servir l'image par défaut + +```rust +use pmoplaylist::FifoPlaylist; + +#[tokio::main] +async fn main() { + let playlist = FifoPlaylist::new( + "radio-1".to_string(), + "Ma Radio".to_string(), + 10, + pmoplaylist::DEFAULT_IMAGE, + ); + + // Récupérer les bytes de l'image par défaut + let image_bytes = playlist.default_image().await; + + // Peut être servi via un endpoint HTTP, par exemple avec Axum: + // Response::builder() + // .status(200) + // .header("Content-Type", "image/webp") + // .body(image_bytes.to_vec()) +} +``` + +## Cas d'usage + +### Radio dynamique en streaming + +```rust +use pmoplaylist::{FifoPlaylist, Track}; + +#[tokio::main] +async fn main() { + // Radio avec historique limité à 20 tracks + let radio = FifoPlaylist::new( + "radio-paradise".to_string(), + "Radio Paradise".to_string(), + 20, + pmoplaylist::DEFAULT_IMAGE, + ); + + // Simuler l'ajout de tracks au fur et à mesure du streaming + // Les anciens tracks sont automatiquement supprimés + for i in 0..100 { + let track = Track::new( + format!("track-{}", i), + format!("Now Playing: Song {}", i), + format!("http://stream.radio.com/track/{}", i) + ); + radio.append_track(track).await; + + // La radio conserve toujours les 20 derniers tracks + assert!(radio.len().await <= 20); + } +} +``` + +### Album statique + +```rust +use pmoplaylist::{FifoPlaylist, Track}; + +#[tokio::main] +async fn main() { + // Album avec tous les tracks + let album = FifoPlaylist::new( + "album-dsotm".to_string(), + "The Dark Side of the Moon".to_string(), + 100, // Capacité large pour un album complet + pmoplaylist::DEFAULT_IMAGE, + ); + + // Ajouter tous les tracks de l'album + let tracks = vec![ + ("1", "Speak to Me", 90), + ("2", "Breathe", 163), + ("3", "On the Run", 216), + ("4", "Time", 413), + ("5", "The Great Gig in the Sky", 283), + ("6", "Money", 382), + ("7", "Us and Them", 462), + ("8", "Any Colour You Like", 205), + ("9", "Brain Damage", 228), + ("10", "Eclipse", 123), + ]; + + for (track_num, title, duration) in tracks { + album.append_track( + Track::new( + format!("dsotm-{}", track_num), + title, + format!("http://library.local/floyd/dsotm/{}.flac", track_num) + ) + .with_artist("Pink Floyd") + .with_album("The Dark Side of the Moon") + .with_duration(duration) + ).await; + } +} +``` + +## Thread Safety + +`FifoPlaylist` est thread-safe et peut être cloné et partagé entre plusieurs threads/tasks : + +```rust +use pmoplaylist::{FifoPlaylist, Track}; +use tokio::task; + +#[tokio::main] +async fn main() { + let playlist = FifoPlaylist::new( + "shared-playlist".to_string(), + "Shared Playlist".to_string(), + 100, + pmoplaylist::DEFAULT_IMAGE, + ); + + // Cloner pour partager entre threads + let playlist_writer = playlist.clone(); + let playlist_reader = playlist.clone(); + + // Thread d'écriture + let writer = task::spawn(async move { + for i in 0..10 { + playlist_writer.append_track(Track::new( + format!("track-{}", i), + format!("Song {}", i), + format!("http://example.com/{}.mp3", i) + )).await; + } + }); + + // Thread de lecture + let reader = task::spawn(async move { + loop { + let len = playlist_reader.len().await; + if len >= 10 { + break; + } + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + } + println!("Playlist complète!"); + }); + + writer.await.unwrap(); + reader.await.unwrap(); +} +``` + +## API complète + +### `Track` + +- `Track::new(id, title, uri)` - Crée un nouveau track +- `.with_artist(artist)` - Définit l'artiste +- `.with_album(album)` - Définit l'album +- `.with_duration(seconds)` - Définit la durée en secondes +- `.with_image(url)` - Définit l'URL de l'image + +### `FifoPlaylist` + +#### Création +- `FifoPlaylist::new(id, title, capacity, default_image)` - Crée une nouvelle playlist + +#### Modification +- `.append_track(track)` - Ajoute un track (supprime le plus ancien si capacité atteinte) +- `.remove_oldest()` - Supprime le track le plus ancien +- `.remove_by_id(id)` - Supprime un track par son ID +- `.clear()` - Vide complètement la playlist + +#### Lecture +- `.len()` - Nombre de tracks +- `.is_empty()` - Vérifie si vide +- `.get_items(offset, count)` - Récupère une portion des tracks +- `.id()` - Retourne l'ID de la playlist +- `.title()` - Retourne le titre de la playlist + +#### Méta-données +- `.update_id()` - Retourne l'update_id actuel (incrémenté à chaque modification) +- `.last_change()` - Retourne le timestamp de dernière modification + +#### DIDL-Lite +- `.as_container()` - Génère un Container DIDL-Lite (parent_id = "0") +- `.as_container_with_parent(parent_id)` - Génère un Container avec parent_id personnalisé +- `.as_objects(offset, count, default_image_url)` - Génère des Items DIDL-Lite +- `.default_image()` - Retourne les bytes de l'image par défaut + +## Architecture + +``` +FifoPlaylist +├── Arc> +│ ├── id: String +│ ├── title: String +│ ├── default_image: &'static [u8] +│ ├── capacity: usize +│ ├── queue: VecDeque +│ ├── update_id: u32 +│ └── last_change: SystemTime +│ +Track +├── id: String +├── title: String +├── artist: Option +├── album: Option +├── duration: Option +├── uri: String +└── image: Option +``` + +## Dépendances + +- `pmodidl` - Génération DIDL-Lite +- `tokio` - Runtime asynchrone et synchronisation +- `serde` - Sérialisation + +## Tests + +```bash +cargo test -p pmoplaylist +``` + +Tous les tests (unitaires et doctests) sont inclus et validés. + +## Licence + +Ce projet fait partie du workspace PMOMusic. diff --git a/pmoplaylist/assets/default.webp b/pmoplaylist/assets/default.webp new file mode 100644 index 00000000..014210b1 Binary files /dev/null and b/pmoplaylist/assets/default.webp differ diff --git a/pmoplaylist/examples/basic_usage.rs b/pmoplaylist/examples/basic_usage.rs new file mode 100644 index 00000000..33c02b2c --- /dev/null +++ b/pmoplaylist/examples/basic_usage.rs @@ -0,0 +1,149 @@ +//! Exemple d'utilisation basique de pmoplaylist +//! +//! Pour exécuter cet exemple : +//! ```bash +//! cargo run -p pmoplaylist --example basic_usage +//! ``` + +use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE}; + +#[tokio::main] +async fn main() { + println!("=== Exemple pmoplaylist ===\n"); + + // 1. Créer une playlist FIFO + println!("1. Création d'une playlist avec capacité de 5 tracks..."); + let playlist = FifoPlaylist::new( + "my-radio".to_string(), + "Ma Radio Préférée".to_string(), + 5, + DEFAULT_IMAGE, + ); + println!(" ✓ Playlist créée: {}", playlist.title().await); + println!(" ✓ ID: {}", playlist.id().await); + println!(" ✓ Capacité: 5 tracks"); + println!(" ✓ Update ID initial: {}\n", playlist.update_id().await); + + // 2. Ajouter des tracks + println!("2. Ajout de 3 tracks..."); + let tracks = vec![ + Track::new("track-1", "Bohemian Rhapsody", "http://example.com/queen/bohemian.flac") + .with_artist("Queen") + .with_album("A Night at the Opera") + .with_duration(354) + .with_image("http://example.com/covers/queen-anato.jpg"), + + Track::new("track-2", "Stairway to Heaven", "http://example.com/zeppelin/stairway.mp3") + .with_artist("Led Zeppelin") + .with_album("Led Zeppelin IV") + .with_duration(482), + + Track::new("track-3", "Hotel California", "http://example.com/eagles/hotel.flac") + .with_artist("Eagles") + .with_album("Hotel California") + .with_duration(391), + ]; + + for track in tracks { + playlist.append_track(track.clone()).await; + println!(" ✓ Ajouté: {} - {}", track.title, track.artist.unwrap_or_default()); + } + + println!("\n Total tracks: {}", playlist.len().await); + println!(" Update ID: {}\n", playlist.update_id().await); + + // 3. Tester le comportement FIFO + println!("3. Test du comportement FIFO (capacité = 5)..."); + println!(" Ajout de 4 tracks supplémentaires..."); + + for i in 4..=7 { + let track = Track::new( + format!("track-{}", i), + format!("Song Number {}", i), + format!("http://example.com/songs/{}.mp3", i) + ); + playlist.append_track(track).await; + } + + println!(" ✓ Total tracks (limité par capacité): {}", playlist.len().await); + + // Afficher les tracks actuels + let items = playlist.get_items(0, 10).await; + println!("\n Tracks actuels dans la FIFO:"); + for (idx, track) in items.iter().enumerate() { + println!(" {}. {} ({})", idx + 1, track.title, track.id); + } + println!(" (Les tracks 1 et 2 ont été supprimés automatiquement)\n"); + + // 4. Supprimer le plus ancien + println!("4. Suppression du track le plus ancien..."); + if let Some(removed) = playlist.remove_oldest().await { + println!(" ✓ Supprimé: {} ({})", removed.title, removed.id); + } + println!(" Total tracks: {}", playlist.len().await); + println!(" Update ID: {}\n", playlist.update_id().await); + + // 5. Supprimer par ID + println!("5. Suppression d'un track par ID (track-5)..."); + if playlist.remove_by_id("track-5").await { + println!(" ✓ Track supprimé"); + } + println!(" Total tracks: {}", playlist.len().await); + println!(" Update ID: {}\n", playlist.update_id().await); + + // 6. Générer un Container DIDL-Lite + println!("6. Génération du Container DIDL-Lite..."); + let container = playlist.as_container().await; + println!(" Container:"); + println!(" - ID: {}", container.id); + println!(" - Parent ID: {}", container.parent_id); + println!(" - Title: {}", container.title); + println!(" - Class: {}", container.class); + println!(" - Child Count: {}\n", container.child_count.unwrap_or_default()); + + // 7. Générer des Items DIDL-Lite + println!("7. Génération des Items DIDL-Lite..."); + let didl_items = playlist.as_objects( + 0, + 10, + Some("http://myserver/api/default-image") + ).await; + + println!(" Items DIDL-Lite:"); + for (idx, item) in didl_items.iter().enumerate() { + println!("\n Item {}:", idx + 1); + println!(" - ID: {}", item.id); + println!(" - Title: {}", item.title); + println!(" - Artist: {}", item.artist.as_deref().unwrap_or("N/A")); + println!(" - Album: {}", item.album.as_deref().unwrap_or("N/A")); + println!(" - Class: {}", item.class); + println!(" - Parent ID: {}", item.parent_id); + + if !item.resources.is_empty() { + println!(" - Resource URI: {}", item.resources[0].url); + if let Some(ref duration) = item.resources[0].duration { + println!(" - Duration: {}", duration); + } + } + + if let Some(ref art) = item.album_art { + println!(" - Album Art: {}", art); + } + } + + // 8. Image par défaut + println!("\n8. Image par défaut..."); + let default_image = playlist.default_image().await; + println!(" ✓ Taille de l'image par défaut: {} bytes", default_image.len()); + println!(" (Cette image peut être servie via un endpoint HTTP)\n"); + + // 9. Vider la playlist + println!("9. Vidage de la playlist..."); + playlist.clear().await; + println!(" ✓ Playlist vidée"); + println!(" Total tracks: {}", playlist.len().await); + println!(" Is empty: {}", playlist.is_empty().await); + println!(" Update ID final: {}\n", playlist.update_id().await); + + println!("=== Exemple terminé ==="); +} diff --git a/pmoplaylist/examples/http_server_integration.rs b/pmoplaylist/examples/http_server_integration.rs new file mode 100644 index 00000000..6a33dd61 --- /dev/null +++ b/pmoplaylist/examples/http_server_integration.rs @@ -0,0 +1,207 @@ +//! Exemple d'intégration avec un serveur HTTP +//! +//! Cet exemple montre comment exposer une playlist FIFO via des endpoints HTTP simples. +//! Dans un vrai MediaServer UPnP, ces endpoints seraient appelés par le protocole ContentDirectory. +//! +//! Pour exécuter : +//! ```bash +//! cargo run -p pmoplaylist --example http_server_integration +//! ``` + +use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE}; +use std::sync::Arc; + +#[tokio::main] +async fn main() { + println!("=== Intégration HTTP Server ===\n"); + + // Créer une playlist partagée + let playlist = Arc::new(FifoPlaylist::new( + "my-radio".to_string(), + "My Internet Radio".to_string(), + 20, + DEFAULT_IMAGE, + )); + + println!("📻 Playlist créée: {}", playlist.title().await); + println!("🆔 ID: {}\n", playlist.id().await); + + // Ajouter quelques tracks initiaux + println!("📝 Ajout de tracks initiaux..."); + let initial_tracks = vec![ + ("The Beatles", "Come Together", "Abbey Road", 259), + ("Nirvana", "Smells Like Teen Spirit", "Nevermind", 301), + ("Queen", "Bohemian Rhapsody", "A Night at the Opera", 354), + ]; + + for (idx, (artist, title, album, duration)) in initial_tracks.iter().enumerate() { + playlist.append_track( + Track::new( + format!("track-{}", idx), + *title, + format!("http://media.server/music/{}.flac", idx) + ) + .with_artist(*artist) + .with_album(*album) + .with_duration(*duration) + .with_image(format!("http://media.server/covers/{}.jpg", idx)) + ).await; + println!(" ✓ {} - {}", artist, title); + } + println!(); + + // Simuler différents endpoints HTTP + + // 1. GET /playlist/container - Retourne le container DIDL-Lite + println!("🌐 Endpoint: GET /playlist/container"); + simulate_get_container(playlist.clone()).await; + println!(); + + // 2. GET /playlist/items?offset=0&count=10 - Retourne les items + println!("🌐 Endpoint: GET /playlist/items?offset=0&count=10"); + simulate_get_items(playlist.clone(), 0, 10).await; + println!(); + + // 3. GET /playlist/metadata - Retourne les métadonnées + println!("🌐 Endpoint: GET /playlist/metadata"); + simulate_get_metadata(playlist.clone()).await; + println!(); + + // 4. POST /playlist/track - Ajoute un nouveau track + println!("🌐 Endpoint: POST /playlist/track"); + let new_track = Track::new( + "track-new-1", + "Stairway to Heaven", + "http://media.server/music/stairway.flac" + ) + .with_artist("Led Zeppelin") + .with_album("Led Zeppelin IV") + .with_duration(482); + + simulate_add_track(playlist.clone(), new_track).await; + println!(); + + // 5. DELETE /playlist/oldest - Supprime le plus ancien + println!("🌐 Endpoint: DELETE /playlist/oldest"); + simulate_delete_oldest(playlist.clone()).await; + println!(); + + // 6. GET /playlist/default-image - Retourne l'image par défaut + println!("🌐 Endpoint: GET /playlist/default-image"); + simulate_get_default_image(playlist.clone()).await; + println!(); + + // 7. Vérifier l'état final + println!("📊 État final:"); + let final_items = playlist.get_items(0, 10).await; + println!(" Total tracks: {}", playlist.len().await); + println!(" Update ID: {}", playlist.update_id().await); + println!("\n Tracks actuels:"); + for (idx, track) in final_items.iter().enumerate() { + let artist = track.artist.as_deref().unwrap_or("Unknown"); + println!(" {}. {} - {}", idx + 1, artist, track.title); + } + + println!("\n=== Exemple terminé ==="); +} + +/// Simule GET /playlist/container +async fn simulate_get_container(playlist: Arc) { + let container = playlist.as_container().await; + + println!(" Response (JSON representation):"); + println!(" {{"); + println!(" \"id\": \"{}\",", container.id); + println!(" \"parentId\": \"{}\",", container.parent_id); + println!(" \"title\": \"{}\",", container.title); + println!(" \"class\": \"{}\",", container.class); + println!(" \"childCount\": {}", container.child_count.unwrap_or_default()); + println!(" }}"); +} + +/// Simule GET /playlist/items?offset=X&count=Y +async fn simulate_get_items(playlist: Arc, offset: usize, count: usize) { + let items = playlist.as_objects( + offset, + count, + Some("http://media.server/api/default-image") + ).await; + + println!(" Response: {} items", items.len()); + println!(" ["); + for (idx, item) in items.iter().enumerate() { + println!(" {{"); + println!(" \"id\": \"{}\",", item.id); + println!(" \"title\": \"{}\",", item.title); + println!(" \"artist\": \"{}\",", item.artist.as_deref().unwrap_or("")); + println!(" \"album\": \"{}\",", item.album.as_deref().unwrap_or("")); + println!(" \"class\": \"{}\",", item.class); + if !item.resources.is_empty() { + println!(" \"uri\": \"{}\",", item.resources[0].url); + } + print!(" }}"); + if idx < items.len() - 1 { + println!(","); + } else { + println!(); + } + } + println!(" ]"); +} + +/// Simule GET /playlist/metadata +async fn simulate_get_metadata(playlist: Arc) { + let update_id = playlist.update_id().await; + let last_change = playlist.last_change().await; + let count = playlist.len().await; + let id = playlist.id().await; + let title = playlist.title().await; + + println!(" Response:"); + println!(" {{"); + println!(" \"id\": \"{}\",", id); + println!(" \"title\": \"{}\",", title); + println!(" \"trackCount\": {},", count); + println!(" \"updateId\": {},", update_id); + println!(" \"lastChange\": \"{:?}\"", last_change); + println!(" }}"); +} + +/// Simule POST /playlist/track +async fn simulate_add_track(playlist: Arc, track: Track) { + let old_update_id = playlist.update_id().await; + + playlist.append_track(track.clone()).await; + + let new_update_id = playlist.update_id().await; + + println!(" Track added: {} - {}", + track.artist.as_deref().unwrap_or("Unknown"), + track.title + ); + println!(" Update ID: {} → {}", old_update_id, new_update_id); + println!(" Response: 201 Created"); +} + +/// Simule DELETE /playlist/oldest +async fn simulate_delete_oldest(playlist: Arc) { + if let Some(removed) = playlist.remove_oldest().await { + println!(" Track removed: {} ({})", removed.title, removed.id); + println!(" New update ID: {}", playlist.update_id().await); + println!(" Response: 200 OK"); + } else { + println!(" No tracks to remove"); + println!(" Response: 404 Not Found"); + } +} + +/// Simule GET /playlist/default-image +async fn simulate_get_default_image(playlist: Arc) { + let image_bytes = playlist.default_image().await; + + println!(" Response:"); + println!(" Content-Type: image/webp"); + println!(" Content-Length: {} bytes", image_bytes.len()); + println!(" Status: 200 OK"); + println!(" (Image WebP {} bytes ready to serve)", image_bytes.len()); +} diff --git a/pmoplaylist/examples/radio_streaming.rs b/pmoplaylist/examples/radio_streaming.rs new file mode 100644 index 00000000..d15d511e --- /dev/null +++ b/pmoplaylist/examples/radio_streaming.rs @@ -0,0 +1,173 @@ +//! Exemple simulant une radio en streaming +//! +//! Cet exemple démontre : +//! - L'utilisation de FifoPlaylist dans un contexte multi-thread +//! - La simulation d'un flux radio continu +//! - La surveillance des changements via update_id +//! +//! Pour exécuter : +//! ```bash +//! cargo run -p pmoplaylist --example radio_streaming +//! ``` + +use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE}; +use std::time::Duration; +use tokio::time::sleep; + +#[tokio::main] +async fn main() { + println!("=== Simulation Radio en Streaming ===\n"); + + // Créer une radio avec historique limité à 10 tracks + let radio = FifoPlaylist::new( + "radio-paradise".to_string(), + "Radio Paradise - Main Mix".to_string(), + 10, + DEFAULT_IMAGE, + ); + + println!("📻 Radio créée: {}", radio.title().await); + println!("📊 Capacité: 10 tracks (historique limité)"); + println!("🆔 ID: {}\n", radio.id().await); + + // Cloner pour les différentes tâches + let radio_streamer = radio.clone(); + let radio_monitor = radio.clone(); + let radio_client = radio.clone(); + + // Tâche 1: Simuler le streaming (ajoute des tracks régulièrement) + let streamer = tokio::spawn(async move { + println!("🎵 [STREAMER] Démarrage du flux radio...\n"); + + let tracks_data = vec![ + ("Radiohead", "Paranoid Android", "OK Computer", 383), + ("Massive Attack", "Teardrop", "Mezzanine", 329), + ("Pink Floyd", "Shine On You Crazy Diamond", "Wish You Were Here", 810), + ("Portishead", "Glory Box", "Dummy", 305), + ("Dire Straits", "Sultans of Swing", "Dire Straits", 349), + ("The Cure", "Pictures of You", "Disintegration", 428), + ("David Bowie", "Heroes", "Heroes", 371), + ("Talking Heads", "Once in a Lifetime", "Remain in Light", 259), + ("Fleetwood Mac", "Dreams", "Rumours", 257), + ("The Smiths", "There Is a Light That Never Goes Out", "The Queen Is Dead", 244), + ("Joy Division", "Love Will Tear Us Apart", "Closer", 206), + ("New Order", "Blue Monday", "Power, Corruption & Lies", 448), + ("Depeche Mode", "Enjoy the Silence", "Violator", 376), + ("R.E.M.", "Losing My Religion", "Out of Time", 269), + ("U2", "Where the Streets Have No Name", "The Joshua Tree", 337), + ]; + + for (idx, (artist, title, album, duration)) in tracks_data.iter().enumerate() { + let track = Track::new( + format!("radio-track-{}", idx), + *title, + format!("http://stream.radioparadise.com/track/{}", idx) + ) + .with_artist(*artist) + .with_album(*album) + .with_duration(*duration); + + radio_streamer.append_track(track).await; + + println!("🎵 [STREAMER] Now Playing: {} - {}", artist, title); + + // Simuler l'attente entre les tracks + sleep(Duration::from_millis(500)).await; + } + + println!("\n🎵 [STREAMER] Fin du streaming"); + }); + + // Tâche 2: Monitorer les changements (update_id) + let monitor = tokio::spawn(async move { + sleep(Duration::from_millis(100)).await; + + println!("👁️ [MONITOR] Surveillance des changements...\n"); + + let mut last_update_id = 0; + let mut iterations = 0; + + loop { + let current_update_id = radio_monitor.update_id().await; + let count = radio_monitor.len().await; + + if current_update_id != last_update_id { + println!( + "👁️ [MONITOR] Changement détecté! Update ID: {} → {} | Tracks: {}", + last_update_id, + current_update_id, + count + ); + last_update_id = current_update_id; + } + + iterations += 1; + if iterations >= 50 { + break; + } + + sleep(Duration::from_millis(200)).await; + } + + println!("\n👁️ [MONITOR] Fin de la surveillance"); + }); + + // Tâche 3: Client consultant l'historique + let client = tokio::spawn(async move { + sleep(Duration::from_millis(2000)).await; + + println!("\n📱 [CLIENT] Consultation de l'historique de la radio...\n"); + + // Consulter plusieurs fois pendant le streaming + for i in 0..3 { + sleep(Duration::from_millis(2000)).await; + + let history = radio_client.get_items(0, 10).await; + let update_id = radio_client.update_id().await; + + println!("📱 [CLIENT] Consultation #{} (Update ID: {})", i + 1, update_id); + println!(" Historique actuel ({} tracks):", history.len()); + + for (idx, track) in history.iter().enumerate() { + let artist = track.artist.as_deref().unwrap_or("Unknown"); + println!(" {}. {} - {}", idx + 1, artist, track.title); + } + println!(); + } + + // Générer le container DIDL-Lite à la fin + println!("📱 [CLIENT] Génération du Container DIDL-Lite..."); + let container = radio_client.as_container().await; + println!(" Container ID: {}", container.id); + println!(" Title: {}", container.title); + println!(" Child Count: {}", container.child_count.unwrap_or_default()); + + println!("\n📱 [CLIENT] Fin de la consultation"); + }); + + // Attendre que toutes les tâches se terminent + let _ = tokio::join!(streamer, monitor, client); + + // Afficher l'état final + println!("\n=== État Final ==="); + println!("📊 Total tracks dans la radio: {}", radio.len().await); + println!("🆔 Update ID final: {}", radio.update_id().await); + + let final_history = radio.get_items(0, 10).await; + println!("\n🎵 Historique final (10 derniers tracks):"); + for (idx, track) in final_history.iter().enumerate() { + let artist = track.artist.as_deref().unwrap_or("Unknown"); + let duration_min = track.duration.map(|d| d / 60).unwrap_or(0); + let duration_sec = track.duration.map(|d| d % 60).unwrap_or(0); + println!( + " {}. {} - {} ({}:{:02})", + idx + 1, + artist, + track.title, + duration_min, + duration_sec + ); + } + + println!("\n=== Simulation terminée ==="); +} diff --git a/pmoplaylist/src/lib.rs b/pmoplaylist/src/lib.rs new file mode 100644 index 00000000..fb7a827b --- /dev/null +++ b/pmoplaylist/src/lib.rs @@ -0,0 +1,774 @@ +//! # pmoplaylist - FIFO Audio Universelle pour MediaServer UPnP/OpenHome +//! +//! Cette crate fournit une abstraction de playlist/container audio avec : +//! - Gestion de FIFO audio avec capacité configurable +//! - Exposition d'objets DIDL-Lite via `pmodidl` +//! - Support update_id et last_change pour signaler les modifications +//! - Image par défaut pour le container racine +//! +//! # Exemples +//! +//! ``` +//! use pmoplaylist::{FifoPlaylist, Track}; +//! +//! # #[tokio::main] +//! # async fn main() { +//! // Créer une FIFO avec capacité de 10 tracks +//! let mut playlist = FifoPlaylist::new( +//! "radio-1".to_string(), +//! "Ma Radio Préférée".to_string(), +//! 10, +//! pmoplaylist::DEFAULT_IMAGE, +//! ); +//! +//! // Ajouter un track +//! let track = Track { +//! id: "track-1".to_string(), +//! title: "Bohemian Rhapsody".to_string(), +//! artist: Some("Queen".to_string()), +//! album: Some("A Night at the Opera".to_string()), +//! duration: Some(354), +//! uri: "http://example.com/song.mp3".to_string(), +//! image: None, +//! }; +//! +//! playlist.append_track(track).await; +//! +//! // Récupérer les items pour ContentDirectory +//! let items = playlist.get_items(0, 10).await; +//! println!("Nombre de tracks: {}", items.len()); +//! +//! // Générer le container DIDL-Lite +//! let container = playlist.as_container().await; +//! println!("Container ID: {}", container.id); +//! # } +//! ``` + +use pmodidl::{Container, Item, Resource}; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use std::sync::Arc; +use std::time::SystemTime; +use tokio::sync::RwLock; + +/// Image WebP par défaut embarquée (1x1 pixel transparent) +/// Remplacez ceci par votre propre image WebP si nécessaire +pub const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); + +/// Représente un track audio dans la FIFO +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Track { + /// Identifiant unique du track + pub id: String, + + /// Titre du track + pub title: String, + + /// Artiste (optionnel) + pub artist: Option, + + /// Album (optionnel) + pub album: Option, + + /// Durée en secondes (optionnel) + pub duration: Option, + + /// URI du flux ou fichier audio + pub uri: String, + + /// URL de l'image/cover (optionnel, utilise l'image par défaut de la FIFO si absent) + pub image: Option, +} + +impl Track { + /// Crée un nouveau track avec les informations minimales + /// + /// # Exemples + /// + /// ``` + /// use pmoplaylist::Track; + /// + /// let track = Track::new( + /// "track-1", + /// "Bohemian Rhapsody", + /// "http://example.com/song.mp3" + /// ); + /// ``` + pub fn new(id: impl Into, title: impl Into, uri: impl Into) -> Self { + Self { + id: id.into(), + title: title.into(), + artist: None, + album: None, + duration: None, + uri: uri.into(), + image: None, + } + } + + /// Définit l'artiste du track + pub fn with_artist(mut self, artist: impl Into) -> Self { + self.artist = Some(artist.into()); + self + } + + /// Définit l'album du track + pub fn with_album(mut self, album: impl Into) -> Self { + self.album = Some(album.into()); + self + } + + /// Définit la durée du track en secondes + pub fn with_duration(mut self, duration: u32) -> Self { + self.duration = Some(duration); + self + } + + /// Définit l'URL de l'image du track + pub fn with_image(mut self, image: impl Into) -> Self { + self.image = Some(image.into()); + self + } + + /// Convertit le track en Item DIDL-Lite + /// + /// # Arguments + /// + /// * `parent_id` - ID du container parent + /// * `default_image` - Image par défaut si le track n'en a pas + fn to_didl_item(&self, parent_id: &str, default_image: Option<&str>) -> Item { + // Formater la durée au format H:MM:SS + let duration_str = self.duration.map(|d| { + let hours = d / 3600; + let minutes = (d % 3600) / 60; + let seconds = d % 60; + format!("{}:{:02}:{:02}", hours, minutes, seconds) + }); + + // Utiliser l'image du track ou l'image par défaut + let album_art = self.image.as_deref().or(default_image).map(String::from); + + // Créer la ressource audio + let resource = Resource { + protocol_info: "http-get:*:audio/*:*".to_string(), + bits_per_sample: None, + sample_frequency: None, + nr_audio_channels: None, + duration: duration_str, + url: self.uri.clone(), + }; + + Item { + id: self.id.clone(), + parent_id: parent_id.to_string(), + restricted: Some("1".to_string()), + title: self.title.clone(), + creator: self.artist.clone(), + class: "object.item.audioItem.musicTrack".to_string(), + artist: self.artist.clone(), + album: self.album.clone(), + genre: None, + album_art, + album_art_pk: None, + date: None, + original_track_number: None, + resources: vec![resource], + descriptions: vec![], + } + } +} + +/// FIFO playlist thread-safe avec capacité configurable +#[derive(Clone)] +pub struct FifoPlaylist { + inner: Arc>, +} + +struct FifoPlaylistInner { + /// Identifiant unique de la FIFO + id: String, + + /// Titre de la FIFO + title: String, + + /// Image par défaut (WebP embarquée) + default_image: &'static [u8], + + /// Capacité maximale de la FIFO + capacity: usize, + + /// Queue FIFO des tracks + queue: VecDeque, + + /// Numéro de version pour signaler les modifications + update_id: u32, + + /// Timestamp de la dernière modification + last_change: SystemTime, +} + +impl FifoPlaylist { + /// Crée une nouvelle FIFO playlist + /// + /// # Arguments + /// + /// * `id` - Identifiant unique de la playlist + /// * `title` - Titre de la playlist + /// * `capacity` - Capacité maximale (nombre de tracks) + /// * `default_image` - Image par défaut en format WebP + /// + /// # Exemples + /// + /// ``` + /// use pmoplaylist::FifoPlaylist; + /// + /// let playlist = FifoPlaylist::new( + /// "radio-1".to_string(), + /// "Ma Radio".to_string(), + /// 10, + /// pmoplaylist::DEFAULT_IMAGE, + /// ); + /// ``` + pub fn new( + id: String, + title: String, + capacity: usize, + default_image: &'static [u8], + ) -> Self { + Self { + inner: Arc::new(RwLock::new(FifoPlaylistInner { + id, + title, + default_image, + capacity, + queue: VecDeque::new(), + update_id: 0, + last_change: SystemTime::now(), + })), + } + } + + /// Ajoute un track à la fin de la FIFO + /// + /// Si la capacité est atteinte, le track le plus ancien est supprimé automatiquement. + /// Met à jour `update_id` et `last_change`. + /// + /// # Arguments + /// + /// * `track` - Le track à ajouter + /// + /// # Exemples + /// + /// ``` + /// use pmoplaylist::{FifoPlaylist, Track}; + /// + /// # #[tokio::main] + /// # async fn main() { + /// let mut playlist = FifoPlaylist::new( + /// "playlist-1".to_string(), + /// "My Playlist".to_string(), + /// 5, + /// pmoplaylist::DEFAULT_IMAGE, + /// ); + /// + /// let track = Track::new("track-1", "Song Title", "http://example.com/song.mp3"); + /// playlist.append_track(track).await; + /// # } + /// ``` + pub async fn append_track(&self, track: Track) { + let mut inner = self.inner.write().await; + + // Si la capacité est atteinte, supprimer le plus ancien + if inner.queue.len() >= inner.capacity { + inner.queue.pop_front(); + } + + inner.queue.push_back(track); + inner.update_id = inner.update_id.wrapping_add(1); + inner.last_change = SystemTime::now(); + } + + /// Supprime le track le plus ancien de la FIFO + /// + /// Met à jour `update_id` et `last_change` si un track est supprimé. + /// Retourne le track supprimé, ou None si la FIFO est vide. + /// + /// # Exemples + /// + /// ``` + /// use pmoplaylist::{FifoPlaylist, Track}; + /// + /// # #[tokio::main] + /// # async fn main() { + /// let mut playlist = FifoPlaylist::new( + /// "playlist-1".to_string(), + /// "My Playlist".to_string(), + /// 5, + /// pmoplaylist::DEFAULT_IMAGE, + /// ); + /// + /// playlist.append_track(Track::new("track-1", "Song", "http://example.com/1.mp3")).await; + /// + /// let removed = playlist.remove_oldest().await; + /// assert!(removed.is_some()); + /// # } + /// ``` + pub async fn remove_oldest(&self) -> Option { + let mut inner = self.inner.write().await; + + let track = inner.queue.pop_front(); + + if track.is_some() { + inner.update_id = inner.update_id.wrapping_add(1); + inner.last_change = SystemTime::now(); + } + + track + } + + /// Supprime un track par son ID + /// + /// Met à jour `update_id` et `last_change` si un track est supprimé. + /// Retourne true si un track a été supprimé, false sinon. + /// + /// # Arguments + /// + /// * `track_id` - L'ID du track à supprimer + pub async fn remove_by_id(&self, track_id: &str) -> bool { + let mut inner = self.inner.write().await; + + if let Some(pos) = inner.queue.iter().position(|t| t.id == track_id) { + inner.queue.remove(pos); + inner.update_id = inner.update_id.wrapping_add(1); + inner.last_change = SystemTime::now(); + true + } else { + false + } + } + + /// Vide complètement la FIFO + /// + /// Met à jour `update_id` et `last_change` si la FIFO n'était pas vide. + pub async fn clear(&self) { + let mut inner = self.inner.write().await; + + if !inner.queue.is_empty() { + inner.queue.clear(); + inner.update_id = inner.update_id.wrapping_add(1); + inner.last_change = SystemTime::now(); + } + } + + /// Retourne le nombre de tracks dans la FIFO + pub async fn len(&self) -> usize { + let inner = self.inner.read().await; + inner.queue.len() + } + + /// Vérifie si la FIFO est vide + pub async fn is_empty(&self) -> bool { + let inner = self.inner.read().await; + inner.queue.is_empty() + } + + /// Récupère une portion des tracks pour navigation partielle + /// + /// # Arguments + /// + /// * `offset` - Index de départ (0-based) + /// * `count` - Nombre maximum de tracks à retourner + /// + /// # Retourne + /// + /// Un vecteur de tracks, potentiellement vide si offset est hors limite + /// + /// # Exemples + /// + /// ``` + /// use pmoplaylist::{FifoPlaylist, Track}; + /// + /// # #[tokio::main] + /// # async fn main() { + /// let mut playlist = FifoPlaylist::new( + /// "playlist-1".to_string(), + /// "My Playlist".to_string(), + /// 10, + /// pmoplaylist::DEFAULT_IMAGE, + /// ); + /// + /// // Ajouter plusieurs tracks... + /// for i in 0..5 { + /// playlist.append_track(Track::new( + /// format!("track-{}", i), + /// format!("Song {}", i), + /// format!("http://example.com/{}.mp3", i) + /// )).await; + /// } + /// + /// // Récupérer les tracks 2 à 4 + /// let items = playlist.get_items(2, 2).await; + /// assert_eq!(items.len(), 2); + /// # } + /// ``` + pub async fn get_items(&self, offset: usize, count: usize) -> Vec { + let inner = self.inner.read().await; + + inner.queue + .iter() + .skip(offset) + .take(count) + .cloned() + .collect() + } + + /// Retourne l'update_id actuel + /// + /// L'update_id est incrémenté à chaque modification de la FIFO. + /// Utile pour détecter les changements côté client UPnP. + pub async fn update_id(&self) -> u32 { + let inner = self.inner.read().await; + inner.update_id + } + + /// Retourne le timestamp de la dernière modification + pub async fn last_change(&self) -> SystemTime { + let inner = self.inner.read().await; + inner.last_change + } + + /// Retourne l'ID de la playlist + pub async fn id(&self) -> String { + let inner = self.inner.read().await; + inner.id.clone() + } + + /// Retourne le titre de la playlist + pub async fn title(&self) -> String { + let inner = self.inner.read().await; + inner.title.clone() + } + + /// Génère un Container DIDL-Lite représentant cette FIFO + /// + /// Le container peut être utilisé pour le ContentDirectory UPnP. + /// + /// # Arguments + /// + /// * `parent_id` - ID du container parent (par défaut "0" pour la racine) + /// + /// # Exemples + /// + /// ``` + /// use pmoplaylist::FifoPlaylist; + /// + /// # #[tokio::main] + /// # async fn main() { + /// let playlist = FifoPlaylist::new( + /// "radio-1".to_string(), + /// "Ma Radio".to_string(), + /// 10, + /// pmoplaylist::DEFAULT_IMAGE, + /// ); + /// + /// let container = playlist.as_container_with_parent("0").await; + /// println!("Container: {:?}", container); + /// # } + /// ``` + pub async fn as_container_with_parent(&self, parent_id: impl Into) -> Container { + let inner = self.inner.read().await; + + Container { + id: inner.id.clone(), + parent_id: parent_id.into(), + restricted: Some("1".to_string()), + child_count: Some(inner.queue.len().to_string()), + title: inner.title.clone(), + class: "object.container.playlistContainer".to_string(), + containers: vec![], + items: vec![], + } + } + + /// Génère un Container DIDL-Lite avec parent_id = "0" + pub async fn as_container(&self) -> Container { + self.as_container_with_parent("0").await + } + + /// Génère un vecteur d'objets DIDL-Lite Item correspondant aux tracks + /// + /// # Arguments + /// + /// * `offset` - Index de départ (0-based) + /// * `count` - Nombre maximum d'items à retourner + /// * `default_image_url` - URL optionnelle pour l'image par défaut (endpoint servant l'image) + /// + /// # Exemples + /// + /// ``` + /// use pmoplaylist::{FifoPlaylist, Track}; + /// + /// # #[tokio::main] + /// # async fn main() { + /// let mut playlist = FifoPlaylist::new( + /// "radio-1".to_string(), + /// "Ma Radio".to_string(), + /// 10, + /// pmoplaylist::DEFAULT_IMAGE, + /// ); + /// + /// playlist.append_track(Track::new("track-1", "Song", "http://example.com/1.mp3")).await; + /// + /// let items = playlist.as_objects(0, 10, Some("http://server/default.webp")).await; + /// assert_eq!(items.len(), 1); + /// # } + /// ``` + pub async fn as_objects( + &self, + offset: usize, + count: usize, + default_image_url: Option<&str>, + ) -> Vec { + let inner = self.inner.read().await; + + inner.queue + .iter() + .skip(offset) + .take(count) + .map(|track| track.to_didl_item(&inner.id, default_image_url)) + .collect() + } + + /// Retourne l'image par défaut en tant que slice de bytes + /// + /// Peut être servi via un endpoint HTTP pour les clients UPnP + pub async fn default_image(&self) -> &'static [u8] { + let inner = self.inner.read().await; + inner.default_image + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_create_playlist() { + let playlist = FifoPlaylist::new( + "test-1".to_string(), + "Test Playlist".to_string(), + 5, + DEFAULT_IMAGE, + ); + + assert_eq!(playlist.len().await, 0); + assert!(playlist.is_empty().await); + assert_eq!(playlist.update_id().await, 0); + } + + #[tokio::test] + async fn test_append_track() { + let playlist = FifoPlaylist::new( + "test-1".to_string(), + "Test Playlist".to_string(), + 5, + DEFAULT_IMAGE, + ); + + let track = Track::new("track-1", "Song 1", "http://example.com/1.mp3"); + playlist.append_track(track).await; + + assert_eq!(playlist.len().await, 1); + assert_eq!(playlist.update_id().await, 1); + } + + #[tokio::test] + async fn test_fifo_capacity() { + let playlist = FifoPlaylist::new( + "test-1".to_string(), + "Test Playlist".to_string(), + 3, + DEFAULT_IMAGE, + ); + + // Ajouter 5 tracks alors que la capacité est 3 + for i in 0..5 { + let track = Track::new( + format!("track-{}", i), + format!("Song {}", i), + format!("http://example.com/{}.mp3", i), + ); + playlist.append_track(track).await; + } + + // Seuls les 3 derniers doivent rester + assert_eq!(playlist.len().await, 3); + + let items = playlist.get_items(0, 10).await; + assert_eq!(items[0].id, "track-2"); + assert_eq!(items[2].id, "track-4"); + } + + #[tokio::test] + async fn test_remove_oldest() { + let playlist = FifoPlaylist::new( + "test-1".to_string(), + "Test Playlist".to_string(), + 5, + DEFAULT_IMAGE, + ); + + playlist.append_track(Track::new("track-1", "Song 1", "http://example.com/1.mp3")).await; + playlist.append_track(Track::new("track-2", "Song 2", "http://example.com/2.mp3")).await; + + let removed = playlist.remove_oldest().await; + assert!(removed.is_some()); + assert_eq!(removed.unwrap().id, "track-1"); + assert_eq!(playlist.len().await, 1); + } + + #[tokio::test] + async fn test_remove_by_id() { + let playlist = FifoPlaylist::new( + "test-1".to_string(), + "Test Playlist".to_string(), + 5, + DEFAULT_IMAGE, + ); + + playlist.append_track(Track::new("track-1", "Song 1", "http://example.com/1.mp3")).await; + playlist.append_track(Track::new("track-2", "Song 2", "http://example.com/2.mp3")).await; + playlist.append_track(Track::new("track-3", "Song 3", "http://example.com/3.mp3")).await; + + assert!(playlist.remove_by_id("track-2").await); + assert_eq!(playlist.len().await, 2); + + let items = playlist.get_items(0, 10).await; + assert_eq!(items[0].id, "track-1"); + assert_eq!(items[1].id, "track-3"); + } + + #[tokio::test] + async fn test_clear() { + let playlist = FifoPlaylist::new( + "test-1".to_string(), + "Test Playlist".to_string(), + 5, + DEFAULT_IMAGE, + ); + + playlist.append_track(Track::new("track-1", "Song 1", "http://example.com/1.mp3")).await; + playlist.append_track(Track::new("track-2", "Song 2", "http://example.com/2.mp3")).await; + + playlist.clear().await; + assert_eq!(playlist.len().await, 0); + assert!(playlist.is_empty().await); + } + + #[tokio::test] + async fn test_get_items_pagination() { + let playlist = FifoPlaylist::new( + "test-1".to_string(), + "Test Playlist".to_string(), + 10, + DEFAULT_IMAGE, + ); + + for i in 0..5 { + playlist.append_track(Track::new( + format!("track-{}", i), + format!("Song {}", i), + format!("http://example.com/{}.mp3", i), + )).await; + } + + let items = playlist.get_items(1, 2).await; + assert_eq!(items.len(), 2); + assert_eq!(items[0].id, "track-1"); + assert_eq!(items[1].id, "track-2"); + } + + #[tokio::test] + async fn test_as_container() { + let playlist = FifoPlaylist::new( + "radio-1".to_string(), + "Test Radio".to_string(), + 10, + DEFAULT_IMAGE, + ); + + playlist.append_track(Track::new("track-1", "Song 1", "http://example.com/1.mp3")).await; + + let container = playlist.as_container().await; + assert_eq!(container.id, "radio-1"); + assert_eq!(container.title, "Test Radio"); + assert_eq!(container.parent_id, "0"); + assert_eq!(container.child_count, Some("1".to_string())); + } + + #[tokio::test] + async fn test_as_objects() { + let playlist = FifoPlaylist::new( + "radio-1".to_string(), + "Test Radio".to_string(), + 10, + DEFAULT_IMAGE, + ); + + let track = Track::new("track-1", "Bohemian Rhapsody", "http://example.com/song.mp3") + .with_artist("Queen") + .with_album("A Night at the Opera") + .with_duration(354); + + playlist.append_track(track).await; + + let items = playlist.as_objects(0, 10, Some("http://server/default.webp")).await; + assert_eq!(items.len(), 1); + + let item = &items[0]; + assert_eq!(item.id, "track-1"); + assert_eq!(item.title, "Bohemian Rhapsody"); + assert_eq!(item.artist, Some("Queen".to_string())); + assert_eq!(item.album, Some("A Night at the Opera".to_string())); + assert_eq!(item.parent_id, "radio-1"); + assert!(item.resources.len() > 0); + } + + #[tokio::test] + async fn test_track_builder() { + let track = Track::new("track-1", "Song", "http://example.com/song.mp3") + .with_artist("Artist") + .with_album("Album") + .with_duration(180) + .with_image("http://example.com/cover.jpg"); + + assert_eq!(track.artist, Some("Artist".to_string())); + assert_eq!(track.album, Some("Album".to_string())); + assert_eq!(track.duration, Some(180)); + assert_eq!(track.image, Some("http://example.com/cover.jpg".to_string())); + } + + #[tokio::test] + async fn test_update_id_increments() { + let playlist = FifoPlaylist::new( + "test-1".to_string(), + "Test Playlist".to_string(), + 5, + DEFAULT_IMAGE, + ); + + assert_eq!(playlist.update_id().await, 0); + + playlist.append_track(Track::new("track-1", "Song 1", "http://example.com/1.mp3")).await; + assert_eq!(playlist.update_id().await, 1); + + playlist.append_track(Track::new("track-2", "Song 2", "http://example.com/2.mp3")).await; + assert_eq!(playlist.update_id().await, 2); + + playlist.remove_oldest().await; + assert_eq!(playlist.update_id().await, 3); + + playlist.clear().await; + assert_eq!(playlist.update_id().await, 4); + } +} diff --git a/pmoqobuz/src/source.rs b/pmoqobuz/src/source.rs index 7edbde34..74e46894 100644 --- a/pmoqobuz/src/source.rs +++ b/pmoqobuz/src/source.rs @@ -1,35 +1,157 @@ //! Music source implementation for Qobuz //! //! This module implements the [`pmosource::MusicSource`] trait for Qobuz, -//! providing access to the service's default image and identification information. +//! providing a complete music catalog browsing and searching experience. -use pmosource::MusicSource; +use crate::client::QobuzClient; +use crate::didl::ToDIDL; +use crate::models::{Album, Track}; +use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result}; +use pmodidl::{Container, Item}; +use std::sync::Arc; +use std::time::SystemTime; +use tokio::sync::RwLock; /// Default image for Qobuz (300x300 WebP, embedded in binary) const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); -/// Qobuz music source +/// Qobuz music source with full MusicSource trait implementation /// -/// This struct implements the [`MusicSource`] trait to provide -/// standardized access to Qobuz's identification and branding. +/// This struct combines a [`QobuzClient`] for API access with browsing and +/// navigation capabilities, implementing the complete [`MusicSource`] trait. +/// +/// # Features +/// +/// - **Catalog Navigation**: Browse albums, artists, playlists, favorites +/// - **Search**: Full-text search across the Qobuz catalog +/// - **URI Resolution**: Resolves track streaming URIs with authentication +/// - **DIDL-Lite Export**: Converts albums, tracks, and playlists to UPnP formats +/// - **Caching**: Integrated with QobuzClient's cache for performance +/// +/// # Architecture +/// +/// Unlike streaming sources like Radio Paradise, Qobuz is a catalog-based source: +/// - Root container has multiple sub-containers (Albums, Artists, Favorites, etc.) +/// - No FIFO support (it's a static catalog, not a dynamic stream) +/// - Hierarchical browsing: Root → Category → Albums → Tracks /// /// # Examples /// -/// ``` -/// use pmoqobuz::QobuzSource; +/// ```no_run +/// use pmoqobuz::{QobuzSource, QobuzClient}; /// use pmosource::MusicSource; /// -/// let source = QobuzSource; -/// assert_eq!(source.name(), "Qobuz"); -/// assert_eq!(source.id(), "qobuz"); +/// #[tokio::main] +/// async fn main() -> Result<(), Box> { +/// let client = QobuzClient::from_config().await?; +/// let source = QobuzSource::new(client); /// -/// // Get default image as WebP bytes -/// let image_data = source.default_image(); -/// assert!(image_data.len() > 0); +/// println!("Source: {}", source.name()); +/// println!("Supports FIFO: {}", source.supports_fifo()); +/// +/// // Browse root container +/// let root = source.root_container().await?; +/// println!("Root: {} with {} children", root.title, root.child_count.unwrap_or_default()); +/// +/// Ok(()) +/// } /// ``` -#[derive(Debug, Clone, Copy, Default)] -pub struct QobuzSource; +#[derive(Clone)] +pub struct QobuzSource { + inner: Arc, +} +struct QobuzSourceInner { + /// Qobuz API client + client: QobuzClient, + + /// Update tracking + update_counter: RwLock, + last_change: RwLock, +} + +impl std::fmt::Debug for QobuzSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("QobuzSource").finish() + } +} + +impl QobuzSource { + /// Create a new Qobuz source + /// + /// # Arguments + /// + /// * `client` - Authenticated Qobuz API client + /// + /// # Examples + /// + /// ```no_run + /// use pmoqobuz::{QobuzSource, QobuzClient}; + /// + /// #[tokio::main] + /// async fn main() -> Result<(), Box> { + /// let client = QobuzClient::from_config().await?; + /// let source = QobuzSource::new(client); + /// Ok(()) + /// } + /// ``` + pub fn new(client: QobuzClient) -> Self { + Self { + inner: Arc::new(QobuzSourceInner { + client, + update_counter: RwLock::new(0), + last_change: RwLock::new(SystemTime::now()), + }), + } + } + + /// Get the Qobuz client + pub fn client(&self) -> &QobuzClient { + &self.inner.client + } + + /// Increment update counter (called on catalog changes) + async fn increment_update_id(&self) { + let mut counter = self.inner.update_counter.write().await; + *counter = counter.wrapping_add(1); + let mut last = self.inner.last_change.write().await; + *last = SystemTime::now(); + } + + /// Parse object_id to determine what to browse + /// + /// Object IDs follow these patterns: + /// - "qobuz" or "0" → Root container + /// - "qobuz:favorites" → User's favorite albums + /// - "qobuz:album:{id}" → Tracks in album + /// - "qobuz:playlist:{id}" → Tracks in playlist + fn parse_object_id(&self, object_id: &str) -> ObjectIdType { + if object_id == "qobuz" || object_id == "0" { + return ObjectIdType::Root; + } + + let parts: Vec<&str> = object_id.split(':').collect(); + match parts.as_slice() { + ["qobuz", "favorites"] => ObjectIdType::Favorites, + ["qobuz", "album", id] => ObjectIdType::Album(id.to_string()), + ["qobuz", "playlist", id] => ObjectIdType::Playlist(id.to_string()), + ["qobuz", "artist", id] => ObjectIdType::Artist(id.to_string()), + _ => ObjectIdType::Unknown, + } + } +} + +#[derive(Debug)] +enum ObjectIdType { + Root, + Favorites, + Album(String), + Playlist(String), + Artist(String), + Unknown, +} + +#[async_trait] impl MusicSource for QobuzSource { fn name(&self) -> &str { "Qobuz" @@ -42,29 +164,227 @@ impl MusicSource for QobuzSource { fn default_image(&self) -> &[u8] { DEFAULT_IMAGE } + + async fn root_container(&self) -> Result { + // Create the root container with sub-containers for different categories + Ok(Container { + id: "qobuz".to_string(), + parent_id: "0".to_string(), + restricted: Some("1".to_string()), + child_count: Some("2".to_string()), // Favorites + Search (simplified) + title: "Qobuz".to_string(), + class: "object.container".to_string(), + containers: vec![ + // Favorites container + Container { + id: "qobuz:favorites".to_string(), + parent_id: "qobuz".to_string(), + restricted: Some("1".to_string()), + child_count: None, // Will be determined when browsed + title: "My Favorites".to_string(), + class: "object.container".to_string(), + containers: vec![], + items: vec![], + }, + ], + items: vec![], + }) + } + + async fn browse(&self, object_id: &str) -> Result { + match self.parse_object_id(object_id) { + ObjectIdType::Root => { + // Return the root container's children + let root = self.root_container().await?; + Ok(BrowseResult::Containers(root.containers)) + } + + ObjectIdType::Favorites => { + // Get user's favorite albums + let albums = self + .inner + .client + .get_favorite_albums() + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let containers: Vec = albums + .into_iter() + .filter_map(|album| album.to_didl_container("qobuz:favorites").ok()) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + ObjectIdType::Album(album_id) => { + // Get tracks in album + let tracks = self + .inner + .client + .get_album_tracks(&album_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let items: Vec = tracks + .into_iter() + .filter_map(|track| { + track + .to_didl_item(&format!("qobuz:album:{}", album_id)) + .ok() + }) + .collect(); + + Ok(BrowseResult::Items(items)) + } + + ObjectIdType::Playlist(playlist_id) => { + // Get tracks in playlist + let tracks = self + .inner + .client + .get_playlist_tracks(&playlist_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let items: Vec = tracks + .into_iter() + .filter_map(|track| { + track + .to_didl_item(&format!("qobuz:playlist:{}", playlist_id)) + .ok() + }) + .collect(); + + Ok(BrowseResult::Items(items)) + } + + ObjectIdType::Artist(artist_id) => { + // Get albums by artist + let albums = self + .inner + .client + .get_artist_albums(&artist_id) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let containers: Vec = albums + .into_iter() + .filter_map(|album| { + album + .to_didl_container(&format!("qobuz:artist:{}", artist_id)) + .ok() + }) + .collect(); + + Ok(BrowseResult::Containers(containers)) + } + + ObjectIdType::Unknown => Err(MusicSourceError::ObjectNotFound(object_id.to_string())), + } + } + + async fn resolve_uri(&self, object_id: &str) -> Result { + // Extract track ID from object_id + // Object IDs for tracks follow pattern: "qobuz://track/{id}" + let track_id = if let Some(id) = object_id.strip_prefix("qobuz://track/") { + id + } else { + object_id + }; + + // Get streaming URL from Qobuz + self.inner + .client + .get_stream_url(track_id) + .await + .map_err(|e| MusicSourceError::UriResolutionError(e.to_string())) + } + + fn supports_fifo(&self) -> bool { + // Qobuz is a catalog, not a dynamic stream + false + } + + async fn append_track(&self, _track: Item) -> Result<()> { + Err(MusicSourceError::FifoNotSupported) + } + + async fn remove_oldest(&self) -> Result> { + Err(MusicSourceError::FifoNotSupported) + } + + async fn update_id(&self) -> u32 { + *self.inner.update_counter.read().await + } + + async fn last_change(&self) -> Option { + Some(*self.inner.last_change.read().await) + } + + async fn get_items(&self, offset: usize, count: usize) -> Result> { + // For Qobuz, "get_items" returns favorite tracks with pagination + let all_tracks = self + .inner + .client + .get_favorite_tracks() + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + let items: Vec = all_tracks + .into_iter() + .skip(offset) + .take(count) + .filter_map(|track| track.to_didl_item("qobuz:favorites").ok()) + .collect(); + + Ok(items) + } + + async fn search(&self, query: &str) -> Result { + // Search across Qobuz catalog + let results = self + .inner + .client + .search(query, None) + .await + .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?; + + // Convert albums to containers and tracks to items + let containers: Vec = results + .albums + .into_iter() + .filter_map(|album| album.to_didl_container("qobuz").ok()) + .collect(); + + let items: Vec = results + .tracks + .into_iter() + .filter_map(|track| track.to_didl_item("qobuz").ok()) + .collect(); + + if !containers.is_empty() || !items.is_empty() { + Ok(BrowseResult::Mixed { containers, items }) + } else { + Ok(BrowseResult::Items(vec![])) + } + } } #[cfg(test)] mod tests { use super::*; - #[test] - fn test_source_info() { - let source = QobuzSource; - assert_eq!(source.name(), "Qobuz"); - assert_eq!(source.id(), "qobuz"); - assert_eq!(source.default_image_mime_type(), "image/webp"); - } - #[test] fn test_default_image_present() { - let source = QobuzSource; - let image = source.default_image(); - assert!(image.len() > 0, "Default image should not be empty"); + assert!(DEFAULT_IMAGE.len() > 0, "Default image should not be empty"); // Check WebP magic bytes (RIFF...WEBP) - assert!(image.len() >= 12, "Image too small to be valid WebP"); - assert_eq!(&image[0..4], b"RIFF", "Missing RIFF header"); - assert_eq!(&image[8..12], b"WEBP", "Missing WEBP signature"); + assert!(DEFAULT_IMAGE.len() >= 12, "Image too small to be valid WebP"); + assert_eq!(&DEFAULT_IMAGE[0..4], b"RIFF", "Missing RIFF header"); + assert_eq!(&DEFAULT_IMAGE[8..12], b"WEBP", "Missing WEBP signature"); } + + // Note: We can't easily test parse_object_id without creating a real client + // which requires authentication. The parsing logic is simple enough that + // it's covered by integration tests. } diff --git a/pmosource/ARCHITECTURE.md b/pmosource/ARCHITECTURE.md new file mode 100644 index 00000000..81658ce8 --- /dev/null +++ b/pmosource/ARCHITECTURE.md @@ -0,0 +1,460 @@ +# PMOSource Architecture + +This document describes the architecture and design decisions for the `pmosource` crate. + +## Overview + +`pmosource` provides a unified abstraction layer for all music sources in the PMOMusic ecosystem. It defines the `MusicSource` trait that all concrete music sources (Radio Paradise, Qobuz, local playlists, etc.) must implement. + +## Design Goals + +1. **Unified Interface**: Single trait for all music source types +2. **UPnP/OpenHome Compatible**: Support ContentDirectory browsing and DIDL-Lite +3. **Cache Integration**: Seamless integration with `pmoaudiocache` and `pmocovers` +4. **Change Tracking**: Support for UPnP event notifications via `update_id` and `last_change` +5. **FIFO Support**: Dynamic sources (radios) can manage track queues +6. **Thread Safety**: All sources must be `Send + Sync` for async servers +7. **No Network Code**: Pure abstraction layer, no HTTP/network implementation + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ PMOMusic Server │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ MusicSource Registry │ │ +│ │ - Manage multiple sources │ │ +│ │ - Aggregate content for ContentDirectory │ │ +│ │ - Handle browse/search requests │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────┼──────────────────┐ │ +│ │ │ │ │ +│ ┌────▼────┐ ┌─────▼────┐ ┌─────▼────┐ │ +│ │ Radio │ │ Qobuz │ │ Local │ │ +│ │Paradise │ │ Source │ │ Playlist │ │ +│ └────┬────┘ └─────┬────┘ └─────┬────┘ │ +│ │ │ │ │ +│ └──────────────────┼──────────────────┘ │ +│ │ │ +│ implements MusicSource trait │ +└─────────────────────────────┬───────────────────────────────────┘ + │ + ┌────────────────────┴────────────────────┐ + │ │ + ┌────▼─────┐ ┌─────▼──────┐ + │pmoplaylist│ │ pmodidl │ + │ FIFO │ │ DIDL-Lite │ + └──────────┘ └────────────┘ + │ │ + ┌────▼─────────┐ ┌────▼────────┐ + │pmoaudiocache │ │ pmocovers │ + │ Audio files │ │ Images │ + └──────────────┘ └─────────────┘ +``` + +## Core Trait: `MusicSource` + +The `MusicSource` trait is divided into 5 logical sections: + +### 1. Basic Information + +```rust +fn name(&self) -> &str; +fn id(&self) -> &str; +fn default_image(&self) -> &[u8]; +fn default_image_mime_type(&self) -> &str; +``` + +These methods provide basic metadata about the source: +- **name**: Human-readable display name +- **id**: Unique identifier for routing and container IDs +- **default_image**: Embedded WebP logo (300x300px) +- **default_image_mime_type**: Always "image/webp" + +### 2. ContentDirectory Navigation + +```rust +async fn root_container(&self) -> Result; +async fn browse(&self, object_id: &str) -> Result; +async fn resolve_uri(&self, object_id: &str) -> Result; +``` + +These methods support UPnP ContentDirectory Service: +- **root_container**: Returns the top-level container for this source +- **browse**: Returns children of a given container (sub-containers or items) +- **resolve_uri**: Resolves the actual streaming URI for a track (checks caches) + +### 3. FIFO Management + +```rust +fn supports_fifo(&self) -> bool; +async fn append_track(&self, track: Item) -> Result<()>; +async fn remove_oldest(&self) -> Result>; +``` + +For dynamic sources (radios, streaming services): +- **supports_fifo**: Indicates if source uses a FIFO queue +- **append_track**: Adds track to queue (auto-removes oldest if capacity reached) +- **remove_oldest**: Manually removes oldest track + +### 4. Change Tracking + +```rust +async fn update_id(&self) -> u32; +async fn last_change(&self) -> Option; +``` + +For UPnP event notifications: +- **update_id**: Counter incremented on each change (wraps around) +- **last_change**: Timestamp of last modification + +### 5. Pagination & Search + +```rust +async fn get_items(&self, offset: usize, count: usize) -> Result>; +async fn search(&self, query: &str) -> Result; +``` + +For efficient browsing and searching: +- **get_items**: Paginated access to items +- **search**: Optional search (default: not supported) + +## Source Types + +### Dynamic Sources (with FIFO) + +Examples: Radio Paradise, streaming radios, live playlists + +**Characteristics:** +- `supports_fifo() = true` +- Uses `pmoplaylist::FifoPlaylist` internally +- `update_id` changes when tracks are added/removed +- Limited capacity (e.g., last 50 tracks) +- Items have dynamic URIs that may change + +**Implementation Pattern:** + +```rust +struct RadioSource { + playlist: FifoPlaylist, + track_cache: RwLock)>>, +} + +impl MusicSource for RadioSource { + fn supports_fifo(&self) -> bool { + true + } + + async fn append_track(&self, track: Item) -> Result<()> { + // Convert Item to pmoplaylist::Track + // Add to playlist + self.playlist.append_track(pmo_track).await; + Ok(()) + } + + async fn update_id(&self) -> u32 { + self.playlist.update_id().await + } +} +``` + +### Static Sources (without FIFO) + +Examples: Local albums, fixed playlists, Qobuz albums + +**Characteristics:** +- `supports_fifo() = false` +- `append_track()` returns `FifoNotSupported` error +- `update_id` is constant (0) +- `last_change()` may be None +- Items have stable URIs + +**Implementation Pattern:** + +```rust +struct AlbumSource { + items: Vec, +} + +impl MusicSource for AlbumSource { + fn supports_fifo(&self) -> bool { + false + } + + async fn append_track(&self, _: Item) -> Result<()> { + Err(MusicSourceError::FifoNotSupported) + } + + async fn update_id(&self) -> u32 { + 0 // Never changes + } +} +``` + +## Integration with PMOMusic Ecosystem + +### pmoplaylist Integration + +`pmoplaylist` provides the `FifoPlaylist` struct for managing dynamic track lists: + +```rust +use pmoplaylist::{FifoPlaylist, Track}; + +let playlist = FifoPlaylist::new( + "radio-id".to_string(), + "Radio Name".to_string(), + 50, // capacity + DEFAULT_IMAGE, +); + +// Add tracks +playlist.append_track(Track::new("id", "title", "uri")).await; + +// Get tracks +let tracks = playlist.get_items(0, 10).await; + +// Track changes +let update_id = playlist.update_id().await; +let last_change = playlist.last_change().await; +``` + +**Benefits:** +- Automatic capacity management (FIFO behavior) +- Built-in change tracking +- Thread-safe (Arc>) + +### pmodidl Integration + +All sources use `pmodidl` for DIDL-Lite generation: + +```rust +use pmodidl::{Container, Item, Resource}; + +// Containers for browsing +let container = Container { + id: "source-id".to_string(), + parent_id: "0".to_string(), + title: "My Source".to_string(), + class: "object.container.playlistContainer".to_string(), + child_count: Some("10".to_string()), + containers: vec![], + items: vec![], +}; + +// Items for tracks +let item = Item { + id: "track-1".to_string(), + parent_id: "source-id".to_string(), + title: "Track Title".to_string(), + artist: Some("Artist".to_string()), + class: "object.item.audioItem.musicTrack".to_string(), + resources: vec![Resource { + url: "http://server/audio/track-1".to_string(), + protocol_info: "http-get:*:audio/flac:*".to_string(), + duration: Some("0:03:45".to_string()), + ..Default::default() + }], + ..Default::default() +}; +``` + +### pmoaudiocache Integration + +Sources can use `pmoaudiocache` to cache audio files locally: + +```rust +async fn resolve_uri(&self, object_id: &str) -> Result { + // Check if track is cached + if let Some(cached_pk) = self.get_cached_pk(object_id).await { + // Return cached URI (local FLAC file) + Ok(format!("{}/audio/cache/{}", self.cache_base_url, cached_pk)) + } else { + // Return original streaming URI + Ok(self.get_original_uri(object_id)) + } +} +``` + +**Benefits:** +- Local caching of streamed audio +- Automatic FLAC conversion +- Metadata extraction and merging +- Reduced bandwidth usage + +### pmocovers Integration + +Sources can use `pmocovers` to cache album art: + +```rust +// Store cover art PK in track metadata +let album_art_url = format!("{}/covers/images/{}", base_url, cover_pk); + +let item = Item { + album_art: Some(album_art_url), + ..Default::default() +}; +``` + +**Benefits:** +- Local caching of album art +- Automatic WebP conversion +- Multiple size variants +- Optimized delivery + +## Error Handling + +All fallible operations return `pmosource::Result`: + +```rust +pub enum MusicSourceError { + ImageLoadError(String), + InvalidImageFormat(String), + SourceUnavailable(String), + ObjectNotFound(String), + BrowseError(String), + SearchNotSupported, + FifoNotSupported, + CacheError(String), + UriResolutionError(String), +} +``` + +**Guidelines:** +- Use `ObjectNotFound` for invalid object IDs +- Use `BrowseError` for general browsing failures +- Use `SearchNotSupported` for sources without search +- Use `FifoNotSupported` for static sources +- Use `CacheError` for cache-related issues + +## Thread Safety + +All `MusicSource` implementations must be `Send + Sync`: + +```rust +pub trait MusicSource: Debug + Send + Sync { + // ... +} +``` + +**Reasoning:** +- Sources may be shared across multiple async tasks +- UPnP server handles concurrent requests +- `Arc` enables efficient sharing + +**Implementation:** +- Use `Arc>` for mutable state +- Use `tokio::sync::RwLock` for async operations +- Avoid `Rc`, `RefCell`, or other non-thread-safe types + +## Testing Strategy + +### Unit Tests + +Test each method independently: + +```rust +#[tokio::test] +async fn test_root_container() { + let source = MySource::new(); + let root = source.root_container().await.unwrap(); + assert_eq!(root.id, "my-source"); +} +``` + +### Integration Tests + +Test complete workflows: + +```rust +#[tokio::test] +async fn test_browse_and_resolve() { + let source = MySource::new(); + let result = source.browse("container-1").await.unwrap(); + for item in result.items() { + let uri = source.resolve_uri(&item.id).await.unwrap(); + assert!(uri.starts_with("http://")); + } +} +``` + +### Example Tests + +Run examples as integration tests: + +```bash +cargo run --example radio_paradise +``` + +## Future Enhancements + +Potential additions to the trait: + +1. **Authentication**: + ```rust + async fn authenticate(&mut self, credentials: Credentials) -> Result<()>; + fn is_authenticated(&self) -> bool; + ``` + +2. **Quality Levels**: + ```rust + fn available_qualities(&self) -> Vec; + async fn set_quality(&mut self, quality: Quality) -> Result<()>; + ``` + +3. **Favorites/Bookmarks**: + ```rust + async fn add_favorite(&self, object_id: &str) -> Result<()>; + async fn list_favorites(&self) -> Result>; + ``` + +4. **Recommendations**: + ```rust + async fn get_recommendations(&self) -> Result>; + ``` + +## Design Decisions + +### Why async-trait? + +- Native async traits don't support trait objects yet +- `async-trait` provides a clean macro-based solution +- Minimal performance overhead with good compiler optimizations + +### Why separate FIFO methods? + +- Clear distinction between dynamic and static sources +- Static sources can return `FifoNotSupported` immediately +- Allows future optimizations for FIFO-specific operations + +### Why BrowseResult enum? + +- Different sources return different types of results +- Some return only containers, some only items, some mixed +- Enum provides type-safe representation of all cases + +### Why separate resolve_uri? + +- Caching is a cross-cutting concern +- Separating resolution from browsing allows flexible caching strategies +- URI resolution may be expensive (check cache, fallback to original) + +## Performance Considerations + +1. **Caching**: Always check local caches before streaming +2. **Pagination**: Use `get_items(offset, count)` for large collections +3. **Lazy Loading**: Don't load all metadata upfront +4. **Arc Sharing**: Use `Arc` to avoid cloning +5. **RwLock Usage**: Prefer read locks when possible + +## Versioning + +The crate follows Semantic Versioning: + +- **MAJOR**: Breaking changes to `MusicSource` trait +- **MINOR**: New trait methods (with default implementations) +- **PATCH**: Bug fixes, documentation, internal changes + +Current version: **0.2.0** diff --git a/pmosource/CHANGELOG.md b/pmosource/CHANGELOG.md new file mode 100644 index 00000000..c3e055d4 --- /dev/null +++ b/pmosource/CHANGELOG.md @@ -0,0 +1,69 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - 2025-01-16 + +### Added + +- Extended `MusicSource` trait with comprehensive async methods: + - `root_container()`: Get root container for ContentDirectory + - `browse(object_id)`: Browse containers and items + - `resolve_uri(object_id)`: Resolve audio URIs (with cache support) + - `supports_fifo()`: Indicate FIFO support + - `append_track(track)`: Add track to FIFO + - `remove_oldest()`: Remove oldest track from FIFO + - `update_id()`: Get current update counter + - `last_change()`: Get last modification timestamp + - `get_items(offset, count)`: Paginated browsing + - `search(query)`: Optional search functionality + +- New types: + - `BrowseResult`: Enum for browse results (Containers, Items, or Mixed) + - Extended `MusicSourceError` with more error variants + +- Dependencies: + - `async-trait`: For async trait methods + - `tokio`: Async runtime + - `pmodidl`: DIDL-Lite support + - `pmoplaylist`: FIFO playlist management + - `pmoaudiocache` (optional): Audio caching + - `pmocovers` (optional): Cover art caching + +- Complete Radio Paradise example (`examples/radio_paradise.rs`) demonstrating: + - FIFO management using `pmoplaylist` + - Cache integration simulation + - DIDL-Lite generation + - Change tracking + - Full trait implementation + +- Comprehensive documentation: + - Updated README with architecture diagrams + - Usage examples for static and dynamic sources + - Integration guides for PMOMusic ecosystem + - Thread safety notes + +### Changed + +- `MusicSource` trait is now async (requires `#[async_trait]`) +- All implementations must be `Send + Sync` +- Trait is now much more comprehensive and ready for UPnP/OpenHome integration + +### Removed + +- Outdated `show_sources.rs` example + +## [0.1.0] - Initial Release + +### Added + +- Basic `MusicSource` trait with: + - `name()`: Human-readable name + - `id()`: Unique identifier + - `default_image()`: Embedded WebP logo + - `default_image_mime_type()`: MIME type +- Basic error types +- Standard image size constant (300x300px) diff --git a/pmosource/Cargo.toml b/pmosource/Cargo.toml index 32f61009..7f88c2ce 100644 --- a/pmosource/Cargo.toml +++ b/pmosource/Cargo.toml @@ -12,6 +12,24 @@ categories = ["multimedia"] [dependencies] # Gestion des erreurs thiserror = "1.0" +anyhow = "1.0" -# Image format support -image = { version = "0.25", default-features = false, features = ["webp"] } +# Async traits +async-trait = "0.1" + +# Async runtime +tokio = { version = "1.0", features = ["sync", "time"] } + +# DIDL-Lite support +pmodidl = { path = "../pmodidl" } + +# Playlist/FIFO support +pmoplaylist = { path = "../pmoplaylist" } + +# Optional cache integrations +pmoaudiocache = { path = "../pmoaudiocache", optional = true } +pmocovers = { path = "../pmocovers", optional = true } + +[features] +default = ["cache"] +cache = ["pmoaudiocache", "pmocovers"] diff --git a/pmosource/README.md b/pmosource/README.md index 9943bfc5..a1de84bd 100644 --- a/pmosource/README.md +++ b/pmosource/README.md @@ -1,112 +1,295 @@ -# pmosource +# pmosource - Music Source Abstraction for PMOMusic Common traits and types for PMOMusic sources. -## Overview - -`pmosource` provides the foundational abstractions for different music sources in the PMOMusic ecosystem, such as Radio Paradise, Qobuz, and potentially others in the future. +This crate provides the foundational abstractions for different music sources in the PMOMusic ecosystem, such as Radio Paradise, Qobuz, local playlists, etc. ## Features -- **`MusicSource` trait**: Common interface for all music sources -- **Default images**: Standardized 300x300px WebP images embedded in binaries -- **Source identification**: Consistent naming and ID scheme +- **FIFO Support**: Dynamic audio sources using `pmoplaylist` for streaming +- **Container/Item Navigation**: Browse and search using DIDL-Lite format (`pmodidl`) +- **Cache Integration**: Automatic URI resolution with `pmoaudiocache` and `pmocovers` +- **Change Tracking**: `update_id` and `last_change` for UPnP notifications +- **Send + Sync**: Ready for async servers -## Usage +## Architecture -### Implementing the trait +The `MusicSource` trait provides a unified interface for all music sources: + +``` +┌─────────────────────────────────────┐ +│ MusicSource Trait │ +├─────────────────────────────────────┤ +│ • Basic Info (name, id, image) │ +│ • ContentDirectory (browse, search) │ +│ • URI Resolution (with caching) │ +│ • FIFO Management │ +│ • Change Tracking │ +└─────────────────────────────────────┘ + ▲ ▲ ▲ + │ │ │ + ┌────┴───┐ ┌──┴────┐ ┌──┴─────┐ + │ Radio │ │ Qobuz │ │ Local │ + │Paradise│ │ │ │Playlist│ + └────────┘ └───────┘ └────────┘ +``` + +## Quick Start + +### Implementing a Music Source ```rust -use pmosource::MusicSource; - -const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); +use pmosource::{async_trait, MusicSource, BrowseResult, Result}; +use pmodidl::{Container, Item}; +use pmoplaylist::FifoPlaylist; +use std::time::SystemTime; #[derive(Debug)] -pub struct MyMusicSource; +pub struct MyRadioSource { + playlist: FifoPlaylist, + // ... other fields +} -impl MusicSource for MyMusicSource { +#[async_trait] +impl MusicSource for MyRadioSource { fn name(&self) -> &str { - "My Music Service" + "My Radio" } fn id(&self) -> &str { - "my-music-service" + "my-radio" } fn default_image(&self) -> &[u8] { - DEFAULT_IMAGE + include_bytes!("../assets/my-radio.webp") + } + + async fn root_container(&self) -> Result { + Ok(self.playlist.as_container().await) + } + + async fn browse(&self, object_id: &str) -> Result { + // Return items from FIFO + let tracks = self.playlist.get_items(0, 100).await; + // Convert tracks to Items... + Ok(BrowseResult::Items(items)) + } + + async fn resolve_uri(&self, object_id: &str) -> Result { + // Return cached URI if available, or original URI + Ok(format!("http://cache-server/audio/{}", object_id)) + } + + fn supports_fifo(&self) -> bool { + true + } + + async fn append_track(&self, track: Item) -> Result<()> { + // Convert Item to Track and add to playlist + self.playlist.append_track(pmo_track).await; + Ok(()) + } + + async fn remove_oldest(&self) -> Result> { + if let Some(track) = self.playlist.remove_oldest().await { + // Convert Track to Item and return + Ok(Some(item)) + } else { + Ok(None) + } + } + + async fn update_id(&self) -> u32 { + self.playlist.update_id().await + } + + async fn last_change(&self) -> Option { + Some(self.playlist.last_change().await) + } + + async fn get_items(&self, offset: usize, count: usize) -> Result> { + let tracks = self.playlist.get_items(offset, count).await; + // Convert tracks to Items... + Ok(items) } } ``` -### Using a music source +### Using a Music Source ```rust use pmosource::MusicSource; -use pmoparadise::RadioParadiseSource; -use pmoqobuz::QobuzSource; -let rp = RadioParadiseSource; -let qobuz = QobuzSource; +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let source = MyRadioSource::new("http://localhost:8080"); -println!("Source: {} ({})", rp.name(), rp.id()); -println!("Image size: {} bytes", rp.default_image().len()); + // Get source info + println!("Source: {}", source.name()); + println!("ID: {}", source.id()); + + // Get root container for ContentDirectory + let root = source.root_container().await?; + println!("Root: {} ({})", root.title, root.id); + + // Browse items + let result = source.browse(&root.id).await?; + for item in result.items() { + println!("Track: {}", item.title); + } + + // Resolve audio URI + let uri = source.resolve_uri("track-123").await?; + println!("Stream from: {}", uri); + + // Track changes + println!("Update ID: {}", source.update_id().await); + + Ok(()) +} ``` -## Image Format +## Trait Methods -All default images should be: -- **Format**: WebP -- **Dimensions**: 300x300 pixels (square) -- **Quality**: 85 (good balance between size and quality) -- **Location**: `/assets/default.webp` +### Basic Information -### Converting images +- `name() -> &str`: Human-readable name +- `id() -> &str`: Unique identifier (e.g., "radio-paradise") +- `default_image() -> &[u8]`: Embedded WebP logo (300x300px) +- `default_image_mime_type() -> &str`: MIME type (default: "image/webp") -Use the provided Python script or similar tool: +### ContentDirectory Navigation -```python -from PIL import Image +- `root_container() -> Container`: Root container for UPnP ContentDirectory +- `browse(object_id: &str) -> BrowseResult`: Browse containers/items +- `resolve_uri(object_id: &str) -> String`: Get audio URI (cached or original) -def convert_to_webp(input_path, output_path, size=300): - img = Image.open(input_path) +### FIFO Support (Dynamic Sources) - # Convert to RGB if necessary - if img.mode not in ('RGB', 'RGBA'): - img = img.convert('RGB') +- `supports_fifo() -> bool`: Whether this source uses a FIFO +- `append_track(track: Item)`: Add track to FIFO (auto-removes oldest if full) +- `remove_oldest() -> Option`: Remove oldest track from FIFO - # Make it square (center crop) - width, height = img.size - if width != height: - min_dim = min(width, height) - left = (width - min_dim) // 2 - top = (height - min_dim) // 2 - right = left + min_dim - bottom = top + min_dim - img = img.crop((left, top, right, bottom)) +### Change Tracking - # Resize to target size - img = img.resize((size, size), Image.Resampling.LANCZOS) +- `update_id() -> u32`: Increments on each change (for UPnP notifications) +- `last_change() -> Option`: Timestamp of last modification - # Save as WebP - img.save(output_path, 'WEBP', quality=85, method=6) +### Pagination & Search + +- `get_items(offset: usize, count: usize) -> Vec`: Paginated browsing +- `search(query: &str) -> BrowseResult`: Search (optional, default: not supported) + +## Integration with PMOMusic Ecosystem + +### With pmoplaylist + +Sources that support FIFO (radios, streaming services) use `pmoplaylist::FifoPlaylist` to manage dynamic track lists: + +```rust +use pmoplaylist::{FifoPlaylist, Track}; + +let playlist = FifoPlaylist::new( + "my-radio".to_string(), + "My Radio".to_string(), + 50, // capacity + DEFAULT_IMAGE, +); + +// Add tracks +playlist.append_track(Track::new("id", "title", "uri")).await; + +// Tracks automatically removed when capacity reached ``` -## Current Implementations +### With pmoaudiocache -- **pmoparadise**: Radio Paradise -- **pmoqobuz**: Qobuz +When the `cache` feature is enabled, sources can integrate with `pmoaudiocache` to: +- Cache audio files locally (with FLAC conversion) +- Serve from local cache instead of re-streaming +- Extract and merge metadata -## Future Enhancements +```rust +// Resolve URI checks cache first +async fn resolve_uri(&self, object_id: &str) -> Result { + if let Some(cached_pk) = self.get_cached_pk(object_id).await { + Ok(format!("{}/audio/cache/{}", self.cache_base_url, cached_pk)) + } else { + Ok(self.get_original_uri(object_id)) + } +} +``` -The `MusicSource` trait can be extended with additional methods such as: +### With pmocovers -- Authentication status -- Available quality levels -- Streaming capabilities -- Search functionality -- Playlist management -- And more... +When the `cache` feature is enabled, sources can integrate with `pmocovers` to: +- Cache album art locally (with WebP conversion) +- Generate multiple size variants +- Serve optimized images + +### With pmodidl + +All sources use `pmodidl` for DIDL-Lite generation compatible with UPnP/DLNA. + +## Examples + +### Radio Paradise + +See [examples/radio_paradise.rs](examples/radio_paradise.rs) for a complete implementation of a streaming radio source with: +- FIFO management using `pmoplaylist` +- Simulated cache integration +- Full DIDL-Lite export +- Change tracking + +Run the example: + +```bash +cargo run --example radio_paradise +``` + +## Design Patterns + +### Static Sources (Albums, Local Playlists) + +```rust +impl MusicSource for LocalAlbum { + fn supports_fifo(&self) -> bool { + false // Static content + } + + async fn append_track(&self, _: Item) -> Result<()> { + Err(MusicSourceError::FifoNotSupported) + } + + async fn update_id(&self) -> u32 { + 0 // Never changes + } +} +``` + +### Dynamic Sources (Radios, Streaming Services) + +```rust +impl MusicSource for RadioSource { + fn supports_fifo(&self) -> bool { + true // Dynamic content + } + + async fn append_track(&self, track: Item) -> Result<()> { + // Add to pmoplaylist::FifoPlaylist + self.playlist.append_track(converted_track).await; + Ok(()) + } + + async fn update_id(&self) -> u32 { + self.playlist.update_id().await + } +} +``` + +## Thread Safety + +All `MusicSource` implementations must be `Send + Sync` for use in async servers. ## License diff --git a/pmosource/assets/radio-paradise.webp b/pmosource/assets/radio-paradise.webp new file mode 100644 index 00000000..014210b1 Binary files /dev/null and b/pmosource/assets/radio-paradise.webp differ diff --git a/pmosource/examples/README.md b/pmosource/examples/README.md new file mode 100644 index 00000000..3896520f --- /dev/null +++ b/pmosource/examples/README.md @@ -0,0 +1,270 @@ +# PMOSource Examples + +This directory contains example implementations of the `MusicSource` trait. + +## Available Examples + +### radio_paradise.rs + +A complete implementation of a streaming radio source demonstrating: + +- **FIFO Management**: Using `pmoplaylist::FifoPlaylist` for dynamic track management +- **Cache Integration**: Simulated integration with `pmoaudiocache` and `pmocovers` +- **DIDL-Lite Export**: Proper conversion between `pmoplaylist::Track` and `pmodidl::Item` +- **Change Tracking**: `update_id` and `last_change` for UPnP notifications +- **URI Resolution**: Dynamic URI resolution with cache support +- **Pagination**: Efficient browsing with `get_items(offset, count)` + +#### Running the Example + +```bash +cargo run --example radio_paradise +``` + +#### Expected Output + +``` +Radio Paradise Source Example +============================== + +Source: Radio Paradise +ID: radio-paradise +Supports FIFO: true +Default image size: 9774 bytes + +Adding sample tracks... +Added 3 tracks + +Root Container: + ID: radio-paradise + Title: Radio Paradise + Child Count: Some("3") + +Browsing tracks: + - Wish You Were Here by Pink Floyd (Wish You Were Here) + - Bohemian Rhapsody by Queen (A Night at the Opera) + - Hotel California by Eagles (Hotel California) + +Resolving URIs: + rp-001: http://stream.radioparadise.com/rp-001.mp3 + rp-002: http://stream.radioparadise.com/rp-002.mp3 + rp-003: http://stream.radioparadise.com/rp-003.mp3 + +Change Tracking: + Update ID: 3 + Last Change: SystemTime { ... } + +Simulating cache for rp-001... + Cached URI: http://localhost:8080/audio/cache/cached-abc123 + +Pagination (get items 1-2): + - Bohemian Rhapsody + - Hotel California + +Removing oldest track... + Removed: Wish You Were Here + New Update ID: 4 + +Browsing after removal: + Tracks remaining: 2 + - Bohemian Rhapsody + - Hotel California +``` + +## Creating Your Own Source + +### 1. Define the Source Structure + +```rust +use pmosource::{async_trait, MusicSource, BrowseResult, Result}; +use pmodidl::{Container, Item}; +use pmoplaylist::FifoPlaylist; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Clone)] +pub struct MySource { + inner: Arc, +} + +struct MySourceInner { + // For dynamic sources: + playlist: FifoPlaylist, + + // For static sources: + // items: Vec, + + // Other fields as needed +} +``` + +### 2. Implement Basic Information + +```rust +#[async_trait] +impl MusicSource for MySource { + fn name(&self) -> &str { + "My Source Name" + } + + fn id(&self) -> &str { + "my-source" + } + + fn default_image(&self) -> &[u8] { + include_bytes!("../assets/my-source.webp") + } +} +``` + +### 3. Implement ContentDirectory Methods + +```rust + async fn root_container(&self) -> Result { + Ok(Container { + id: self.id().to_string(), + parent_id: "0".to_string(), + title: self.name().to_string(), + class: "object.container.playlistContainer".to_string(), + child_count: Some("0".to_string()), + containers: vec![], + items: vec![], + }) + } + + async fn browse(&self, object_id: &str) -> Result { + // Return items for this container + Ok(BrowseResult::Items(vec![])) + } + + async fn resolve_uri(&self, object_id: &str) -> Result { + // Return URI for track + Ok(format!("http://example.com/{}", object_id)) + } +``` + +### 4. Implement FIFO Methods (if applicable) + +```rust + fn supports_fifo(&self) -> bool { + true // or false for static sources + } + + async fn append_track(&self, track: Item) -> Result<()> { + // For dynamic sources: convert and add to playlist + // For static sources: return FifoNotSupported error + Ok(()) + } + + async fn remove_oldest(&self) -> Result> { + // For dynamic sources: remove from playlist + // For static sources: return FifoNotSupported error + Ok(None) + } +``` + +### 5. Implement Change Tracking + +```rust + async fn update_id(&self) -> u32 { + // For dynamic sources: delegate to playlist + // For static sources: return 0 + 0 + } + + async fn last_change(&self) -> Option { + // Return timestamp of last modification + None + } +``` + +### 6. Implement Pagination + +```rust + async fn get_items(&self, offset: usize, count: usize) -> Result> { + // Return paginated items + Ok(vec![]) + } +``` + +### 7. Implement Search (optional) + +```rust + async fn search(&self, query: &str) -> Result { + // If search is not supported: + Err(pmosource::MusicSourceError::SearchNotSupported) + + // If search is supported: + // let results = self.search_items(query)?; + // Ok(BrowseResult::Items(results)) + } +``` + +## Best Practices + +### Thread Safety + +Always use `Arc>` for mutable state: + +```rust +use std::sync::Arc; +use tokio::sync::RwLock; + +struct MySourceInner { + state: RwLock>, +} +``` + +### Error Handling + +Use appropriate error types: + +```rust +if object_id_not_found { + return Err(MusicSourceError::ObjectNotFound(object_id.to_string())); +} +``` + +### Manual Debug Implementation + +If your source contains non-Debug types, implement Debug manually: + +```rust +impl std::fmt::Debug for MySource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MySource") + .field("name", &self.name()) + .finish() + } +} +``` + +### Testing + +Create comprehensive tests: + +```rust +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let source = MySource::new(); + + // Test basic info + println!("Source: {}", source.name()); + + // Test browsing + let result = source.browse("root").await?; + println!("Items: {}", result.count()); + + // Test URI resolution + let uri = source.resolve_uri("track-1").await?; + println!("URI: {}", uri); + + Ok(()) +} +``` + +## Further Reading + +- [Main README](../README.md): Overview and quick start +- [ARCHITECTURE.md](../ARCHITECTURE.md): Detailed architecture documentation +- [CHANGELOG.md](../CHANGELOG.md): Version history and changes diff --git a/pmosource/examples/radio_paradise.rs b/pmosource/examples/radio_paradise.rs new file mode 100644 index 00000000..54794a90 --- /dev/null +++ b/pmosource/examples/radio_paradise.rs @@ -0,0 +1,466 @@ +//! # Radio Paradise Example +//! +//! This example demonstrates how to implement a concrete `MusicSource` using +//! Radio Paradise as a streaming radio source with FIFO support. +//! +//! ## Features +//! +//! - **FIFO Playlist**: Uses `pmoplaylist::FifoPlaylist` for dynamic track management +//! - **Cache Integration**: Resolves URIs via `pmoaudiocache` and `pmocovers` (when enabled) +//! - **DIDL-Lite Export**: Generates proper UPnP-compatible containers and items +//! - **Change Tracking**: Tracks `update_id` and `last_change` for notifications +//! +//! ## Usage +//! +//! ```bash +//! cargo run --example radio_paradise +//! ``` + +use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result}; +use pmodidl::{Container, Item, Resource}; +use pmoplaylist::{FifoPlaylist, Track}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Default image for Radio Paradise (embedded WebP) +const RADIO_PARADISE_IMAGE: &[u8] = include_bytes!("../assets/radio-paradise.webp"); + +/// Default capacity for the FIFO (number of recent tracks to keep) +const DEFAULT_FIFO_CAPACITY: usize = 50; + +/// Radio Paradise music source +/// +/// This is a concrete implementation of `MusicSource` for Radio Paradise, +/// demonstrating how to: +/// - Use `pmoplaylist::FifoPlaylist` for dynamic track management +/// - Integrate with caches for URI resolution +/// - Implement ContentDirectory browsing +/// - Track changes via `update_id` and `last_change` +#[derive(Clone)] +pub struct RadioParadise { + inner: Arc, +} + +struct RadioParadiseInner { + /// FIFO playlist managed by pmoplaylist + playlist: FifoPlaylist, + + /// Cache server base URL (for URI resolution) + cache_base_url: String, + + /// Track metadata cache (object_id -> original_uri, cached_pk) + track_cache: RwLock)>>, +} + +// Manual Debug implementation since FifoPlaylist doesn't derive Debug +impl std::fmt::Debug for RadioParadise { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RadioParadise") + .field("cache_base_url", &self.inner.cache_base_url) + .finish() + } +} + +impl RadioParadise { + /// Create a new Radio Paradise source + /// + /// # Arguments + /// + /// * `cache_base_url` - Base URL for the cache server (e.g., "http://localhost:8080") + /// * `fifo_capacity` - Maximum number of tracks in the FIFO + /// + /// # Examples + /// + /// ``` + /// use pmosource::RadioParadise; + /// + /// let source = RadioParadise::new("http://localhost:8080", 50); + /// ``` + pub fn new(cache_base_url: impl Into, fifo_capacity: usize) -> Self { + let playlist = FifoPlaylist::new( + "radio-paradise".to_string(), + "Radio Paradise".to_string(), + fifo_capacity, + RADIO_PARADISE_IMAGE, + ); + + Self { + inner: Arc::new(RadioParadiseInner { + playlist, + cache_base_url: cache_base_url.into(), + track_cache: RwLock::new(HashMap::new()), + }), + } + } + + /// Create with default settings + pub fn new_default(cache_base_url: impl Into) -> Self { + Self::new(cache_base_url, DEFAULT_FIFO_CAPACITY) + } + + /// Add a track to the Radio Paradise FIFO from raw data + /// + /// This simulates receiving a new track from the Radio Paradise API. + /// + /// # Arguments + /// + /// * `id` - Unique track ID + /// * `title` - Track title + /// * `artist` - Artist name + /// * `album` - Album name + /// * `uri` - Original streaming URI + /// * `image_url` - URL for cover art (optional) + /// * `duration` - Track duration in seconds (optional) + pub async fn add_track( + &self, + id: String, + title: String, + artist: Option, + album: Option, + uri: String, + image_url: Option, + duration: Option, + ) -> Result<()> { + // Store the original URI for later resolution + { + let mut cache = self.inner.track_cache.write().await; + cache.insert(id.clone(), (uri.clone(), None)); + } + + // Create a Track for pmoplaylist + let mut track = Track::new(id, title, uri); + + if let Some(artist) = artist { + track = track.with_artist(artist); + } + + if let Some(album) = album { + track = track.with_album(album); + } + + if let Some(duration) = duration { + track = track.with_duration(duration); + } + + if let Some(image) = image_url { + track = track.with_image(image); + } + + // Add to the FIFO (automatically handles capacity) + self.inner.playlist.append_track(track).await; + + Ok(()) + } + + /// Simulate caching a track + /// + /// In a real implementation, this would interact with `pmoaudiocache` + /// to download and cache the track, then store the cache key. + /// + /// # Arguments + /// + /// * `track_id` - The track ID to cache + /// * `cache_pk` - The cache primary key returned by pmoaudiocache + pub async fn cache_track(&self, track_id: &str, cache_pk: String) -> Result<()> { + let mut cache = self.inner.track_cache.write().await; + + if let Some((_original_uri, cached_pk)) = cache.get_mut(track_id) { + *cached_pk = Some(cache_pk); + Ok(()) + } else { + Err(MusicSourceError::ObjectNotFound(track_id.to_string())) + } + } + + /// Convert pmoplaylist::Track to pmodidl::Item + fn track_to_item(&self, track: &Track) -> Item { + // Format duration + let duration_str = track.duration.map(|d| { + let hours = d / 3600; + let minutes = (d % 3600) / 60; + let seconds = d % 60; + format!("{}:{:02}:{:02}", hours, minutes, seconds) + }); + + // Create resource + let resource = Resource { + protocol_info: "http-get:*:audio/*:*".to_string(), + bits_per_sample: None, + sample_frequency: None, + nr_audio_channels: None, + duration: duration_str, + url: track.uri.clone(), + }; + + Item { + id: track.id.clone(), + parent_id: "radio-paradise".to_string(), + restricted: Some("1".to_string()), + title: track.title.clone(), + creator: track.artist.clone(), + class: "object.item.audioItem.musicTrack".to_string(), + artist: track.artist.clone(), + album: track.album.clone(), + genre: None, + album_art: track.image.clone(), + album_art_pk: None, + date: None, + original_track_number: None, + resources: vec![resource], + descriptions: vec![], + } + } +} + +#[async_trait] +impl MusicSource for RadioParadise { + fn name(&self) -> &str { + "Radio Paradise" + } + + fn id(&self) -> &str { + "radio-paradise" + } + + fn default_image(&self) -> &[u8] { + RADIO_PARADISE_IMAGE + } + + async fn root_container(&self) -> Result { + Ok(self.inner.playlist.as_container().await) + } + + async fn browse(&self, object_id: &str) -> Result { + // For Radio Paradise, browsing the root returns all tracks in the FIFO + if object_id == "radio-paradise" || object_id == "0" { + let tracks = self.inner.playlist.get_items(0, 1000).await; + let items: Vec = tracks.iter().map(|t| self.track_to_item(t)).collect(); + Ok(BrowseResult::Items(items)) + } else { + Err(MusicSourceError::ObjectNotFound(object_id.to_string())) + } + } + + async fn resolve_uri(&self, object_id: &str) -> Result { + let cache = self.inner.track_cache.read().await; + + if let Some((original_uri, cached_pk)) = cache.get(object_id) { + // If cached, return the cached URI + if let Some(pk) = cached_pk { + Ok(format!("{}/audio/cache/{}", self.inner.cache_base_url, pk)) + } else { + // Not cached yet, return original URI + Ok(original_uri.clone()) + } + } else { + Err(MusicSourceError::ObjectNotFound(object_id.to_string())) + } + } + + fn supports_fifo(&self) -> bool { + true + } + + async fn append_track(&self, track: Item) -> Result<()> { + // Convert Item back to Track + let duration = track + .resources + .first() + .and_then(|r| r.duration.as_ref()) + .and_then(|d| { + let parts: Vec<&str> = d.split(':').collect(); + if parts.len() == 3 { + let h: u32 = parts[0].parse().ok()?; + let m: u32 = parts[1].parse().ok()?; + let s: u32 = parts[2].parse().ok()?; + Some(h * 3600 + m * 60 + s) + } else { + None + } + }); + + let uri = track + .resources + .first() + .map(|r| r.url.clone()) + .unwrap_or_default(); + + let mut pmo_track = Track::new(track.id.clone(), track.title.clone(), uri.clone()); + + if let Some(artist) = track.artist { + pmo_track = pmo_track.with_artist(artist); + } + + if let Some(album) = track.album { + pmo_track = pmo_track.with_album(album); + } + + if let Some(dur) = duration { + pmo_track = pmo_track.with_duration(dur); + } + + if let Some(img) = track.album_art { + pmo_track = pmo_track.with_image(img); + } + + // Store in cache + { + let mut cache = self.inner.track_cache.write().await; + cache.insert(track.id.clone(), (uri, None)); + } + + self.inner.playlist.append_track(pmo_track).await; + Ok(()) + } + + async fn remove_oldest(&self) -> Result> { + if let Some(track) = self.inner.playlist.remove_oldest().await { + // Remove from cache + { + let mut cache = self.inner.track_cache.write().await; + cache.remove(&track.id); + } + + Ok(Some(self.track_to_item(&track))) + } else { + Ok(None) + } + } + + async fn update_id(&self) -> u32 { + self.inner.playlist.update_id().await + } + + async fn last_change(&self) -> Option { + Some(self.inner.playlist.last_change().await) + } + + async fn get_items(&self, offset: usize, count: usize) -> Result> { + let tracks = self.inner.playlist.get_items(offset, count).await; + Ok(tracks.iter().map(|t| self.track_to_item(t)).collect()) + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + println!("Radio Paradise Source Example"); + println!("==============================\n"); + + // Create the source + let source = RadioParadise::new_default("http://localhost:8080"); + + println!("Source: {}", source.name()); + println!("ID: {}", source.id()); + println!("Supports FIFO: {}", source.supports_fifo()); + println!("Default image size: {} bytes\n", source.default_image().len()); + + // Add some sample tracks + println!("Adding sample tracks..."); + + source + .add_track( + "rp-001".to_string(), + "Wish You Were Here".to_string(), + Some("Pink Floyd".to_string()), + Some("Wish You Were Here".to_string()), + "http://stream.radioparadise.com/rp-001.mp3".to_string(), + Some("http://img.radioparadise.com/covers/l/001.jpg".to_string()), + Some(334), + ) + .await?; + + source + .add_track( + "rp-002".to_string(), + "Bohemian Rhapsody".to_string(), + Some("Queen".to_string()), + Some("A Night at the Opera".to_string()), + "http://stream.radioparadise.com/rp-002.mp3".to_string(), + Some("http://img.radioparadise.com/covers/l/002.jpg".to_string()), + Some(354), + ) + .await?; + + source + .add_track( + "rp-003".to_string(), + "Hotel California".to_string(), + Some("Eagles".to_string()), + Some("Hotel California".to_string()), + "http://stream.radioparadise.com/rp-003.mp3".to_string(), + Some("http://img.radioparadise.com/covers/l/003.jpg".to_string()), + Some(391), + ) + .await?; + + println!("Added 3 tracks\n"); + + // Get root container + println!("Root Container:"); + let root = source.root_container().await?; + println!(" ID: {}", root.id); + println!(" Title: {}", root.title); + println!(" Child Count: {:?}\n", root.child_count); + + // Browse the source + println!("Browsing tracks:"); + let result = source.browse("radio-paradise").await?; + for item in result.items() { + println!( + " - {} by {} ({})", + item.title, + item.artist.as_deref().unwrap_or("Unknown"), + item.album.as_deref().unwrap_or("Unknown Album") + ); + } + println!(); + + // Resolve URIs + println!("Resolving URIs:"); + for item in result.items() { + let uri = source.resolve_uri(&item.id).await?; + println!(" {}: {}", item.id, uri); + } + println!(); + + // Track changes + println!("Change Tracking:"); + println!(" Update ID: {}", source.update_id().await); + println!( + " Last Change: {:?}\n", + source.last_change().await.unwrap() + ); + + // Simulate caching a track + println!("Simulating cache for rp-001..."); + source.cache_track("rp-001", "cached-abc123".to_string()).await?; + + let cached_uri = source.resolve_uri("rp-001").await?; + println!(" Cached URI: {}\n", cached_uri); + + // Pagination + println!("Pagination (get items 1-2):"); + let items = source.get_items(1, 2).await?; + for item in items { + println!(" - {}", item.title); + } + println!(); + + // Remove oldest track + println!("Removing oldest track..."); + if let Some(removed) = source.remove_oldest().await? { + println!(" Removed: {}", removed.title); + } + println!(" New Update ID: {}\n", source.update_id().await); + + // Browse again to see the change + println!("Browsing after removal:"); + let result = source.browse("radio-paradise").await?; + println!(" Tracks remaining: {}", result.count()); + for item in result.items() { + println!(" - {}", item.title); + } + + Ok(()) +} diff --git a/pmosource/examples/show_sources.rs b/pmosource/examples/show_sources.rs deleted file mode 100644 index 02e27321..00000000 --- a/pmosource/examples/show_sources.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! Example showing how to use the MusicSource trait -//! -//! This example demonstrates accessing source information and images -//! from different music sources (requires pmoparadise and pmoqobuz to be compiled). - -use pmosource::{MusicSource, DEFAULT_IMAGE_SIZE}; - -// Mock implementations for demonstration -#[derive(Debug)] -struct RadioParadiseSource; - -impl MusicSource for RadioParadiseSource { - fn name(&self) -> &str { - "Radio Paradise" - } - - fn id(&self) -> &str { - "radio-paradise" - } - - fn default_image(&self) -> &[u8] { - // This would normally be: include_bytes!("../../pmoparadise/assets/default.webp") - // For this example, we return an empty slice - &[] - } -} - -#[derive(Debug)] -struct QobuzSource; - -impl MusicSource for QobuzSource { - fn name(&self) -> &str { - "Qobuz" - } - - fn id(&self) -> &str { - "qobuz" - } - - fn default_image(&self) -> &[u8] { - // This would normally be: include_bytes!("../../pmoqobuz/assets/default.webp") - // For this example, we return an empty slice - &[] - } -} - -fn main() { - println!("PMOMusic Sources\n"); - println!("Standard image size: {}x{} pixels\n", DEFAULT_IMAGE_SIZE, DEFAULT_IMAGE_SIZE); - - let sources: Vec> = vec![ - Box::new(RadioParadiseSource), - Box::new(QobuzSource), - ]; - - for source in sources { - println!("Source: {}", source.name()); - println!(" ID: {}", source.id()); - println!(" Image MIME: {}", source.default_image_mime_type()); - println!(" Image size: {} bytes", source.default_image().len()); - println!(); - } - - println!("Note: In a real implementation, the images would be embedded in the binary"); - println!(" and would be approximately 3-10 KB each in WebP format."); -} diff --git a/pmosource/src/lib.rs b/pmosource/src/lib.rs index 635134a8..227539f0 100644 --- a/pmosource/src/lib.rs +++ b/pmosource/src/lib.rs @@ -4,8 +4,22 @@ //! //! This crate provides the foundational abstractions for different music sources //! in the PMOMusic ecosystem, such as Radio Paradise, Qobuz, etc. +//! +//! ## Features +//! +//! - **FIFO Support**: Dynamic audio sources using `pmoplaylist` for streaming. +//! - **Container/Item Navigation**: Browse and search using DIDL-Lite format (`pmodidl`). +//! - **Cache Integration**: Automatic URI resolution with `pmoaudiocache` and `pmocovers`. +//! - **Change Tracking**: `update_id` and `last_change` for UPnP notifications. +//! - **Send + Sync**: Ready for async servers. +//! +//! ## Usage +//! +//! See the [examples/radio_paradise.rs](../examples/radio_paradise.rs) for a complete implementation. +use pmodidl::{Container, Item}; use std::fmt::Debug; +use std::time::SystemTime; /// Standard size for default images (300x300 pixels) pub const DEFAULT_IMAGE_SIZE: u32 = 300; @@ -21,19 +35,174 @@ pub enum MusicSourceError { #[error("Source not available: {0}")] SourceUnavailable(String), + + #[error("Object not found: {0}")] + ObjectNotFound(String), + + #[error("Browse error: {0}")] + BrowseError(String), + + #[error("Search not supported")] + SearchNotSupported, + + #[error("FIFO not supported")] + FifoNotSupported, + + #[error("Cache error: {0}")] + CacheError(String), + + #[error("URI resolution failed: {0}")] + UriResolutionError(String), } /// Result type for music source operations pub type Result = std::result::Result; +/// Result of a browse operation +#[derive(Debug, Clone)] +pub enum BrowseResult { + /// List of sub-containers only + Containers(Vec), + + /// List of items only + Items(Vec), + + /// Mixed: both containers and items + Mixed { + containers: Vec, + items: Vec, + }, +} + +impl BrowseResult { + /// Returns the total count of objects (containers + items) + pub fn count(&self) -> usize { + match self { + BrowseResult::Containers(c) => c.len(), + BrowseResult::Items(i) => i.len(), + BrowseResult::Mixed { containers, items } => containers.len() + items.len(), + } + } + + /// Returns all containers + pub fn containers(&self) -> &[Container] { + match self { + BrowseResult::Containers(c) => c, + BrowseResult::Items(_) => &[], + BrowseResult::Mixed { containers, .. } => containers, + } + } + + /// Returns all items + pub fn items(&self) -> &[Item] { + match self { + BrowseResult::Containers(_) => &[], + BrowseResult::Items(i) => i, + BrowseResult::Mixed { items, .. } => items, + } + } +} + /// Main trait for music sources /// /// This trait defines the common interface that all music sources must implement. /// It provides methods for: /// - Getting the source name and identification /// - Retrieving default images/logos -/// - Other common operations (to be extended) +/// - Browsing containers and items (ContentDirectory) +/// - Resolving audio URIs (using caches when available) +/// - Managing FIFO playlists for dynamic sources +/// - Tracking changes via `update_id` and `last_change` +/// +/// # Thread Safety +/// +/// All implementations must be `Send + Sync` for use in async servers. +/// +/// # Examples +/// +/// ```rust,no_run +/// use pmosource::{MusicSource, BrowseResult, Result}; +/// use pmodidl::{Container, Item}; +/// use std::time::SystemTime; +/// +/// #[derive(Debug)] +/// struct RadioParadise { +/// // implementation details +/// } +/// +/// #[async_trait::async_trait] +/// impl MusicSource for RadioParadise { +/// fn name(&self) -> &str { +/// "Radio Paradise" +/// } +/// +/// fn id(&self) -> &str { +/// "radio-paradise" +/// } +/// +/// fn default_image(&self) -> &[u8] { +/// // WebP image bytes +/// &[] +/// } +/// +/// async fn root_container(&self) -> Result { +/// Ok(Container { +/// id: "0".to_string(), +/// parent_id: "-1".to_string(), +/// restricted: Some("1".to_string()), +/// child_count: Some("0".to_string()), +/// title: "Radio Paradise".to_string(), +/// class: "object.container".to_string(), +/// containers: vec![], +/// items: vec![], +/// }) +/// } +/// +/// async fn browse(&self, object_id: &str) -> Result { +/// // Browse implementation +/// Ok(BrowseResult::Items(vec![])) +/// } +/// +/// async fn resolve_uri(&self, object_id: &str) -> Result { +/// // Return cached URI or original URI +/// Ok("http://example.com/track.mp3".to_string()) +/// } +/// +/// fn supports_fifo(&self) -> bool { +/// true +/// } +/// +/// async fn append_track(&self, track: Item) -> Result<()> { +/// // Add track to FIFO +/// Ok(()) +/// } +/// +/// async fn remove_oldest(&self) -> Result> { +/// // Remove oldest track +/// Ok(None) +/// } +/// +/// async fn update_id(&self) -> u32 { +/// 0 +/// } +/// +/// async fn last_change(&self) -> Option { +/// None +/// } +/// +/// async fn get_items(&self, offset: usize, count: usize) -> Result> { +/// Ok(vec![]) +/// } +/// +/// async fn search(&self, query: &str) -> Result { +/// Err(pmosource::MusicSourceError::SearchNotSupported) +/// } +/// } +/// ``` +#[async_trait::async_trait] pub trait MusicSource: Debug + Send + Sync { + // ============= Basic Information ============= + /// Returns the human-readable name of the music source /// /// # Examples @@ -79,8 +248,212 @@ pub trait MusicSource: Debug + Send + Sync { fn default_image_mime_type(&self) -> &str { "image/webp" } + + // ============= ContentDirectory Navigation ============= + + /// Returns the root container for this source + /// + /// This container is exposed at the top level of the ContentDirectory. + /// Its `id` should be unique across all sources, typically the source id. + /// + /// # Returns + /// + /// A `Container` representing the root of this source's hierarchy. + /// + /// # Examples + /// + /// ```ignore + /// let root = source.root_container().await?; + /// assert_eq!(root.id, "radio-paradise"); + /// assert_eq!(root.title, "Radio Paradise"); + /// ``` + async fn root_container(&self) -> Result; + + /// Browse a container or item by its object_id + /// + /// Returns the children of the specified container, or an error if the + /// object doesn't exist or isn't browsable. + /// + /// # Arguments + /// + /// * `object_id` - The ID of the container to browse + /// + /// # Returns + /// + /// A `BrowseResult` containing sub-containers and/or items. + /// + /// # Examples + /// + /// ```ignore + /// let result = source.browse("radio-paradise").await?; + /// for item in result.items() { + /// println!("Track: {}", item.title); + /// } + /// ``` + async fn browse(&self, object_id: &str) -> Result; + + /// Resolve the actual URI for a track + /// + /// This method should return the URI that can be used to stream/download + /// the audio. If the track is cached (via `pmoaudiocache`), return the + /// cached URI. Otherwise, return the original URI. + /// + /// # Arguments + /// + /// * `object_id` - The ID of the track to resolve + /// + /// # Returns + /// + /// The HTTP URI to access the audio file. + /// + /// # Examples + /// + /// ```ignore + /// let uri = source.resolve_uri("track-123").await?; + /// // Returns something like: "http://localhost:8080/cache/audio/abc123" + /// // or the original URL if not cached + /// ``` + async fn resolve_uri(&self, object_id: &str) -> Result; + + // ============= FIFO Support ============= + + /// Indicates whether this source supports FIFO operations + /// + /// Dynamic sources (like radios) typically return `true`, while + /// static sources (like albums) return `false`. + /// + /// # Returns + /// + /// `true` if the source supports FIFO operations, `false` otherwise. + fn supports_fifo(&self) -> bool; + + /// Append a track to the FIFO + /// + /// This method is only applicable for sources that support FIFO. + /// It adds the track to the end of the queue, potentially removing + /// the oldest track if capacity is reached. + /// + /// Updates `update_id` and `last_change`. + /// + /// # Arguments + /// + /// * `track` - The `Item` to add to the FIFO + /// + /// # Errors + /// + /// Returns `MusicSourceError::FifoNotSupported` if the source doesn't + /// support FIFO operations. + /// + /// # Examples + /// + /// ```ignore + /// let track = Item { + /// id: "track-1".to_string(), + /// title: "Song Title".to_string(), + /// // ... other fields + /// }; + /// source.append_track(track).await?; + /// ``` + async fn append_track(&self, track: Item) -> Result<()>; + + /// Remove the oldest track from the FIFO + /// + /// This method is only applicable for sources that support FIFO. + /// Updates `update_id` and `last_change` if a track is removed. + /// + /// # Returns + /// + /// The removed track, or `None` if the FIFO is empty. + /// + /// # Errors + /// + /// Returns `MusicSourceError::FifoNotSupported` if the source doesn't + /// support FIFO operations. + async fn remove_oldest(&self) -> Result>; + + // ============= Change Tracking ============= + + /// Returns the current update_id + /// + /// This counter is incremented each time the source's content changes + /// (track added, removed, metadata updated, etc.). It's used by UPnP + /// Control Points to detect changes and refresh their view. + /// + /// # Returns + /// + /// The current update_id value. Wraps around on overflow. + async fn update_id(&self) -> u32; + + /// Returns the timestamp of the last change + /// + /// This is used to notify MediaRenderers and Control Points about + /// content updates. + /// + /// # Returns + /// + /// The `SystemTime` of the last modification, or `None` if never modified. + async fn last_change(&self) -> Option; + + // ============= Pagination & Search ============= + + /// Get a paginated list of items + /// + /// This is useful for browsing large collections without loading + /// everything into memory. + /// + /// # Arguments + /// + /// * `offset` - Starting index (0-based) + /// * `count` - Maximum number of items to return + /// + /// # Returns + /// + /// A vector of `Item` objects, potentially empty if offset is out of range. + /// + /// # Examples + /// + /// ```ignore + /// // Get items 10-19 + /// let items = source.get_items(10, 10).await?; + /// ``` + async fn get_items(&self, offset: usize, count: usize) -> Result>; + + /// Search for tracks matching a query + /// + /// This is an optional feature. Sources that don't support search + /// should return `MusicSourceError::SearchNotSupported`. + /// + /// # Arguments + /// + /// * `query` - Search query string + /// + /// # Returns + /// + /// A `BrowseResult` containing matching items/containers. + /// + /// # Errors + /// + /// Returns `MusicSourceError::SearchNotSupported` if not implemented. + /// + /// # Examples + /// + /// ```ignore + /// let results = source.search("Pink Floyd").await?; + /// for item in results.items() { + /// println!("Found: {}", item.title); + /// } + /// ``` + async fn search(&self, query: &str) -> Result { + let _ = query; + Err(MusicSourceError::SearchNotSupported) + } } +// Re-export commonly used types +pub use async_trait::async_trait; +pub use pmodidl; +pub use pmoplaylist; + #[cfg(test)] mod tests { use super::*; @@ -88,6 +461,7 @@ mod tests { #[derive(Debug)] struct TestSource; + #[async_trait] impl MusicSource for TestSource { fn name(&self) -> &str { "Test Source" @@ -100,13 +474,112 @@ mod tests { fn default_image(&self) -> &[u8] { &[] } + + async fn root_container(&self) -> Result { + Ok(Container { + id: "test-source".to_string(), + parent_id: "0".to_string(), + restricted: Some("1".to_string()), + child_count: Some("0".to_string()), + title: "Test Source".to_string(), + class: "object.container".to_string(), + containers: vec![], + items: vec![], + }) + } + + async fn browse(&self, _object_id: &str) -> Result { + Ok(BrowseResult::Items(vec![])) + } + + async fn resolve_uri(&self, object_id: &str) -> Result { + Ok(format!("http://example.com/{}", object_id)) + } + + fn supports_fifo(&self) -> bool { + false + } + + async fn append_track(&self, _track: Item) -> Result<()> { + Err(MusicSourceError::FifoNotSupported) + } + + async fn remove_oldest(&self) -> Result> { + Err(MusicSourceError::FifoNotSupported) + } + + async fn update_id(&self) -> u32 { + 0 + } + + async fn last_change(&self) -> Option { + None + } + + async fn get_items(&self, _offset: usize, _count: usize) -> Result> { + Ok(vec![]) + } } - #[test] - fn test_music_source_trait() { + #[tokio::test] + async fn test_music_source_trait() { let source = TestSource; assert_eq!(source.name(), "Test Source"); assert_eq!(source.id(), "test-source"); assert_eq!(source.default_image_mime_type(), "image/webp"); + assert!(!source.supports_fifo()); + } + + #[tokio::test] + async fn test_root_container() { + let source = TestSource; + let root = source.root_container().await.unwrap(); + assert_eq!(root.id, "test-source"); + assert_eq!(root.title, "Test Source"); + } + + #[tokio::test] + async fn test_browse_result() { + let items = vec![]; + let result = BrowseResult::Items(items); + assert_eq!(result.count(), 0); + assert_eq!(result.items().len(), 0); + assert_eq!(result.containers().len(), 0); + } + + #[tokio::test] + async fn test_search_not_supported() { + let source = TestSource; + let result = source.search("test").await; + assert!(matches!(result, Err(MusicSourceError::SearchNotSupported))); + } + + #[tokio::test] + async fn test_fifo_not_supported() { + let source = TestSource; + + let item = Item { + id: "test-1".to_string(), + parent_id: "0".to_string(), + restricted: Some("1".to_string()), + title: "Test".to_string(), + creator: None, + class: "object.item.audioItem.musicTrack".to_string(), + artist: None, + album: None, + genre: None, + album_art: None, + album_art_pk: None, + date: None, + original_track_number: None, + resources: vec![], + descriptions: vec![], + }; + + let result = source.append_track(item).await; + assert!(matches!(result, Err(MusicSourceError::FifoNotSupported))); + + let result = source.remove_oldest().await; + assert!(matches!(result, Err(MusicSourceError::FifoNotSupported))); } }