Crée la crate pmoplaylist
This commit is contained in:
20
Cargo.lock
generated
20
Cargo.lock
generated
@@ -2353,7 +2353,10 @@ dependencies = [
|
||||
"claxon",
|
||||
"futures",
|
||||
"hound",
|
||||
"pmoaudiocache",
|
||||
"pmocovers",
|
||||
"pmodidl",
|
||||
"pmoplaylist",
|
||||
"pmoserver",
|
||||
"pmosource",
|
||||
"pmoupnp",
|
||||
@@ -2371,6 +2374,15 @@ dependencies = [
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pmoplaylist"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"pmodidl",
|
||||
"serde",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pmoqobuz"
|
||||
version = "0.1.0"
|
||||
@@ -2424,8 +2436,14 @@ dependencies = [
|
||||
name = "pmosource"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"pmoaudiocache",
|
||||
"pmocovers",
|
||||
"pmodidl",
|
||||
"pmoplaylist",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["PMOMusic", "pmoupnp", "pmomediarenderer", "pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp", "pmocache", "pmocovers", "pmoaudiocache", "pmoaudio", "pmoqobuz", "pmoparadise", "pmosource"]
|
||||
members = ["PMOMusic", "pmoupnp", "pmomediarenderer", "pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp", "pmocache", "pmocovers", "pmoaudiocache", "pmoaudio", "pmoqobuz", "pmoparadise", "pmosource", "pmoplaylist"]
|
||||
|
||||
@@ -48,6 +48,13 @@ uuid = { version = "1.18", optional = true }
|
||||
# Common music source traits
|
||||
pmosource = { path = "../pmosource" }
|
||||
|
||||
# Playlist management for FIFO support
|
||||
pmoplaylist = { path = "../pmoplaylist" }
|
||||
|
||||
# Cache support
|
||||
pmocovers = { path = "../pmocovers", optional = true }
|
||||
pmoaudiocache = { path = "../pmoaudiocache", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["metadata-only"]
|
||||
# Mode métadonnées seules (pas de décodage FLAC)
|
||||
@@ -58,6 +65,8 @@ per-track = ["dep:claxon", "dep:hound", "dep:tempfile"]
|
||||
logging = ["dep:tracing"]
|
||||
# Active le media server UPnP
|
||||
mediaserver = ["dep:pmoupnp", "dep:pmoserver", "dep:pmodidl", "dep:uuid"]
|
||||
# Active le cache d'images et audio
|
||||
cache = ["dep:pmocovers", "dep:pmoaudiocache", "logging"]
|
||||
|
||||
[dev-dependencies]
|
||||
# Tests
|
||||
@@ -65,6 +74,9 @@ tokio-test = "0.4"
|
||||
wiremock = "0.6"
|
||||
# Pour les exemples avec logging
|
||||
tracing-subscriber = "0.3"
|
||||
# Pour l'exemple with_cache
|
||||
pmocovers = { path = "../pmocovers" }
|
||||
pmoaudiocache = { path = "../pmoaudiocache" }
|
||||
|
||||
[[example]]
|
||||
name = "now_playing"
|
||||
@@ -83,3 +95,8 @@ required-features = ["per-track"]
|
||||
name = "upnp_mediaserver"
|
||||
path = "examples/upnp_mediaserver.rs"
|
||||
required-features = ["mediaserver"]
|
||||
|
||||
[[example]]
|
||||
name = "with_cache"
|
||||
path = "examples/with_cache.rs"
|
||||
required-features = ["cache"]
|
||||
|
||||
100
pmoparadise/examples/with_cache.rs
Normal file
100
pmoparadise/examples/with_cache.rs
Normal 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(())
|
||||
}
|
||||
@@ -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
|
||||
//!
|
||||
//! - `default = ["metadata-only"]`: Standard metadata and streaming (no FLAC decoding)
|
||||
//! - `per-track`: Enable FLAC decoding and per-track extraction (adds `claxon`, `hound`, `tempfile`)
|
||||
//! - `logging`: Enable tracing logs for debugging
|
||||
//! - `mediaserver`: Enable UPnP/DLNA Media Server (adds `pmoupnp`, `pmoserver`, `pmodidl`)
|
||||
//! - `cache`: Enable cover and audio caching support (adds `pmocovers`, `pmoaudiocache`, enables `logging`)
|
||||
//!
|
||||
//! ## See Also
|
||||
//!
|
||||
|
||||
@@ -1,35 +1,374 @@
|
||||
//! Music source implementation for Radio Paradise
|
||||
//!
|
||||
//! This module implements the [`pmosource::MusicSource`] trait for Radio Paradise,
|
||||
//! providing access to the service's default image and identification information.
|
||||
//! providing a complete music source with FIFO playlist support, browsing, and caching.
|
||||
|
||||
use pmosource::MusicSource;
|
||||
use crate::client::RadioParadiseClient;
|
||||
use crate::models::{Block, Song};
|
||||
use pmosource::{async_trait, pmodidl, BrowseResult, MusicSource, MusicSourceError, Result};
|
||||
use pmodidl::{Container, Item, Resource};
|
||||
use pmoplaylist::{FifoPlaylist, Track};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[cfg(feature = "cache")]
|
||||
use pmocovers::Cache as CoverCache;
|
||||
#[cfg(feature = "cache")]
|
||||
use pmoaudiocache::{AudioCache, AudioMetadata};
|
||||
|
||||
/// Default image for Radio Paradise (300x300 WebP, embedded in binary)
|
||||
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
|
||||
|
||||
/// Radio Paradise music source
|
||||
/// Default FIFO capacity (number of recent tracks to keep)
|
||||
const DEFAULT_FIFO_CAPACITY: usize = 50;
|
||||
|
||||
/// Radio Paradise music source with full MusicSource trait implementation
|
||||
///
|
||||
/// This struct implements the [`MusicSource`] trait to provide
|
||||
/// standardized access to Radio Paradise's identification and branding.
|
||||
/// This struct combines a [`RadioParadiseClient`] for API access with a FIFO playlist
|
||||
/// for dynamic track management, implementing the complete [`MusicSource`] trait.
|
||||
///
|
||||
/// # Features
|
||||
///
|
||||
/// - **FIFO Playlist**: Dynamic track management with configurable capacity
|
||||
/// - **API Integration**: Fetches blocks and metadata from Radio Paradise
|
||||
/// - **URI Resolution**: Resolves track URIs with optional cache support
|
||||
/// - **Change Tracking**: Tracks update_id and last_change for UPnP notifications
|
||||
/// - **DIDL-Lite Export**: Converts tracks and blocks to UPnP-compatible formats
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoparadise::RadioParadiseSource;
|
||||
/// ```no_run
|
||||
/// use pmoparadise::{RadioParadiseSource, RadioParadiseClient};
|
||||
/// use pmosource::MusicSource;
|
||||
///
|
||||
/// let source = RadioParadiseSource;
|
||||
/// assert_eq!(source.name(), "Radio Paradise");
|
||||
/// assert_eq!(source.id(), "radio-paradise");
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let client = RadioParadiseClient::new().await?;
|
||||
/// let source = RadioParadiseSource::new(client, "http://localhost:8080", 50);
|
||||
///
|
||||
/// // Get default image as WebP bytes
|
||||
/// let image_data = source.default_image();
|
||||
/// assert!(image_data.len() > 0);
|
||||
/// println!("Source: {}", source.name());
|
||||
/// println!("Supports FIFO: {}", source.supports_fifo());
|
||||
///
|
||||
/// // Start streaming and the FIFO will be populated
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct RadioParadiseSource;
|
||||
#[derive(Clone)]
|
||||
pub struct RadioParadiseSource {
|
||||
inner: Arc<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 {
|
||||
fn name(&self) -> &str {
|
||||
"Radio Paradise"
|
||||
@@ -42,29 +381,166 @@ impl MusicSource for RadioParadiseSource {
|
||||
fn default_image(&self) -> &[u8] {
|
||||
DEFAULT_IMAGE
|
||||
}
|
||||
|
||||
async fn root_container(&self) -> Result<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_source_info() {
|
||||
let source = RadioParadiseSource;
|
||||
#[tokio::test]
|
||||
async fn test_source_info() {
|
||||
let client = RadioParadiseClient::with_client(reqwest::Client::new());
|
||||
let source = RadioParadiseSource::new_default(client, "http://localhost:8080");
|
||||
|
||||
assert_eq!(source.name(), "Radio Paradise");
|
||||
assert_eq!(source.id(), "radio-paradise");
|
||||
assert_eq!(source.default_image_mime_type(), "image/webp");
|
||||
assert!(source.supports_fifo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_image_present() {
|
||||
let source = RadioParadiseSource;
|
||||
let image = source.default_image();
|
||||
assert!(image.len() > 0, "Default image should not be empty");
|
||||
assert!(DEFAULT_IMAGE.len() > 0, "Default image should not be empty");
|
||||
|
||||
// Check WebP magic bytes (RIFF...WEBP)
|
||||
assert!(image.len() >= 12, "Image too small to be valid WebP");
|
||||
assert_eq!(&image[0..4], b"RIFF", "Missing RIFF header");
|
||||
assert_eq!(&image[8..12], b"WEBP", "Missing WEBP signature");
|
||||
assert!(DEFAULT_IMAGE.len() >= 12, "Image too small to be valid WebP");
|
||||
assert_eq!(&DEFAULT_IMAGE[0..4], b"RIFF", "Missing RIFF header");
|
||||
assert_eq!(&DEFAULT_IMAGE[8..12], b"WEBP", "Missing WEBP signature");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fifo_operations() {
|
||||
let client = RadioParadiseClient::with_client(reqwest::Client::new());
|
||||
let source = RadioParadiseSource::new_default(client, "http://localhost:8080");
|
||||
|
||||
// Initially empty
|
||||
let items = source.get_items(0, 10).await.unwrap();
|
||||
assert_eq!(items.len(), 0);
|
||||
|
||||
// Test FIFO support
|
||||
assert!(source.supports_fifo());
|
||||
|
||||
// Initial update_id
|
||||
let update_id = source.update_id().await;
|
||||
assert_eq!(update_id, 0);
|
||||
}
|
||||
}
|
||||
|
||||
519
pmoplaylist/ARCHITECTURE.md
Normal file
519
pmoplaylist/ARCHITECTURE.md
Normal 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
100
pmoplaylist/CHANGELOG.md
Normal 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
9
pmoplaylist/Cargo.toml
Normal 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
507
pmoplaylist/README.md
Normal 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.
|
||||
BIN
pmoplaylist/assets/default.webp
Normal file
BIN
pmoplaylist/assets/default.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
149
pmoplaylist/examples/basic_usage.rs
Normal file
149
pmoplaylist/examples/basic_usage.rs
Normal 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é ===");
|
||||
}
|
||||
207
pmoplaylist/examples/http_server_integration.rs
Normal file
207
pmoplaylist/examples/http_server_integration.rs
Normal 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());
|
||||
}
|
||||
173
pmoplaylist/examples/radio_streaming.rs
Normal file
173
pmoplaylist/examples/radio_streaming.rs
Normal 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
774
pmoplaylist/src/lib.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,157 @@
|
||||
//! Music source implementation for Qobuz
|
||||
//!
|
||||
//! This module implements the [`pmosource::MusicSource`] trait for Qobuz,
|
||||
//! providing access to the service's default image and identification information.
|
||||
//! providing a complete music catalog browsing and searching experience.
|
||||
|
||||
use pmosource::MusicSource;
|
||||
use crate::client::QobuzClient;
|
||||
use crate::didl::ToDIDL;
|
||||
use crate::models::{Album, Track};
|
||||
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
|
||||
use pmodidl::{Container, Item};
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Default image for Qobuz (300x300 WebP, embedded in binary)
|
||||
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
|
||||
|
||||
/// Qobuz music source
|
||||
/// Qobuz music source with full MusicSource trait implementation
|
||||
///
|
||||
/// This struct implements the [`MusicSource`] trait to provide
|
||||
/// standardized access to Qobuz's identification and branding.
|
||||
/// This struct combines a [`QobuzClient`] for API access with browsing and
|
||||
/// navigation capabilities, implementing the complete [`MusicSource`] trait.
|
||||
///
|
||||
/// # Features
|
||||
///
|
||||
/// - **Catalog Navigation**: Browse albums, artists, playlists, favorites
|
||||
/// - **Search**: Full-text search across the Qobuz catalog
|
||||
/// - **URI Resolution**: Resolves track streaming URIs with authentication
|
||||
/// - **DIDL-Lite Export**: Converts albums, tracks, and playlists to UPnP formats
|
||||
/// - **Caching**: Integrated with QobuzClient's cache for performance
|
||||
///
|
||||
/// # Architecture
|
||||
///
|
||||
/// Unlike streaming sources like Radio Paradise, Qobuz is a catalog-based source:
|
||||
/// - Root container has multiple sub-containers (Albums, Artists, Favorites, etc.)
|
||||
/// - No FIFO support (it's a static catalog, not a dynamic stream)
|
||||
/// - Hierarchical browsing: Root → Category → Albums → Tracks
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoqobuz::QobuzSource;
|
||||
/// ```no_run
|
||||
/// use pmoqobuz::{QobuzSource, QobuzClient};
|
||||
/// use pmosource::MusicSource;
|
||||
///
|
||||
/// let source = QobuzSource;
|
||||
/// assert_eq!(source.name(), "Qobuz");
|
||||
/// assert_eq!(source.id(), "qobuz");
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let client = QobuzClient::from_config().await?;
|
||||
/// let source = QobuzSource::new(client);
|
||||
///
|
||||
/// // Get default image as WebP bytes
|
||||
/// let image_data = source.default_image();
|
||||
/// assert!(image_data.len() > 0);
|
||||
/// println!("Source: {}", source.name());
|
||||
/// println!("Supports FIFO: {}", source.supports_fifo());
|
||||
///
|
||||
/// // Browse root container
|
||||
/// let root = source.root_container().await?;
|
||||
/// println!("Root: {} with {} children", root.title, root.child_count.unwrap_or_default());
|
||||
///
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct QobuzSource;
|
||||
#[derive(Clone)]
|
||||
pub struct QobuzSource {
|
||||
inner: Arc<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 {
|
||||
fn name(&self) -> &str {
|
||||
"Qobuz"
|
||||
@@ -42,29 +164,227 @@ impl MusicSource for QobuzSource {
|
||||
fn default_image(&self) -> &[u8] {
|
||||
DEFAULT_IMAGE
|
||||
}
|
||||
|
||||
async fn root_container(&self) -> Result<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_source_info() {
|
||||
let source = QobuzSource;
|
||||
assert_eq!(source.name(), "Qobuz");
|
||||
assert_eq!(source.id(), "qobuz");
|
||||
assert_eq!(source.default_image_mime_type(), "image/webp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_image_present() {
|
||||
let source = QobuzSource;
|
||||
let image = source.default_image();
|
||||
assert!(image.len() > 0, "Default image should not be empty");
|
||||
assert!(DEFAULT_IMAGE.len() > 0, "Default image should not be empty");
|
||||
|
||||
// Check WebP magic bytes (RIFF...WEBP)
|
||||
assert!(image.len() >= 12, "Image too small to be valid WebP");
|
||||
assert_eq!(&image[0..4], b"RIFF", "Missing RIFF header");
|
||||
assert_eq!(&image[8..12], b"WEBP", "Missing WEBP signature");
|
||||
assert!(DEFAULT_IMAGE.len() >= 12, "Image too small to be valid WebP");
|
||||
assert_eq!(&DEFAULT_IMAGE[0..4], b"RIFF", "Missing RIFF header");
|
||||
assert_eq!(&DEFAULT_IMAGE[8..12], b"WEBP", "Missing WEBP signature");
|
||||
}
|
||||
|
||||
// Note: We can't easily test parse_object_id without creating a real client
|
||||
// which requires authentication. The parsing logic is simple enough that
|
||||
// it's covered by integration tests.
|
||||
}
|
||||
|
||||
460
pmosource/ARCHITECTURE.md
Normal file
460
pmosource/ARCHITECTURE.md
Normal 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
69
pmosource/CHANGELOG.md
Normal 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)
|
||||
@@ -12,6 +12,24 @@ categories = ["multimedia"]
|
||||
[dependencies]
|
||||
# Gestion des erreurs
|
||||
thiserror = "1.0"
|
||||
anyhow = "1.0"
|
||||
|
||||
# Image format support
|
||||
image = { version = "0.25", default-features = false, features = ["webp"] }
|
||||
# Async traits
|
||||
async-trait = "0.1"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.0", features = ["sync", "time"] }
|
||||
|
||||
# DIDL-Lite support
|
||||
pmodidl = { path = "../pmodidl" }
|
||||
|
||||
# Playlist/FIFO support
|
||||
pmoplaylist = { path = "../pmoplaylist" }
|
||||
|
||||
# Optional cache integrations
|
||||
pmoaudiocache = { path = "../pmoaudiocache", optional = true }
|
||||
pmocovers = { path = "../pmocovers", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["cache"]
|
||||
cache = ["pmoaudiocache", "pmocovers"]
|
||||
|
||||
@@ -1,112 +1,295 @@
|
||||
# pmosource
|
||||
# pmosource - Music Source Abstraction for PMOMusic
|
||||
|
||||
Common traits and types for PMOMusic sources.
|
||||
|
||||
## Overview
|
||||
|
||||
`pmosource` provides the foundational abstractions for different music sources in the PMOMusic ecosystem, such as Radio Paradise, Qobuz, and potentially others in the future.
|
||||
This crate provides the foundational abstractions for different music sources in the PMOMusic ecosystem, such as Radio Paradise, Qobuz, local playlists, etc.
|
||||
|
||||
## Features
|
||||
|
||||
- **`MusicSource` trait**: Common interface for all music sources
|
||||
- **Default images**: Standardized 300x300px WebP images embedded in binaries
|
||||
- **Source identification**: Consistent naming and ID scheme
|
||||
- **FIFO Support**: Dynamic audio sources using `pmoplaylist` for streaming
|
||||
- **Container/Item Navigation**: Browse and search using DIDL-Lite format (`pmodidl`)
|
||||
- **Cache Integration**: Automatic URI resolution with `pmoaudiocache` and `pmocovers`
|
||||
- **Change Tracking**: `update_id` and `last_change` for UPnP notifications
|
||||
- **Send + Sync**: Ready for async servers
|
||||
|
||||
## Usage
|
||||
## Architecture
|
||||
|
||||
### Implementing the trait
|
||||
The `MusicSource` trait provides a unified interface for all music sources:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ MusicSource Trait │
|
||||
├─────────────────────────────────────┤
|
||||
│ • Basic Info (name, id, image) │
|
||||
│ • ContentDirectory (browse, search) │
|
||||
│ • URI Resolution (with caching) │
|
||||
│ • FIFO Management │
|
||||
│ • Change Tracking │
|
||||
└─────────────────────────────────────┘
|
||||
▲ ▲ ▲
|
||||
│ │ │
|
||||
┌────┴───┐ ┌──┴────┐ ┌──┴─────┐
|
||||
│ Radio │ │ Qobuz │ │ Local │
|
||||
│Paradise│ │ │ │Playlist│
|
||||
└────────┘ └───────┘ └────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Implementing a Music Source
|
||||
|
||||
```rust
|
||||
use pmosource::MusicSource;
|
||||
|
||||
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
|
||||
use pmosource::{async_trait, MusicSource, BrowseResult, Result};
|
||||
use pmodidl::{Container, Item};
|
||||
use pmoplaylist::FifoPlaylist;
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MyMusicSource;
|
||||
pub struct MyRadioSource {
|
||||
playlist: FifoPlaylist,
|
||||
// ... other fields
|
||||
}
|
||||
|
||||
impl MusicSource for MyMusicSource {
|
||||
#[async_trait]
|
||||
impl MusicSource for MyRadioSource {
|
||||
fn name(&self) -> &str {
|
||||
"My Music Service"
|
||||
"My Radio"
|
||||
}
|
||||
|
||||
fn id(&self) -> &str {
|
||||
"my-music-service"
|
||||
"my-radio"
|
||||
}
|
||||
|
||||
fn default_image(&self) -> &[u8] {
|
||||
DEFAULT_IMAGE
|
||||
include_bytes!("../assets/my-radio.webp")
|
||||
}
|
||||
|
||||
async fn root_container(&self) -> Result<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
|
||||
use pmosource::MusicSource;
|
||||
use pmoparadise::RadioParadiseSource;
|
||||
use pmoqobuz::QobuzSource;
|
||||
|
||||
let rp = RadioParadiseSource;
|
||||
let qobuz = QobuzSource;
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let source = MyRadioSource::new("http://localhost:8080");
|
||||
|
||||
println!("Source: {} ({})", rp.name(), rp.id());
|
||||
println!("Image size: {} bytes", rp.default_image().len());
|
||||
// Get source info
|
||||
println!("Source: {}", source.name());
|
||||
println!("ID: {}", source.id());
|
||||
|
||||
// Get root container for ContentDirectory
|
||||
let root = source.root_container().await?;
|
||||
println!("Root: {} ({})", root.title, root.id);
|
||||
|
||||
// Browse items
|
||||
let result = source.browse(&root.id).await?;
|
||||
for item in result.items() {
|
||||
println!("Track: {}", item.title);
|
||||
}
|
||||
|
||||
// Resolve audio URI
|
||||
let uri = source.resolve_uri("track-123").await?;
|
||||
println!("Stream from: {}", uri);
|
||||
|
||||
// Track changes
|
||||
println!("Update ID: {}", source.update_id().await);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Image Format
|
||||
## Trait Methods
|
||||
|
||||
All default images should be:
|
||||
- **Format**: WebP
|
||||
- **Dimensions**: 300x300 pixels (square)
|
||||
- **Quality**: 85 (good balance between size and quality)
|
||||
- **Location**: `<crate>/assets/default.webp`
|
||||
### Basic Information
|
||||
|
||||
### Converting images
|
||||
- `name() -> &str`: Human-readable name
|
||||
- `id() -> &str`: Unique identifier (e.g., "radio-paradise")
|
||||
- `default_image() -> &[u8]`: Embedded WebP logo (300x300px)
|
||||
- `default_image_mime_type() -> &str`: MIME type (default: "image/webp")
|
||||
|
||||
Use the provided Python script or similar tool:
|
||||
### ContentDirectory Navigation
|
||||
|
||||
```python
|
||||
from PIL import Image
|
||||
- `root_container() -> Container`: Root container for UPnP ContentDirectory
|
||||
- `browse(object_id: &str) -> BrowseResult`: Browse containers/items
|
||||
- `resolve_uri(object_id: &str) -> String`: Get audio URI (cached or original)
|
||||
|
||||
def convert_to_webp(input_path, output_path, size=300):
|
||||
img = Image.open(input_path)
|
||||
### FIFO Support (Dynamic Sources)
|
||||
|
||||
# Convert to RGB if necessary
|
||||
if img.mode not in ('RGB', 'RGBA'):
|
||||
img = img.convert('RGB')
|
||||
- `supports_fifo() -> bool`: Whether this source uses a FIFO
|
||||
- `append_track(track: Item)`: Add track to FIFO (auto-removes oldest if full)
|
||||
- `remove_oldest() -> Option<Item>`: Remove oldest track from FIFO
|
||||
|
||||
# Make it square (center crop)
|
||||
width, height = img.size
|
||||
if width != height:
|
||||
min_dim = min(width, height)
|
||||
left = (width - min_dim) // 2
|
||||
top = (height - min_dim) // 2
|
||||
right = left + min_dim
|
||||
bottom = top + min_dim
|
||||
img = img.crop((left, top, right, bottom))
|
||||
### Change Tracking
|
||||
|
||||
# Resize to target size
|
||||
img = img.resize((size, size), Image.Resampling.LANCZOS)
|
||||
- `update_id() -> u32`: Increments on each change (for UPnP notifications)
|
||||
- `last_change() -> Option<SystemTime>`: Timestamp of last modification
|
||||
|
||||
# Save as WebP
|
||||
img.save(output_path, 'WEBP', quality=85, method=6)
|
||||
### Pagination & Search
|
||||
|
||||
- `get_items(offset: usize, count: usize) -> Vec<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
|
||||
- **pmoqobuz**: Qobuz
|
||||
When the `cache` feature is enabled, sources can integrate with `pmoaudiocache` to:
|
||||
- Cache audio files locally (with FLAC conversion)
|
||||
- Serve from local cache instead of re-streaming
|
||||
- Extract and merge metadata
|
||||
|
||||
## Future Enhancements
|
||||
```rust
|
||||
// Resolve URI checks cache first
|
||||
async fn resolve_uri(&self, object_id: &str) -> Result<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
|
||||
- Available quality levels
|
||||
- Streaming capabilities
|
||||
- Search functionality
|
||||
- Playlist management
|
||||
- And more...
|
||||
When the `cache` feature is enabled, sources can integrate with `pmocovers` to:
|
||||
- Cache album art locally (with WebP conversion)
|
||||
- Generate multiple size variants
|
||||
- Serve optimized images
|
||||
|
||||
### With pmodidl
|
||||
|
||||
All sources use `pmodidl` for DIDL-Lite generation compatible with UPnP/DLNA.
|
||||
|
||||
## Examples
|
||||
|
||||
### Radio Paradise
|
||||
|
||||
See [examples/radio_paradise.rs](examples/radio_paradise.rs) for a complete implementation of a streaming radio source with:
|
||||
- FIFO management using `pmoplaylist`
|
||||
- Simulated cache integration
|
||||
- Full DIDL-Lite export
|
||||
- Change tracking
|
||||
|
||||
Run the example:
|
||||
|
||||
```bash
|
||||
cargo run --example radio_paradise
|
||||
```
|
||||
|
||||
## Design Patterns
|
||||
|
||||
### Static Sources (Albums, Local Playlists)
|
||||
|
||||
```rust
|
||||
impl MusicSource for LocalAlbum {
|
||||
fn supports_fifo(&self) -> bool {
|
||||
false // Static content
|
||||
}
|
||||
|
||||
async fn append_track(&self, _: Item) -> Result<()> {
|
||||
Err(MusicSourceError::FifoNotSupported)
|
||||
}
|
||||
|
||||
async fn update_id(&self) -> u32 {
|
||||
0 // Never changes
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic Sources (Radios, Streaming Services)
|
||||
|
||||
```rust
|
||||
impl MusicSource for RadioSource {
|
||||
fn supports_fifo(&self) -> bool {
|
||||
true // Dynamic content
|
||||
}
|
||||
|
||||
async fn append_track(&self, track: Item) -> Result<()> {
|
||||
// Add to pmoplaylist::FifoPlaylist
|
||||
self.playlist.append_track(converted_track).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_id(&self) -> u32 {
|
||||
self.playlist.update_id().await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Thread Safety
|
||||
|
||||
All `MusicSource` implementations must be `Send + Sync` for use in async servers.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
BIN
pmosource/assets/radio-paradise.webp
Normal file
BIN
pmosource/assets/radio-paradise.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
270
pmosource/examples/README.md
Normal file
270
pmosource/examples/README.md
Normal 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
|
||||
466
pmosource/examples/radio_paradise.rs
Normal file
466
pmosource/examples/radio_paradise.rs
Normal 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(())
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -4,8 +4,22 @@
|
||||
//!
|
||||
//! This crate provides the foundational abstractions for different music sources
|
||||
//! in the PMOMusic ecosystem, such as Radio Paradise, Qobuz, etc.
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! - **FIFO Support**: Dynamic audio sources using `pmoplaylist` for streaming.
|
||||
//! - **Container/Item Navigation**: Browse and search using DIDL-Lite format (`pmodidl`).
|
||||
//! - **Cache Integration**: Automatic URI resolution with `pmoaudiocache` and `pmocovers`.
|
||||
//! - **Change Tracking**: `update_id` and `last_change` for UPnP notifications.
|
||||
//! - **Send + Sync**: Ready for async servers.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! See the [examples/radio_paradise.rs](../examples/radio_paradise.rs) for a complete implementation.
|
||||
|
||||
use pmodidl::{Container, Item};
|
||||
use std::fmt::Debug;
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// Standard size for default images (300x300 pixels)
|
||||
pub const DEFAULT_IMAGE_SIZE: u32 = 300;
|
||||
@@ -21,19 +35,174 @@ pub enum MusicSourceError {
|
||||
|
||||
#[error("Source not available: {0}")]
|
||||
SourceUnavailable(String),
|
||||
|
||||
#[error("Object not found: {0}")]
|
||||
ObjectNotFound(String),
|
||||
|
||||
#[error("Browse error: {0}")]
|
||||
BrowseError(String),
|
||||
|
||||
#[error("Search not supported")]
|
||||
SearchNotSupported,
|
||||
|
||||
#[error("FIFO not supported")]
|
||||
FifoNotSupported,
|
||||
|
||||
#[error("Cache error: {0}")]
|
||||
CacheError(String),
|
||||
|
||||
#[error("URI resolution failed: {0}")]
|
||||
UriResolutionError(String),
|
||||
}
|
||||
|
||||
/// Result type for music source operations
|
||||
pub type Result<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
|
||||
///
|
||||
/// This trait defines the common interface that all music sources must implement.
|
||||
/// It provides methods for:
|
||||
/// - Getting the source name and identification
|
||||
/// - Retrieving default images/logos
|
||||
/// - Other common operations (to be extended)
|
||||
/// - Browsing containers and items (ContentDirectory)
|
||||
/// - Resolving audio URIs (using caches when available)
|
||||
/// - Managing FIFO playlists for dynamic sources
|
||||
/// - Tracking changes via `update_id` and `last_change`
|
||||
///
|
||||
/// # Thread Safety
|
||||
///
|
||||
/// All implementations must be `Send + Sync` for use in async servers.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pmosource::{MusicSource, BrowseResult, Result};
|
||||
/// use pmodidl::{Container, Item};
|
||||
/// use std::time::SystemTime;
|
||||
///
|
||||
/// #[derive(Debug)]
|
||||
/// struct RadioParadise {
|
||||
/// // implementation details
|
||||
/// }
|
||||
///
|
||||
/// #[async_trait::async_trait]
|
||||
/// impl MusicSource for RadioParadise {
|
||||
/// fn name(&self) -> &str {
|
||||
/// "Radio Paradise"
|
||||
/// }
|
||||
///
|
||||
/// fn id(&self) -> &str {
|
||||
/// "radio-paradise"
|
||||
/// }
|
||||
///
|
||||
/// fn default_image(&self) -> &[u8] {
|
||||
/// // WebP image bytes
|
||||
/// &[]
|
||||
/// }
|
||||
///
|
||||
/// async fn root_container(&self) -> Result<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 {
|
||||
// ============= Basic Information =============
|
||||
|
||||
/// Returns the human-readable name of the music source
|
||||
///
|
||||
/// # Examples
|
||||
@@ -79,8 +248,212 @@ pub trait MusicSource: Debug + Send + Sync {
|
||||
fn default_image_mime_type(&self) -> &str {
|
||||
"image/webp"
|
||||
}
|
||||
|
||||
// ============= ContentDirectory Navigation =============
|
||||
|
||||
/// Returns the root container for this source
|
||||
///
|
||||
/// This container is exposed at the top level of the ContentDirectory.
|
||||
/// Its `id` should be unique across all sources, typically the source id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A `Container` representing the root of this source's hierarchy.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let root = source.root_container().await?;
|
||||
/// assert_eq!(root.id, "radio-paradise");
|
||||
/// assert_eq!(root.title, "Radio Paradise");
|
||||
/// ```
|
||||
async fn root_container(&self) -> Result<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -88,6 +461,7 @@ mod tests {
|
||||
#[derive(Debug)]
|
||||
struct TestSource;
|
||||
|
||||
#[async_trait]
|
||||
impl MusicSource for TestSource {
|
||||
fn name(&self) -> &str {
|
||||
"Test Source"
|
||||
@@ -100,13 +474,112 @@ mod tests {
|
||||
fn default_image(&self) -> &[u8] {
|
||||
&[]
|
||||
}
|
||||
|
||||
async fn root_container(&self) -> Result<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![],
|
||||
})
|
||||
}
|
||||
|
||||
async fn browse(&self, _object_id: &str) -> Result<BrowseResult> {
|
||||
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![])
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_music_source_trait() {
|
||||
#[tokio::test]
|
||||
async fn test_music_source_trait() {
|
||||
let source = TestSource;
|
||||
assert_eq!(source.name(), "Test Source");
|
||||
assert_eq!(source.id(), "test-source");
|
||||
assert_eq!(source.default_image_mime_type(), "image/webp");
|
||||
assert!(!source.supports_fifo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_root_container() {
|
||||
let source = TestSource;
|
||||
let root = source.root_container().await.unwrap();
|
||||
assert_eq!(root.id, "test-source");
|
||||
assert_eq!(root.title, "Test Source");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_browse_result() {
|
||||
let items = vec![];
|
||||
let result = BrowseResult::Items(items);
|
||||
assert_eq!(result.count(), 0);
|
||||
assert_eq!(result.items().len(), 0);
|
||||
assert_eq!(result.containers().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_not_supported() {
|
||||
let source = TestSource;
|
||||
let result = source.search("test").await;
|
||||
assert!(matches!(result, Err(MusicSourceError::SearchNotSupported)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fifo_not_supported() {
|
||||
let source = TestSource;
|
||||
|
||||
let item = Item {
|
||||
id: "test-1".to_string(),
|
||||
parent_id: "0".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
title: "Test".to_string(),
|
||||
creator: None,
|
||||
class: "object.item.audioItem.musicTrack".to_string(),
|
||||
artist: None,
|
||||
album: None,
|
||||
genre: None,
|
||||
album_art: None,
|
||||
album_art_pk: None,
|
||||
date: None,
|
||||
original_track_number: None,
|
||||
resources: vec![],
|
||||
descriptions: vec![],
|
||||
};
|
||||
|
||||
let result = source.append_track(item).await;
|
||||
assert!(matches!(result, Err(MusicSourceError::FifoNotSupported)));
|
||||
|
||||
let result = source.remove_oldest().await;
|
||||
assert!(matches!(result, Err(MusicSourceError::FifoNotSupported)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user