467 lines
14 KiB
Rust
467 lines
14 KiB
Rust
|
|
//! # 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(())
|
||
|
|
}
|