Files
pmomusic/pmoparadise/src/source.rs

630 lines
21 KiB
Rust
Raw Normal View History

2025-10-16 22:00:35 +02:00
//! Music source implementation for Radio Paradise
//!
//! This module implements the [`pmosource::MusicSource`] trait for Radio Paradise,
2025-10-16 22:13:00 +02:00
//! providing a complete music source with FIFO playlist support, browsing, and caching.
2025-10-16 22:00:35 +02:00
2025-10-16 22:13:00 +02:00
use crate::client::RadioParadiseClient;
use crate::models::{Block, Song};
use pmosource::{async_trait, pmodidl, BrowseResult, MusicSource, MusicSourceError, Result};
2025-10-17 23:45:01 +02:00
use pmosource::SourceCacheManager;
use pmoaudiocache::{AudioMetadata, Cache as AudioCache};
use pmocovers::Cache as CoverCache;
2025-10-16 22:13:00 +02:00
use pmodidl::{Container, Item, Resource};
use pmoplaylist::{FifoPlaylist, Track};
use std::sync::Arc;
use std::time::SystemTime;
2025-10-16 22:00:35 +02:00
/// Default image for Radio Paradise (300x300 WebP, embedded in binary)
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
2025-10-16 22:13:00 +02:00
/// 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 combines a [`RadioParadiseClient`] for API access with a FIFO playlist
/// for dynamic track management, implementing the complete [`MusicSource`] trait.
2025-10-16 22:00:35 +02:00
///
2025-10-16 22:13:00 +02:00
/// # 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
2025-10-16 22:00:35 +02:00
///
/// # Examples
///
2025-10-16 22:13:00 +02:00
/// ```no_run
/// use pmoparadise::{RadioParadiseSource, RadioParadiseClient};
2025-10-16 22:00:35 +02:00
/// use pmosource::MusicSource;
///
2025-10-16 22:13:00 +02:00
/// #[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);
///
/// println!("Source: {}", source.name());
/// println!("Supports FIFO: {}", source.supports_fifo());
2025-10-16 22:00:35 +02:00
///
2025-10-16 22:13:00 +02:00
/// // Start streaming and the FIFO will be populated
/// Ok(())
/// }
2025-10-16 22:00:35 +02:00
/// ```
2025-10-16 22:13:00 +02:00
#[derive(Clone)]
pub struct RadioParadiseSource {
inner: Arc<RadioParadiseSourceInner>,
}
struct RadioParadiseSourceInner {
/// Radio Paradise API client
client: RadioParadiseClient,
/// FIFO playlist for dynamic track management
playlist: FifoPlaylist,
2025-10-17 23:45:01 +02:00
/// Cache manager (centralisé)
cache_manager: SourceCacheManager,
2025-10-16 22:13:00 +02:00
2025-10-17 23:45:01 +02:00
/// Blocks cache pour retrouver les métadonnées originales
/// (track_id -> (block, song_index))
blocks: tokio::sync::RwLock<std::collections::HashMap<String, (Arc<Block>, usize)>>,
2025-10-16 22:13:00 +02:00
}
impl std::fmt::Debug for RadioParadiseSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RadioParadiseSource")
.finish()
}
}
impl RadioParadiseSource {
2025-10-17 23:45:01 +02:00
/// Create a new Radio Paradise source with caches
2025-10-16 22:13:00 +02:00
///
/// # 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
2025-10-17 23:45:01 +02:00
/// * `cover_cache` - Cover image cache (required)
/// * `audio_cache` - Audio cache (required)
2025-10-16 22:13:00 +02:00
pub fn new(
client: RadioParadiseClient,
cache_base_url: impl Into<String>,
fifo_capacity: usize,
2025-10-17 23:45:01 +02:00
cover_cache: Arc<CoverCache>,
audio_cache: Arc<AudioCache>,
2025-10-16 22:13:00 +02:00
) -> Self {
let playlist = FifoPlaylist::new(
"radio-paradise".to_string(),
"Radio Paradise".to_string(),
fifo_capacity,
DEFAULT_IMAGE,
);
2025-10-17 23:45:01 +02:00
let cache_base_url = cache_base_url.into();
let cache_manager = SourceCacheManager::new(
cache_base_url.clone(),
"radio-paradise".to_string(),
cover_cache,
audio_cache,
);
2025-10-16 22:13:00 +02:00
Self {
inner: Arc::new(RadioParadiseSourceInner {
client,
playlist,
2025-10-17 23:45:01 +02:00
cache_manager,
blocks: tokio::sync::RwLock::new(std::collections::HashMap::new()),
2025-10-16 22:13:00 +02:00
}),
}
}
/// Create with default FIFO capacity
2025-10-17 23:45:01 +02:00
pub fn new_default(
2025-10-16 22:13:00 +02:00
client: RadioParadiseClient,
cache_base_url: impl Into<String>,
2025-10-17 23:45:01 +02:00
cover_cache: Arc<CoverCache>,
audio_cache: Arc<AudioCache>,
2025-10-16 22:13:00 +02:00
) -> Self {
2025-10-17 23:45:01 +02:00
Self::new(client, cache_base_url, DEFAULT_FIFO_CAPACITY, cover_cache, audio_cache)
2025-10-16 22:13:00 +02:00
}
/// 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);
}
2025-10-17 23:45:01 +02:00
// 1. Cache cover via le manager
let cached_cover_pk = if let Some(ref image_base) = block.image_base {
if let Some(ref cover) = song.cover {
let image_url = format!("{}{}", image_base, cover);
match self.inner.cache_manager.cache_cover(&image_url).await {
Ok(pk) => {
// Use the cached cover URL
let cached_url = self.inner.cache_manager.cover_url(&pk, None);
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
2025-10-16 22:13:00 +02:00
}
}
} else {
None
}
} else {
None
};
2025-10-17 23:45:01 +02:00
// 2. Cache audio via le manager (métadonnées pour compatibilité)
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,
};
2025-10-16 22:13:00 +02:00
2025-10-17 23:45:01 +02:00
let cached_audio_pk = match self.inner.cache_manager.cache_audio(&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
2025-10-16 22:13:00 +02:00
}
};
2025-10-17 23:45:01 +02:00
// 3. Store metadata in the cache manager
self.inner.cache_manager.update_metadata(
track_id.clone(),
pmosource::TrackMetadata {
original_uri: block.url.clone(),
cached_audio_pk,
cached_cover_pk,
}
).await;
// 4. Store block for later retrieval
2025-10-16 22:13:00 +02:00
{
2025-10-17 23:45:01 +02:00
let mut blocks = self.inner.blocks.write().await;
blocks.insert(track_id.clone(), (block.clone(), song_index));
2025-10-16 22:13:00 +02:00
}
2025-10-17 23:45:01 +02:00
// 5. Add to FIFO
2025-10-16 22:13:00 +02:00
self.inner.playlist.append_track(track).await;
Ok(())
}
/// 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)
});
2025-10-16 22:00:35 +02:00
2025-10-16 22:13:00 +02:00
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]
2025-10-16 22:00:35 +02:00
impl MusicSource for RadioParadiseSource {
fn name(&self) -> &str {
"Radio Paradise"
}
fn id(&self) -> &str {
"radio-paradise"
}
fn default_image(&self) -> &[u8] {
DEFAULT_IMAGE
}
2025-10-16 22:13:00 +02:00
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> {
2025-10-17 23:45:01 +02:00
// Delegate to cache manager
self.inner.cache_manager.resolve_uri(object_id).await
2025-10-16 22:13:00 +02:00
}
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 {
2025-10-17 23:45:01 +02:00
// Remove from caches
self.inner.cache_manager.remove_track(&track.id).await;
let mut blocks = self.inner.blocks.write().await;
blocks.remove(&track.id);
2025-10-16 22:13:00 +02:00
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)
}
2025-10-17 08:19:10 +02:00
// ============= Extended Features Implementation =============
fn capabilities(&self) -> pmosource::SourceCapabilities {
pmosource::SourceCapabilities {
supports_fifo: true,
supports_search: false,
supports_favorites: false,
supports_playlists: false,
supports_user_content: false,
supports_high_res_audio: true,
max_sample_rate: Some(96_000), // Radio Paradise FLAC is typically 44.1 or 48 kHz, up to 96 kHz
supports_multiple_formats: true,
supports_advanced_search: false,
supports_pagination: false,
}
}
async fn get_available_formats(&self, _object_id: &str) -> Result<Vec<pmosource::AudioFormat>> {
use pmosource::AudioFormat;
// Radio Paradise offers 5 quality levels
Ok(vec![
AudioFormat {
format_id: "mp3-128".to_string(),
mime_type: "audio/mpeg".to_string(),
sample_rate: Some(44100),
bit_depth: None,
bitrate: Some(128),
channels: Some(2),
},
AudioFormat {
format_id: "aac-64".to_string(),
mime_type: "audio/aac".to_string(),
sample_rate: Some(44100),
bit_depth: None,
bitrate: Some(64),
channels: Some(2),
},
AudioFormat {
format_id: "aac-128".to_string(),
mime_type: "audio/aac".to_string(),
sample_rate: Some(44100),
bit_depth: None,
bitrate: Some(128),
channels: Some(2),
},
AudioFormat {
format_id: "aac-320".to_string(),
mime_type: "audio/aac".to_string(),
sample_rate: Some(44100),
bit_depth: None,
bitrate: Some(320),
channels: Some(2),
},
AudioFormat {
format_id: "flac".to_string(),
mime_type: "audio/flac".to_string(),
sample_rate: Some(44100),
bit_depth: Some(16),
bitrate: None,
channels: Some(2),
},
])
}
async fn get_cache_status(&self, object_id: &str) -> Result<pmosource::CacheStatus> {
2025-10-17 23:45:01 +02:00
// Delegate to cache manager
self.inner.cache_manager.get_cache_status(object_id).await
2025-10-17 08:19:10 +02:00
}
async fn cache_item(&self, object_id: &str) -> Result<pmosource::CacheStatus> {
2025-10-17 23:45:01 +02:00
use pmosource::CacheStatus;
2025-10-17 08:19:10 +02:00
2025-10-17 23:45:01 +02:00
// Check if already cached
let status = self.inner.cache_manager.get_cache_status(object_id).await?;
if matches!(status, CacheStatus::Cached { .. }) {
return Ok(status);
}
2025-10-17 08:19:10 +02:00
2025-10-17 23:45:01 +02:00
// Get metadata and block info
let metadata = self.inner.cache_manager.get_metadata(object_id).await
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
let blocks = self.inner.blocks.read().await;
let (block, song_index) = blocks.get(object_id)
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
let song = block.get_song(*song_index)
.ok_or_else(|| MusicSourceError::ObjectNotFound(format!("Song {} not found", object_id)))?;
// Prepare metadata
let audio_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,
};
2025-10-17 08:19:10 +02:00
2025-10-17 23:45:01 +02:00
// Cache via manager
match self.inner.cache_manager.cache_audio(&metadata.original_uri, Some(audio_metadata)).await {
Ok(pk) => {
// Update metadata with new pk
let mut updated = metadata;
updated.cached_audio_pk = Some(pk);
self.inner.cache_manager.update_metadata(object_id.to_string(), updated).await;
self.get_cache_status(object_id).await
2025-10-17 08:19:10 +02:00
}
2025-10-17 23:45:01 +02:00
Err(e) => Ok(CacheStatus::Failed { error: e.to_string() }),
2025-10-17 08:19:10 +02:00
}
}
async fn browse_paginated(
&self,
object_id: &str,
offset: usize,
limit: usize,
) -> Result<BrowseResult> {
// For Radio Paradise, we can efficiently paginate the FIFO
if object_id == "radio-paradise" || object_id == "0" {
let tracks = self.inner.playlist.get_items(offset, limit).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 get_item_count(&self, object_id: &str) -> Result<usize> {
if object_id == "radio-paradise" || object_id == "0" {
Ok(self.inner.playlist.len().await)
} else {
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
}
}
async fn statistics(&self) -> Result<pmosource::SourceStatistics> {
2025-10-17 23:45:01 +02:00
let cache_stats = self.inner.cache_manager.statistics().await;
2025-10-17 08:19:10 +02:00
2025-10-17 23:45:01 +02:00
Ok(pmosource::SourceStatistics {
total_items: Some(self.inner.playlist.len().await),
cached_items: Some(cache_stats.cached_tracks),
..Default::default()
})
2025-10-17 08:19:10 +02:00
}
2025-10-16 22:00:35 +02:00
}
#[cfg(test)]
mod tests {
use super::*;
2025-10-17 23:45:01 +02:00
// Helper to create test caches (requires actual directories in tests)
async fn create_test_caches() -> (Arc<CoverCache>, Arc<AudioCache>) {
let temp_dir = std::env::temp_dir();
let cover_dir = temp_dir.join("test_covers");
let audio_dir = temp_dir.join("test_audio");
std::fs::create_dir_all(&cover_dir).ok();
std::fs::create_dir_all(&audio_dir).ok();
let cover_cache = Arc::new(
pmocovers::Cache::new(cover_dir.to_str().unwrap(), 100, "http://localhost:8080")
.await.unwrap()
);
let audio_cache = Arc::new(
pmoaudiocache::new_cache(audio_dir.to_str().unwrap(), 100, "http://localhost:8080")
.unwrap()
);
(cover_cache, audio_cache)
}
2025-10-16 22:13:00 +02:00
#[tokio::test]
async fn test_source_info() {
let client = RadioParadiseClient::with_client(reqwest::Client::new());
2025-10-17 23:45:01 +02:00
let (cover_cache, audio_cache) = create_test_caches().await;
let source = RadioParadiseSource::new_default(
client,
"http://localhost:8080",
cover_cache,
audio_cache
);
2025-10-16 22:13:00 +02:00
2025-10-16 22:00:35 +02:00
assert_eq!(source.name(), "Radio Paradise");
assert_eq!(source.id(), "radio-paradise");
assert_eq!(source.default_image_mime_type(), "image/webp");
2025-10-16 22:13:00 +02:00
assert!(source.supports_fifo());
2025-10-16 22:00:35 +02:00
}
#[test]
fn test_default_image_present() {
2025-10-16 22:13:00 +02:00
assert!(DEFAULT_IMAGE.len() > 0, "Default image should not be empty");
2025-10-16 22:00:35 +02:00
// Check WebP magic bytes (RIFF...WEBP)
2025-10-16 22:13:00 +02:00
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());
2025-10-17 23:45:01 +02:00
let (cover_cache, audio_cache) = create_test_caches().await;
let source = RadioParadiseSource::new_default(
client,
"http://localhost:8080",
cover_cache,
audio_cache
);
2025-10-16 22:13:00 +02:00
// 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);
2025-10-16 22:00:35 +02:00
}
}