Crée la crate pmoplaylist

This commit is contained in:
2025-10-16 22:13:00 +02:00
parent e0fbb11475
commit 5c06a0f0e9
25 changed files with 5482 additions and 189 deletions

20
Cargo.lock generated
View File

@@ -2353,7 +2353,10 @@ dependencies = [
"claxon", "claxon",
"futures", "futures",
"hound", "hound",
"pmoaudiocache",
"pmocovers",
"pmodidl", "pmodidl",
"pmoplaylist",
"pmoserver", "pmoserver",
"pmosource", "pmosource",
"pmoupnp", "pmoupnp",
@@ -2371,6 +2374,15 @@ dependencies = [
"wiremock", "wiremock",
] ]
[[package]]
name = "pmoplaylist"
version = "0.1.0"
dependencies = [
"pmodidl",
"serde",
"tokio",
]
[[package]] [[package]]
name = "pmoqobuz" name = "pmoqobuz"
version = "0.1.0" version = "0.1.0"
@@ -2424,8 +2436,14 @@ dependencies = [
name = "pmosource" name = "pmosource"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"image", "anyhow",
"async-trait",
"pmoaudiocache",
"pmocovers",
"pmodidl",
"pmoplaylist",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio",
] ]
[[package]] [[package]]

View File

@@ -1,3 +1,3 @@
[workspace] [workspace]
resolver = "3" 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"]

View File

@@ -48,6 +48,13 @@ uuid = { version = "1.18", optional = true }
# Common music source traits # Common music source traits
pmosource = { path = "../pmosource" } pmosource = { path = "../pmosource" }
# Playlist management for FIFO support
pmoplaylist = { path = "../pmoplaylist" }
# Cache support
pmocovers = { path = "../pmocovers", optional = true }
pmoaudiocache = { path = "../pmoaudiocache", optional = true }
[features] [features]
default = ["metadata-only"] default = ["metadata-only"]
# Mode métadonnées seules (pas de décodage FLAC) # 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"] logging = ["dep:tracing"]
# Active le media server UPnP # Active le media server UPnP
mediaserver = ["dep:pmoupnp", "dep:pmoserver", "dep:pmodidl", "dep:uuid"] mediaserver = ["dep:pmoupnp", "dep:pmoserver", "dep:pmodidl", "dep:uuid"]
# Active le cache d'images et audio
cache = ["dep:pmocovers", "dep:pmoaudiocache", "logging"]
[dev-dependencies] [dev-dependencies]
# Tests # Tests
@@ -65,6 +74,9 @@ tokio-test = "0.4"
wiremock = "0.6" wiremock = "0.6"
# Pour les exemples avec logging # Pour les exemples avec logging
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
# Pour l'exemple with_cache
pmocovers = { path = "../pmocovers" }
pmoaudiocache = { path = "../pmoaudiocache" }
[[example]] [[example]]
name = "now_playing" name = "now_playing"
@@ -83,3 +95,8 @@ required-features = ["per-track"]
name = "upnp_mediaserver" name = "upnp_mediaserver"
path = "examples/upnp_mediaserver.rs" path = "examples/upnp_mediaserver.rs"
required-features = ["mediaserver"] required-features = ["mediaserver"]
[[example]]
name = "with_cache"
path = "examples/with_cache.rs"
required-features = ["cache"]

View File

@@ -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<dyn std::error::Error>> {
// 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(())
}

View File

@@ -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<dyn std::error::Error>> {
//! // 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 //! ## Cargo Features
//! //!
//! - `default = ["metadata-only"]`: Standard metadata and streaming (no FLAC decoding) //! - `default = ["metadata-only"]`: Standard metadata and streaming (no FLAC decoding)
//! - `per-track`: Enable FLAC decoding and per-track extraction (adds `claxon`, `hound`, `tempfile`) //! - `per-track`: Enable FLAC decoding and per-track extraction (adds `claxon`, `hound`, `tempfile`)
//! - `logging`: Enable tracing logs for debugging //! - `logging`: Enable tracing logs for debugging
//! - `mediaserver`: Enable UPnP/DLNA Media Server (adds `pmoupnp`, `pmoserver`, `pmodidl`) //! - `mediaserver`: Enable UPnP/DLNA Media Server (adds `pmoupnp`, `pmoserver`, `pmodidl`)
//! - `cache`: Enable cover and audio caching support (adds `pmocovers`, `pmoaudiocache`, enables `logging`)
//! //!
//! ## See Also //! ## See Also
//! //!

View File

@@ -1,35 +1,374 @@
//! Music source implementation for Radio Paradise //! Music source implementation for Radio Paradise
//! //!
//! This module implements the [`pmosource::MusicSource`] trait 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) /// Default image for Radio Paradise (300x300 WebP, embedded in binary)
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); 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 /// This struct combines a [`RadioParadiseClient`] for API access with a FIFO playlist
/// standardized access to Radio Paradise's identification and branding. /// 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 /// # Examples
/// ///
/// ``` /// ```no_run
/// use pmoparadise::RadioParadiseSource; /// use pmoparadise::{RadioParadiseSource, RadioParadiseClient};
/// use pmosource::MusicSource; /// use pmosource::MusicSource;
/// ///
/// let source = RadioParadiseSource; /// #[tokio::main]
/// assert_eq!(source.name(), "Radio Paradise"); /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// assert_eq!(source.id(), "radio-paradise"); /// let client = RadioParadiseClient::new().await?;
/// let source = RadioParadiseSource::new(client, "http://localhost:8080", 50);
/// ///
/// // Get default image as WebP bytes /// println!("Source: {}", source.name());
/// let image_data = source.default_image(); /// println!("Supports FIFO: {}", source.supports_fifo());
/// assert!(image_data.len() > 0); ///
/// // Start streaming and the FIFO will be populated
/// Ok(())
/// }
/// ``` /// ```
#[derive(Debug, Clone, Copy, Default)] #[derive(Clone)]
pub struct RadioParadiseSource; pub struct RadioParadiseSource {
inner: Arc<RadioParadiseSourceInner>,
}
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<HashMap<String, TrackMetadata>>,
/// Cover image cache (optional)
#[cfg(feature = "cache")]
cover_cache: Option<Arc<CoverCache>>,
/// Audio cache (optional)
#[cfg(feature = "cache")]
audio_cache: Option<Arc<AudioCache>>,
}
#[derive(Debug, Clone)]
struct TrackMetadata {
original_uri: String,
cached_pk: Option<String>,
block: Arc<Block>,
song_index: usize,
#[cfg(feature = "cache")]
cached_audio_pk: Option<String>,
#[cfg(feature = "cache")]
cached_cover_pk: Option<String>,
}
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<String>,
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<String>) -> 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<String>,
fifo_capacity: usize,
cover_cache: Option<Arc<CoverCache>>,
audio_cache: Option<Arc<AudioCache>>,
) -> 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<Block>, 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 { impl MusicSource for RadioParadiseSource {
fn name(&self) -> &str { fn name(&self) -> &str {
"Radio Paradise" "Radio Paradise"
@@ -42,29 +381,166 @@ impl MusicSource for RadioParadiseSource {
fn default_image(&self) -> &[u8] { fn default_image(&self) -> &[u8] {
DEFAULT_IMAGE DEFAULT_IMAGE
} }
async fn root_container(&self) -> Result<Container> {
Ok(self.inner.playlist.as_container().await)
}
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
// 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<Item> = 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<String> {
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<Option<Item>> {
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<SystemTime> {
Some(self.inner.playlist.last_change().await)
}
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
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<BrowseResult> {
// Radio Paradise doesn't support search
Err(MusicSourceError::SearchNotSupported)
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[tokio::test]
fn test_source_info() { async fn test_source_info() {
let source = RadioParadiseSource; 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.name(), "Radio Paradise");
assert_eq!(source.id(), "radio-paradise"); assert_eq!(source.id(), "radio-paradise");
assert_eq!(source.default_image_mime_type(), "image/webp"); assert_eq!(source.default_image_mime_type(), "image/webp");
assert!(source.supports_fifo());
} }
#[test] #[test]
fn test_default_image_present() { fn test_default_image_present() {
let source = RadioParadiseSource; assert!(DEFAULT_IMAGE.len() > 0, "Default image should not be empty");
let image = source.default_image();
assert!(image.len() > 0, "Default image should not be empty");
// Check WebP magic bytes (RIFF...WEBP) // Check WebP magic bytes (RIFF...WEBP)
assert!(image.len() >= 12, "Image too small to be valid WebP"); assert!(DEFAULT_IMAGE.len() >= 12, "Image too small to be valid WebP");
assert_eq!(&image[0..4], b"RIFF", "Missing RIFF header"); assert_eq!(&DEFAULT_IMAGE[0..4], b"RIFF", "Missing RIFF header");
assert_eq!(&image[8..12], b"WEBP", "Missing WEBP signature"); 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);
} }
} }

519
pmoplaylist/ARCHITECTURE.md Normal file
View File

@@ -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<RwLock<FifoPlaylistInner>>,
}
```
**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<Track>,
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<String>, // Artiste
pub album: Option<String>, // Album
pub duration: Option<u32>, // Durée en secondes
pub uri: String, // URI du fichier/flux
pub image: Option<String>, // 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<Track>, // 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
<container id="playlist-id" parentID="0" childCount="5">
<dc:title>My Playlist</dc:title>
<upnp:class>object.container.playlistContainer</upnp:class>
</container>
```
**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<Item>
```
**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<FifoPlaylist>) -> 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<FifoPlaylist>,
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<FifoPlaylist>) -> 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<Self>;
}
```
### 2. Événements et callbacks
```rust
pub enum PlaylistEvent {
TrackAdded(Track),
TrackRemoved(String),
Cleared,
}
impl FifoPlaylist {
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<PlaylistEvent>;
}
```
### 3. Indexation et recherche
```rust
impl FifoPlaylist {
pub async fn find_by_artist(&self, artist: &str) -> Vec<Track>;
pub async fn find_by_title(&self, title: &str) -> Vec<Track>;
}
```
### 4. Statistiques
```rust
impl FifoPlaylist {
pub async fn total_duration(&self) -> u32;
pub async fn most_common_artist(&self) -> Option<String>;
}
```
## Licence
Ce projet fait partie du workspace PMOMusic.

100
pmoplaylist/CHANGELOG.md Normal file
View File

@@ -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<RwLock>`
- 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

9
pmoplaylist/Cargo.toml Normal file
View File

@@ -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"] }

507
pmoplaylist/README.md Normal file
View File

@@ -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<RwLock>`
- ✅ 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<RwLock<FifoPlaylistInner>>
│ ├── id: String
│ ├── title: String
│ ├── default_image: &'static [u8]
│ ├── capacity: usize
│ ├── queue: VecDeque<Track>
│ ├── update_id: u32
│ └── last_change: SystemTime
Track
├── id: String
├── title: String
├── artist: Option<String>
├── album: Option<String>
├── duration: Option<u32>
├── uri: String
└── image: Option<String>
```
## 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -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é ===");
}

View File

@@ -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<FifoPlaylist>) {
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<FifoPlaylist>, 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<FifoPlaylist>) {
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<FifoPlaylist>, 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<FifoPlaylist>) {
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<FifoPlaylist>) {
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());
}

View File

@@ -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 ===");
}

774
pmoplaylist/src/lib.rs Normal file
View File

@@ -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<String>,
/// Album (optionnel)
pub album: Option<String>,
/// Durée en secondes (optionnel)
pub duration: Option<u32>,
/// 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<String>,
}
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<String>, title: impl Into<String>, uri: impl Into<String>) -> 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<String>) -> Self {
self.artist = Some(artist.into());
self
}
/// Définit l'album du track
pub fn with_album(mut self, album: impl Into<String>) -> 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<String>) -> 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<RwLock<FifoPlaylistInner>>,
}
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<Track>,
/// 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<Track> {
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<Track> {
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<String>) -> 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<Item> {
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);
}
}

View File

@@ -1,35 +1,157 @@
//! Music source implementation for Qobuz //! Music source implementation for Qobuz
//! //!
//! This module implements the [`pmosource::MusicSource`] trait 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) /// Default image for Qobuz (300x300 WebP, embedded in binary)
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); 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 /// This struct combines a [`QobuzClient`] for API access with browsing and
/// standardized access to Qobuz's identification and branding. /// 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 /// # Examples
/// ///
/// ``` /// ```no_run
/// use pmoqobuz::QobuzSource; /// use pmoqobuz::{QobuzSource, QobuzClient};
/// use pmosource::MusicSource; /// use pmosource::MusicSource;
/// ///
/// let source = QobuzSource; /// #[tokio::main]
/// assert_eq!(source.name(), "Qobuz"); /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// assert_eq!(source.id(), "qobuz"); /// let client = QobuzClient::from_config().await?;
/// let source = QobuzSource::new(client);
/// ///
/// // Get default image as WebP bytes /// println!("Source: {}", source.name());
/// let image_data = source.default_image(); /// println!("Supports FIFO: {}", source.supports_fifo());
/// assert!(image_data.len() > 0); ///
/// // 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)] #[derive(Clone)]
pub struct QobuzSource; pub struct QobuzSource {
inner: Arc<QobuzSourceInner>,
}
struct QobuzSourceInner {
/// Qobuz API client
client: QobuzClient,
/// Update tracking
update_counter: RwLock<u32>,
last_change: RwLock<SystemTime>,
}
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<dyn std::error::Error>> {
/// 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 { impl MusicSource for QobuzSource {
fn name(&self) -> &str { fn name(&self) -> &str {
"Qobuz" "Qobuz"
@@ -42,29 +164,227 @@ impl MusicSource for QobuzSource {
fn default_image(&self) -> &[u8] { fn default_image(&self) -> &[u8] {
DEFAULT_IMAGE DEFAULT_IMAGE
} }
async fn root_container(&self) -> Result<Container> {
// 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<BrowseResult> {
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<Container> = 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<Item> = 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<Item> = 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<Container> = 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<String> {
// 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<Option<Item>> {
Err(MusicSourceError::FifoNotSupported)
}
async fn update_id(&self) -> u32 {
*self.inner.update_counter.read().await
}
async fn last_change(&self) -> Option<SystemTime> {
Some(*self.inner.last_change.read().await)
}
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
// 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<Item> = 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<BrowseResult> {
// 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<Container> = results
.albums
.into_iter()
.filter_map(|album| album.to_didl_container("qobuz").ok())
.collect();
let items: Vec<Item> = 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn test_default_image_present() { fn test_default_image_present() {
let source = QobuzSource; assert!(DEFAULT_IMAGE.len() > 0, "Default image should not be empty");
let image = source.default_image();
assert!(image.len() > 0, "Default image should not be empty");
// Check WebP magic bytes (RIFF...WEBP) // Check WebP magic bytes (RIFF...WEBP)
assert!(image.len() >= 12, "Image too small to be valid WebP"); assert!(DEFAULT_IMAGE.len() >= 12, "Image too small to be valid WebP");
assert_eq!(&image[0..4], b"RIFF", "Missing RIFF header"); assert_eq!(&DEFAULT_IMAGE[0..4], b"RIFF", "Missing RIFF header");
assert_eq!(&image[8..12], b"WEBP", "Missing WEBP signature"); 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.
} }

460
pmosource/ARCHITECTURE.md Normal file
View File

@@ -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<Container>;
async fn browse(&self, object_id: &str) -> Result<BrowseResult>;
async fn resolve_uri(&self, object_id: &str) -> Result<String>;
```
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<Option<Item>>;
```
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<SystemTime>;
```
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<Vec<Item>>;
async fn search(&self, query: &str) -> Result<BrowseResult>;
```
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<HashMap<String, (String, Option<String>)>>,
}
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<Item>,
}
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<RwLock<>>)
### 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<String> {
// 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<T>`:
```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<dyn MusicSource>` enables efficient sharing
**Implementation:**
- Use `Arc<RwLock<>>` 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<Quality>;
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<Vec<Item>>;
```
4. **Recommendations**:
```rust
async fn get_recommendations(&self) -> Result<Vec<Item>>;
```
## 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<dyn MusicSource>` 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**

69
pmosource/CHANGELOG.md Normal file
View File

@@ -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)

View File

@@ -12,6 +12,24 @@ categories = ["multimedia"]
[dependencies] [dependencies]
# Gestion des erreurs # Gestion des erreurs
thiserror = "1.0" thiserror = "1.0"
anyhow = "1.0"
# Image format support # Async traits
image = { version = "0.25", default-features = false, features = ["webp"] } 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"]

View File

@@ -1,112 +1,295 @@
# pmosource # pmosource - Music Source Abstraction for PMOMusic
Common traits and types for PMOMusic sources. Common traits and types for PMOMusic sources.
## Overview This crate provides the foundational abstractions for different music sources in the PMOMusic ecosystem, such as Radio Paradise, Qobuz, local playlists, etc.
`pmosource` provides the foundational abstractions for different music sources in the PMOMusic ecosystem, such as Radio Paradise, Qobuz, and potentially others in the future.
## Features ## Features
- **`MusicSource` trait**: Common interface for all music sources - **FIFO Support**: Dynamic audio sources using `pmoplaylist` for streaming
- **Default images**: Standardized 300x300px WebP images embedded in binaries - **Container/Item Navigation**: Browse and search using DIDL-Lite format (`pmodidl`)
- **Source identification**: Consistent naming and ID scheme - **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 ```rust
use pmosource::MusicSource; use pmosource::{async_trait, MusicSource, BrowseResult, Result};
use pmodidl::{Container, Item};
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); use pmoplaylist::FifoPlaylist;
use std::time::SystemTime;
#[derive(Debug)] #[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 { fn name(&self) -> &str {
"My Music Service" "My Radio"
} }
fn id(&self) -> &str { fn id(&self) -> &str {
"my-music-service" "my-radio"
} }
fn default_image(&self) -> &[u8] { fn default_image(&self) -> &[u8] {
DEFAULT_IMAGE include_bytes!("../assets/my-radio.webp")
}
async fn root_container(&self) -> Result<Container> {
Ok(self.playlist.as_container().await)
}
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
// 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<String> {
// 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<Option<Item>> {
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<SystemTime> {
Some(self.playlist.last_change().await)
}
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
let tracks = self.playlist.get_items(offset, count).await;
// Convert tracks to Items...
Ok(items)
} }
} }
``` ```
### Using a music source ### Using a Music Source
```rust ```rust
use pmosource::MusicSource; use pmosource::MusicSource;
use pmoparadise::RadioParadiseSource;
use pmoqobuz::QobuzSource;
let rp = RadioParadiseSource; #[tokio::main]
let qobuz = QobuzSource; async fn main() -> anyhow::Result<()> {
let source = MyRadioSource::new("http://localhost:8080");
println!("Source: {} ({})", rp.name(), rp.id()); // Get source info
println!("Image size: {} bytes", rp.default_image().len()); 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: ### Basic Information
- **Format**: WebP
- **Dimensions**: 300x300 pixels (square)
- **Quality**: 85 (good balance between size and quality)
- **Location**: `<crate>/assets/default.webp`
### 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 - `root_container() -> Container`: Root container for UPnP ContentDirectory
from PIL import Image - `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): ### FIFO Support (Dynamic Sources)
img = Image.open(input_path)
# Convert to RGB if necessary - `supports_fifo() -> bool`: Whether this source uses a FIFO
if img.mode not in ('RGB', 'RGBA'): - `append_track(track: Item)`: Add track to FIFO (auto-removes oldest if full)
img = img.convert('RGB') - `remove_oldest() -> Option<Item>`: Remove oldest track from FIFO
# Make it square (center crop) ### Change Tracking
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))
# Resize to target size - `update_id() -> u32`: Increments on each change (for UPnP notifications)
img = img.resize((size, size), Image.Resampling.LANCZOS) - `last_change() -> Option<SystemTime>`: Timestamp of last modification
# Save as WebP ### Pagination & Search
img.save(output_path, 'WEBP', quality=85, method=6)
- `get_items(offset: usize, count: usize) -> Vec<Item>`: 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 When the `cache` feature is enabled, sources can integrate with `pmoaudiocache` to:
- **pmoqobuz**: Qobuz - 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<String> {
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 When the `cache` feature is enabled, sources can integrate with `pmocovers` to:
- Available quality levels - Cache album art locally (with WebP conversion)
- Streaming capabilities - Generate multiple size variants
- Search functionality - Serve optimized images
- Playlist management
- And more... ### 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 ## License

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -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<MySourceInner>,
}
struct MySourceInner {
// For dynamic sources:
playlist: FifoPlaylist,
// For static sources:
// items: Vec<Item>,
// 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<Container> {
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<BrowseResult> {
// Return items for this container
Ok(BrowseResult::Items(vec![]))
}
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
// 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<Option<Item>> {
// 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<std::time::SystemTime> {
// Return timestamp of last modification
None
}
```
### 6. Implement Pagination
```rust
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
// Return paginated items
Ok(vec![])
}
```
### 7. Implement Search (optional)
```rust
async fn search(&self, query: &str) -> Result<BrowseResult> {
// 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<RwLock<>>` for mutable state:
```rust
use std::sync::Arc;
use tokio::sync::RwLock;
struct MySourceInner {
state: RwLock<HashMap<String, Track>>,
}
```
### 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

View File

@@ -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<RadioParadiseInner>,
}
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<HashMap<String, (String, Option<String>)>>,
}
// 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<String>, 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<String>) -> 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<String>,
album: Option<String>,
uri: String,
image_url: Option<String>,
duration: Option<u32>,
) -> 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<Container> {
Ok(self.inner.playlist.as_container().await)
}
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
// 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<Item> = 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<String> {
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<Option<Item>> {
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<std::time::SystemTime> {
Some(self.inner.playlist.last_change().await)
}
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
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(())
}

View File

@@ -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<Box<dyn MusicSource>> = 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.");
}

View File

@@ -4,8 +4,22 @@
//! //!
//! This crate provides the foundational abstractions for different music sources //! This crate provides the foundational abstractions for different music sources
//! in the PMOMusic ecosystem, such as Radio Paradise, Qobuz, etc. //! 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::fmt::Debug;
use std::time::SystemTime;
/// Standard size for default images (300x300 pixels) /// Standard size for default images (300x300 pixels)
pub const DEFAULT_IMAGE_SIZE: u32 = 300; pub const DEFAULT_IMAGE_SIZE: u32 = 300;
@@ -21,19 +35,174 @@ pub enum MusicSourceError {
#[error("Source not available: {0}")] #[error("Source not available: {0}")]
SourceUnavailable(String), 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 /// Result type for music source operations
pub type Result<T> = std::result::Result<T, MusicSourceError>; pub type Result<T> = std::result::Result<T, MusicSourceError>;
/// Result of a browse operation
#[derive(Debug, Clone)]
pub enum BrowseResult {
/// List of sub-containers only
Containers(Vec<Container>),
/// List of items only
Items(Vec<Item>),
/// Mixed: both containers and items
Mixed {
containers: Vec<Container>,
items: Vec<Item>,
},
}
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 /// Main trait for music sources
/// ///
/// This trait defines the common interface that all music sources must implement. /// This trait defines the common interface that all music sources must implement.
/// It provides methods for: /// It provides methods for:
/// - Getting the source name and identification /// - Getting the source name and identification
/// - Retrieving default images/logos /// - 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<Container> {
/// 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<BrowseResult> {
/// // Browse implementation
/// Ok(BrowseResult::Items(vec![]))
/// }
///
/// async fn resolve_uri(&self, object_id: &str) -> Result<String> {
/// // 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<Option<Item>> {
/// // Remove oldest track
/// Ok(None)
/// }
///
/// async fn update_id(&self) -> u32 {
/// 0
/// }
///
/// async fn last_change(&self) -> Option<SystemTime> {
/// None
/// }
///
/// async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
/// Ok(vec![])
/// }
///
/// async fn search(&self, query: &str) -> Result<BrowseResult> {
/// Err(pmosource::MusicSourceError::SearchNotSupported)
/// }
/// }
/// ```
#[async_trait::async_trait]
pub trait MusicSource: Debug + Send + Sync { pub trait MusicSource: Debug + Send + Sync {
// ============= Basic Information =============
/// Returns the human-readable name of the music source /// Returns the human-readable name of the music source
/// ///
/// # Examples /// # Examples
@@ -79,8 +248,212 @@ pub trait MusicSource: Debug + Send + Sync {
fn default_image_mime_type(&self) -> &str { fn default_image_mime_type(&self) -> &str {
"image/webp" "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<Container>;
/// 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<BrowseResult>;
/// 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<String>;
// ============= 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<Option<Item>>;
// ============= 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<SystemTime>;
// ============= 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<Vec<Item>>;
/// 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<BrowseResult> {
let _ = query;
Err(MusicSourceError::SearchNotSupported)
}
} }
// Re-export commonly used types
pub use async_trait::async_trait;
pub use pmodidl;
pub use pmoplaylist;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -88,6 +461,7 @@ mod tests {
#[derive(Debug)] #[derive(Debug)]
struct TestSource; struct TestSource;
#[async_trait]
impl MusicSource for TestSource { impl MusicSource for TestSource {
fn name(&self) -> &str { fn name(&self) -> &str {
"Test Source" "Test Source"
@@ -100,13 +474,112 @@ mod tests {
fn default_image(&self) -> &[u8] { fn default_image(&self) -> &[u8] {
&[] &[]
} }
async fn root_container(&self) -> Result<Container> {
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![],
})
} }
#[test] async fn browse(&self, _object_id: &str) -> Result<BrowseResult> {
fn test_music_source_trait() { Ok(BrowseResult::Items(vec![]))
}
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
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<Option<Item>> {
Err(MusicSourceError::FifoNotSupported)
}
async fn update_id(&self) -> u32 {
0
}
async fn last_change(&self) -> Option<SystemTime> {
None
}
async fn get_items(&self, _offset: usize, _count: usize) -> Result<Vec<Item>> {
Ok(vec![])
}
}
#[tokio::test]
async fn test_music_source_trait() {
let source = TestSource; let source = TestSource;
assert_eq!(source.name(), "Test Source"); assert_eq!(source.name(), "Test Source");
assert_eq!(source.id(), "test-source"); assert_eq!(source.id(), "test-source");
assert_eq!(source.default_image_mime_type(), "image/webp"); 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)));
} }
} }