Refactoring des pmosource

This commit is contained in:
2025-10-17 23:45:01 +02:00
parent b9e998fa8a
commit e0a4eff6dd
14 changed files with 865 additions and 703 deletions

5
Cargo.lock generated
View File

@@ -2485,6 +2485,7 @@ dependencies = [
name = "pmoupnp"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"base64",
"bevy_reflect",
@@ -2493,7 +2494,10 @@ dependencies = [
"hex",
"once_cell",
"parking_lot",
"pmoaudiocache",
"pmocache",
"pmoconfig",
"pmocovers",
"pmodidl",
"pmoserver",
"pmoutils",
@@ -2505,6 +2509,7 @@ dependencies = [
"tokio",
"tracing",
"url",
"utoipa",
"uuid",
"xmltree",
]

View File

@@ -134,12 +134,6 @@ pub mod cache;
pub mod metadata;
pub mod flac;
#[cfg(feature = "pmoserver")]
mod pmoserver_ext;
#[cfg(feature = "pmoserver")]
mod pmoserver_impl;
#[cfg(feature = "pmoserver")]
pub mod openapi;
@@ -148,7 +142,67 @@ pub use cache::{Cache, AudioConfig, new_cache, add_with_metadata_extraction, get
pub use metadata::AudioMetadata;
#[cfg(feature = "pmoserver")]
pub use pmoserver_ext::AudioCacheExt;
pub use openapi::ApiDoc;
// ============================================================================
// Extension pmoserver (inline comme pmocovers)
// ============================================================================
/// Trait pour étendre un serveur HTTP avec des fonctionnalités de cache audio.
#[cfg(feature = "pmoserver")]
pub trait AudioCacheExt {
/// Initialise le cache audio et enregistre les routes HTTP.
///
/// # Arguments
///
/// * `cache_dir` - Répertoire de stockage du cache
/// * `limit` - Limite de taille du cache (en nombre de pistes)
///
/// # Returns
///
/// * `Arc<Cache>` - Instance partagée du cache
async fn init_audio_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result<std::sync::Arc<Cache>>;
/// Initialise le cache audio avec la configuration par défaut.
///
/// Utilise automatiquement les paramètres de `pmoconfig::Config`.
async fn init_audio_cache_configured(&mut self) -> anyhow::Result<std::sync::Arc<Cache>>;
}
#[cfg(feature = "pmoserver")]
pub use openapi::ApiDoc;
use pmocache::pmoserver_ext::{create_file_router, create_api_router};
#[cfg(feature = "pmoserver")]
use std::sync::Arc;
#[cfg(feature = "pmoserver")]
use utoipa::OpenApi;
#[cfg(feature = "pmoserver")]
impl AudioCacheExt for pmoserver::Server {
async fn init_audio_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result<Arc<Cache>> {
let base_url = self.info().base_url;
let cache = Arc::new(crate::cache::new_cache(cache_dir, limit, &base_url)?);
// Router de fichiers pour servir les pistes FLAC
// Routes: GET /audio/tracks/{pk} et GET /audio/tracks/{pk}/{param}
let file_router = create_file_router(
cache.clone(),
"audio/flac" // Content-Type
);
self.add_router("/", file_router).await;
// API REST générique (pmocache)
// Routes: GET/POST/DELETE /api/audio, etc.
let api_router = create_api_router(cache.clone());
let openapi = crate::ApiDoc::openapi();
self.add_openapi(api_router, openapi, "audio").await;
Ok(cache)
}
async fn init_audio_cache_configured(&mut self) -> anyhow::Result<Arc<Cache>> {
let config = pmoconfig::get_config();
let cache_dir = config.get_audio_cache_dir()?;
let limit = config.get_audio_cache_size()?;
self.init_audio_cache(&cache_dir, limit).await
}
}

View File

@@ -1,26 +0,0 @@
#[cfg(feature = "pmoserver")]
use crate::Cache;
/// Trait pour étendre un serveur HTTP avec des fonctionnalités de cache audio.
///
/// Ce trait permet à `pmoaudiocache` d'ajouter des méthodes d'extension sur des types
/// de serveurs externes (comme `pmoserver::Server`) sans que ces crates dépendent de `pmoaudiocache`.
#[cfg(feature = "pmoserver")]
pub trait AudioCacheExt {
/// Initialise le cache audio et enregistre les routes HTTP.
///
/// # Arguments
///
/// * `cache_dir` - Répertoire de stockage du cache
/// * `limit` - Limite de taille du cache (en nombre de pistes)
///
/// # Returns
///
/// * `Arc<Cache>` - Instance partagée du cache
async fn init_audio_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result<std::sync::Arc<Cache>>;
/// Initialise le cache audio avec la configuration par défaut.
///
/// Utilise automatiquement les paramètres de `pmoconfig::Config`.
async fn init_audio_cache_configured(&mut self) -> anyhow::Result<std::sync::Arc<Cache>>;
}

View File

@@ -1,41 +0,0 @@
//! Implémentation du trait AudioCacheExt pour pmoserver::Server
#[cfg(feature = "pmoserver")]
use crate::{AudioCacheExt, Cache};
#[cfg(feature = "pmoserver")]
use pmocache::pmoserver_ext::{create_file_router, create_api_router};
#[cfg(feature = "pmoserver")]
use std::sync::Arc;
#[cfg(feature = "pmoserver")]
use utoipa::OpenApi;
#[cfg(feature = "pmoserver")]
impl AudioCacheExt for pmoserver::Server {
async fn init_audio_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result<Arc<Cache>> {
let base_url = self.info().base_url;
let cache = Arc::new(crate::cache::new_cache(cache_dir, limit, &base_url)?);
// Router de fichiers pour servir les pistes FLAC
// Routes: GET /audio/tracks/{pk} et GET /audio/tracks/{pk}/{param}
let file_router = create_file_router(
cache.clone(),
"audio/flac" // Content-Type
);
self.add_router("/", file_router).await;
// API REST générique (pmocache)
// Routes: GET/POST/DELETE /api/audio, etc.
let api_router = create_api_router(cache.clone());
let openapi = crate::ApiDoc::openapi();
self.add_openapi(api_router, openapi, "audio").await;
Ok(cache)
}
async fn init_audio_cache_configured(&mut self) -> anyhow::Result<Arc<Cache>> {
let config = pmoconfig::get_config();
let cache_dir = config.get_audio_cache_dir()?;
let limit = config.get_audio_cache_size()?;
self.init_audio_cache(&cache_dir, limit).await
}
}

View File

@@ -28,8 +28,8 @@ anyhow = "1.0"
bytes = "1.5"
futures = "0.3"
# Logging (optionnel)
tracing = { version = "0.1", optional = true }
# Logging
tracing = "0.1"
# URL manipulation
url = "2.5"
@@ -51,9 +51,9 @@ pmosource = { path = "../pmosource" }
# Playlist management for FIFO support
pmoplaylist = { path = "../pmoplaylist" }
# Cache support
pmocovers = { path = "../pmocovers", optional = true }
pmoaudiocache = { path = "../pmoaudiocache", optional = true }
# Cache support (OBLIGATOIRE - architecture refactorisée)
pmocovers = { path = "../pmocovers" }
pmoaudiocache = { path = "../pmoaudiocache" }
[features]
default = ["metadata-only"]
@@ -61,12 +61,10 @@ default = ["metadata-only"]
metadata-only = []
# Active le décodage FLAC par-track
per-track = ["dep:claxon", "dep:hound", "dep:tempfile"]
# Active le logging
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"]
# Feature cache (deprecated - toujours actif maintenant)
cache = []
[dev-dependencies]
# Tests

View File

@@ -6,17 +6,13 @@
use crate::client::RadioParadiseClient;
use crate::models::{Block, Song};
use pmosource::{async_trait, pmodidl, BrowseResult, MusicSource, MusicSourceError, Result};
use pmosource::SourceCacheManager;
use pmoaudiocache::{AudioMetadata, Cache as AudioCache};
use pmocovers::Cache as CoverCache;
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");
@@ -67,53 +63,37 @@ struct RadioParadiseSourceInner {
/// FIFO playlist for dynamic track management
playlist: FifoPlaylist,
/// Cache server base URL for URI resolution
cache_base_url: String,
/// Cache manager (centralisé)
cache_manager: SourceCacheManager,
/// 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>,
/// 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)>>,
}
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
/// Create a new Radio Paradise source with caches
///
/// # 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` - Cover image cache (required)
/// * `audio_cache` - Audio cache (required)
pub fn new(
client: RadioParadiseClient,
cache_base_url: impl Into<String>,
fifo_capacity: usize,
cover_cache: Arc<CoverCache>,
audio_cache: Arc<AudioCache>,
) -> Self {
let playlist = FifoPlaylist::new(
"radio-paradise".to_string(),
@@ -122,59 +102,32 @@ impl RadioParadiseSource {
DEFAULT_IMAGE,
);
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,
);
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,
cache_manager,
blocks: tokio::sync::RwLock::new(std::collections::HashMap::new()),
}),
}
}
/// 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(
pub fn new_default(
client: RadioParadiseClient,
cache_base_url: impl Into<String>,
fifo_capacity: usize,
cover_cache: Option<Arc<CoverCache>>,
audio_cache: Option<Arc<AudioCache>>,
cover_cache: Arc<CoverCache>,
audio_cache: 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,
}),
}
Self::new(client, cache_base_url, DEFAULT_FIFO_CAPACITY, cover_cache, audio_cache)
}
/// Add a track from a Radio Paradise song and block
@@ -199,30 +152,24 @@ impl RadioParadiseSource {
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);
// 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);
// 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
}
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
}
} else {
None
}
} else {
None
@@ -231,100 +178,68 @@ impl RadioParadiseSource {
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
// 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,
};
// Store metadata
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
}
};
// 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
{
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,
},
);
let mut blocks = self.inner.blocks.write().await;
blocks.insert(track_id.clone(), (block.clone(), song_index));
}
// Add to FIFO
// 5. 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| {
@@ -398,25 +313,8 @@ impl MusicSource for RadioParadiseSource {
}
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()))
}
// Delegate to cache manager
self.inner.cache_manager.resolve_uri(object_id).await
}
fn supports_fifo(&self) -> bool {
@@ -471,11 +369,10 @@ impl MusicSource for RadioParadiseSource {
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);
}
// Remove from caches
self.inner.cache_manager.remove_track(&track.id).await;
let mut blocks = self.inner.blocks.write().await;
blocks.remove(&track.id);
Ok(Some(self.track_to_item(&track)))
} else {
@@ -567,112 +464,56 @@ impl MusicSource for RadioParadiseSource {
}
async fn get_cache_status(&self, object_id: &str) -> Result<pmosource::CacheStatus> {
use pmosource::CacheStatus;
let cache = self.inner.track_cache.read().await;
if let Some(metadata) = cache.get(object_id) {
#[cfg(feature = "cache")]
{
if let Some(ref audio_cache) = self.inner.audio_cache {
if let Some(ref pk) = metadata.cached_audio_pk {
// Check if the cached file exists and get its size
if let Ok(Some(info)) = audio_cache.get_info(pk).await {
return Ok(CacheStatus::Cached {
size_bytes: info.size_bytes,
});
}
}
}
}
// Check legacy cached_pk for backward compatibility
if metadata.cached_pk.is_some() {
// We don't have size info for legacy cache
return Ok(CacheStatus::Cached { size_bytes: 0 });
}
}
Ok(CacheStatus::NotCached)
// Delegate to cache manager
self.inner.cache_manager.get_cache_status(object_id).await
}
async fn cache_item(&self, object_id: &str) -> Result<pmosource::CacheStatus> {
#[cfg(not(feature = "cache"))]
{
let _ = object_id;
return Err(MusicSourceError::NotSupported("Caching not enabled".to_string()));
use pmosource::CacheStatus;
// Check if already cached
let status = self.inner.cache_manager.get_cache_status(object_id).await?;
if matches!(status, CacheStatus::Cached { .. }) {
return Ok(status);
}
#[cfg(feature = "cache")]
{
use pmosource::CacheStatus;
// 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()))?;
// Get the track metadata
let cache = self.inner.track_cache.read().await;
let metadata = cache
.get(object_id)
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?
.clone();
drop(cache);
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)))?;
// If already cached, return status
if metadata.cached_audio_pk.is_some() {
return self.get_cache_status(object_id).await;
// 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,
};
// 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
}
// Cache it now
if let Some(ref audio_cache) = self.inner.audio_cache {
let song = &metadata.block.songs[metadata.song_index];
let audio_metadata = pmoaudiocache::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,
};
match audio_cache
.add_from_url(&metadata.original_uri, Some(audio_metadata))
.await
{
Ok((pk, _)) => {
// Update the metadata
let mut cache = self.inner.track_cache.write().await;
if let Some(meta) = cache.get_mut(object_id) {
meta.cached_audio_pk = Some(pk);
}
return self.get_cache_status(object_id).await;
}
Err(e) => {
return Ok(CacheStatus::Failed {
error: e.to_string(),
});
}
}
}
Ok(CacheStatus::NotCached)
Err(e) => Ok(CacheStatus::Failed { error: e.to_string() }),
}
}
@@ -701,26 +542,13 @@ impl MusicSource for RadioParadiseSource {
}
async fn statistics(&self) -> Result<pmosource::SourceStatistics> {
let mut stats = pmosource::SourceStatistics::default();
let cache_stats = self.inner.cache_manager.statistics().await;
// Total items in FIFO
stats.total_items = Some(self.inner.playlist.len().await);
// Cache statistics
#[cfg(feature = "cache")]
{
let cache = self.inner.track_cache.read().await;
let cached_count = cache.values().filter(|m| m.cached_audio_pk.is_some()).count();
stats.cached_items = Some(cached_count);
if let Some(ref audio_cache) = self.inner.audio_cache {
if let Ok(cache_stats) = audio_cache.statistics().await {
stats.cache_size_bytes = Some(cache_stats.total_size_bytes);
}
}
}
Ok(stats)
Ok(pmosource::SourceStatistics {
total_items: Some(self.inner.playlist.len().await),
cached_items: Some(cache_stats.cached_tracks),
..Default::default()
})
}
}
@@ -728,10 +556,37 @@ impl MusicSource for RadioParadiseSource {
mod tests {
use super::*;
// 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)
}
#[tokio::test]
async fn test_source_info() {
let client = RadioParadiseClient::with_client(reqwest::Client::new());
let source = RadioParadiseSource::new_default(client, "http://localhost:8080");
let (cover_cache, audio_cache) = create_test_caches().await;
let source = RadioParadiseSource::new_default(
client,
"http://localhost:8080",
cover_cache,
audio_cache
);
assert_eq!(source.name(), "Radio Paradise");
assert_eq!(source.id(), "radio-paradise");
@@ -752,7 +607,13 @@ mod tests {
#[tokio::test]
async fn test_fifo_operations() {
let client = RadioParadiseClient::with_client(reqwest::Client::new());
let source = RadioParadiseSource::new_default(client, "http://localhost:8080");
let (cover_cache, audio_cache) = create_test_caches().await;
let source = RadioParadiseSource::new_default(
client,
"http://localhost:8080",
cover_cache,
audio_cache
);
// Initially empty
let items = source.get_items(0, 10).await.unwrap();

View File

@@ -34,11 +34,11 @@ chrono = { version = "0.4", features = ["serde"] }
# Configuration
pmoconfig = { path = "../pmoconfig" }
# Intégration avec pmocovers pour le cache d'images
pmocovers = { path = "../pmocovers", optional = true }
# Intégration avec pmocovers pour le cache d'images (OBLIGATOIRE)
pmocovers = { path = "../pmocovers" }
# Intégration avec pmoaudiocache pour le cache audio
pmoaudiocache = { path = "../pmoaudiocache", optional = true }
# Intégration avec pmoaudiocache pour le cache audio (OBLIGATOIRE)
pmoaudiocache = { path = "../pmoaudiocache" }
# Intégration avec pmodidl pour l'export DIDL
pmodidl = { path = "../pmodidl" }
@@ -57,10 +57,8 @@ pmosource = { path = "../pmosource" }
default = []
# Feature pour activer les extensions pmoserver
pmoserver = ["dep:pmoserver", "dep:axum", "dep:utoipa"]
# Feature pour activer le cache d'images via pmocovers
covers = ["dep:pmocovers"]
# Feature pour activer le cache complet (images + audio)
cache = ["dep:pmocovers", "dep:pmoaudiocache"]
# Feature cache (deprecated - toujours actif maintenant)
cache = []
[dev-dependencies]
# Tests

View File

@@ -7,16 +7,12 @@ use crate::client::QobuzClient;
use crate::didl::ToDIDL;
use crate::models::Track;
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
use pmosource::SourceCacheManager;
use pmoaudiocache::{AudioMetadata, Cache as AudioCache};
use pmocovers::Cache as CoverCache;
use pmodidl::{Container, Item};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::SystemTime;
use tokio::sync::RwLock;
#[cfg(feature = "cache")]
use pmocovers::{Cache as CoverCache, ImageCacheExt};
#[cfg(feature = "cache")]
use pmoaudiocache::{AudioCache, AudioMetadata};
/// Default image for Qobuz (300x300 WebP, embedded in binary)
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
@@ -71,32 +67,12 @@ struct QobuzSourceInner {
/// Qobuz API client
client: QobuzClient,
/// Cache server base URL for URI resolution
cache_base_url: String,
/// Track metadata cache (track_id -> TrackMetadata)
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>>,
/// Cache manager (centralisé)
cache_manager: SourceCacheManager,
/// Update tracking
update_counter: RwLock<u32>,
last_change: RwLock<SystemTime>,
}
#[derive(Debug, Clone)]
struct TrackMetadata {
original_uri: String,
#[cfg(feature = "cache")]
cached_audio_pk: Option<String>,
#[cfg(feature = "cache")]
cached_cover_pk: Option<String>,
update_counter: tokio::sync::RwLock<u32>,
last_change: tokio::sync::RwLock<SystemTime>,
}
impl std::fmt::Debug for QobuzSource {
@@ -106,89 +82,34 @@ impl std::fmt::Debug for QobuzSource {
}
impl QobuzSource {
/// Create a new Qobuz source
/// Create a new Qobuz source with caches
///
/// # Arguments
///
/// * `client` - Authenticated Qobuz API client
/// * `cache_base_url` - Base URL for the cache server (e.g., "http://localhost:8080")
///
/// # 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, "http://localhost:8080");
/// Ok(())
/// }
/// ```
pub fn new(client: QobuzClient, cache_base_url: impl Into<String>) -> Self {
Self {
inner: Arc::new(QobuzSourceInner {
client,
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,
update_counter: RwLock::new(0),
last_change: RwLock::new(SystemTime::now()),
}),
}
}
/// Create a new Qobuz source with caching support
///
/// # Arguments
///
/// * `client` - Authenticated Qobuz API client
/// * `cache_base_url` - Base URL for the cache server (e.g., "http://localhost:8080")
/// * `cover_cache` - Optional cover image cache
/// * `audio_cache` - Optional audio cache
///
/// # Examples
///
/// ```no_run
/// use pmoqobuz::{QobuzSource, QobuzClient};
/// use pmocovers::Cache as CoverCache;
/// use pmoaudiocache::AudioCache;
/// use std::sync::Arc;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = QobuzClient::from_config().await?;
/// let cover_cache = Arc::new(CoverCache::new("/tmp/qobuz-covers").await?);
/// let audio_cache = Arc::new(AudioCache::new("/tmp/qobuz-audio").await?);
///
/// let source = QobuzSource::new_with_cache(
/// client,
/// "http://localhost:8080",
/// Some(cover_cache),
/// Some(audio_cache),
/// );
/// Ok(())
/// }
/// ```
#[cfg(feature = "cache")]
pub fn new_with_cache(
/// * `cover_cache` - Cover image cache (required)
/// * `audio_cache` - Audio cache (required)
pub fn new(
client: QobuzClient,
cache_base_url: impl Into<String>,
cover_cache: Option<Arc<CoverCache>>,
audio_cache: Option<Arc<AudioCache>>,
cover_cache: Arc<CoverCache>,
audio_cache: Arc<AudioCache>,
) -> Self {
let cache_base_url = cache_base_url.into();
let cache_manager = SourceCacheManager::new(
cache_base_url.clone(),
"qobuz".to_string(),
cover_cache,
audio_cache,
);
Self {
inner: Arc::new(QobuzSourceInner {
client,
cache_base_url: cache_base_url.into(),
track_cache: RwLock::new(HashMap::new()),
cover_cache,
audio_cache,
update_counter: RwLock::new(0),
last_change: RwLock::new(SystemTime::now()),
cache_manager,
update_counter: tokio::sync::RwLock::new(0),
last_change: tokio::sync::RwLock::new(SystemTime::now()),
}),
}
}
@@ -198,114 +119,56 @@ impl QobuzSource {
&self.inner.client
}
/// Add a track from Qobuz with optional caching
/// Add a track from Qobuz with caching
///
/// This method is used to add a Qobuz track to the internal cache,
/// downloading and caching both cover art and audio data if caching is enabled.
///
/// # Arguments
///
/// * `track` - The Qobuz track to add
///
/// # Returns
///
/// Returns the track ID that was used for caching.
/// This method downloads and caches both cover art and audio data.
pub async fn add_track(&self, track: &Track) -> Result<String> {
let track_id = format!("qobuz://track/{}", track.id);
// Get streaming URL
let stream_url = self
.inner
.client
.get_stream_url(&track.id)
.await
let stream_url = self.inner.client.get_stream_url(&track.id).await
.map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))?;
// Cache cover image
#[cfg(feature = "cache")]
let cached_cover_pk = if let Some(ref cover_cache) = self.inner.cover_cache {
if let Some(ref album) = track.album {
if let Some(ref image_url) = album.image {
match cover_cache.add_image_from_url(image_url).await {
Ok(pk) => {
tracing::info!("Successfully cached cover for track {}: {}", track_id, pk);
Some(pk)
}
Err(e) => {
tracing::warn!("Failed to cache cover image {}: {}", image_url, e);
None
}
}
} else {
None
}
} else {
None
}
} else {
None
// 1. Cache cover via manager
let cached_cover_pk = if let Some(ref album) = track.album {
if let Some(ref image_url) = album.image {
self.inner.cache_manager.cache_cover(image_url).await.ok()
} else { None }
} else { None };
// 2. Prepare rich metadata from Qobuz track
let metadata = AudioMetadata {
title: Some(track.title.clone()),
artist: track.performer.as_ref().map(|p| p.name.clone()),
album: track.album.as_ref().map(|a| a.title.clone()),
duration_secs: Some(track.duration as u64),
year: track.album.as_ref().and_then(|a| {
a.release_date.as_ref().and_then(|d| d.split('-').next()?.parse().ok())
}),
track_number: Some(track.track_number),
track_total: track.album.as_ref().and_then(|a| a.tracks_count),
disc_number: Some(track.media_number),
disc_total: None,
genre: track.album.as_ref().and_then(|a| {
if !a.genres.is_empty() { Some(a.genres.join(", ")) } else { None }
}),
sample_rate: track.sample_rate,
channels: track.channels,
bitrate: None,
};
// Cache audio asynchronously
#[cfg(feature = "cache")]
let cached_audio_pk = if let Some(ref audio_cache) = self.inner.audio_cache {
// Prepare rich metadata from Qobuz track
let metadata = AudioMetadata {
title: Some(track.title.clone()),
artist: track.performer.as_ref().map(|p| p.name.clone()),
album: track.album.as_ref().map(|a| a.title.clone()),
duration_secs: Some(track.duration as u64),
year: track.album.as_ref().and_then(|a| {
a.release_date.as_ref().and_then(|d| {
// Parse year from ISO date (e.g., "2023-01-15")
d.split('-').next()?.parse().ok()
})
}),
track_number: Some(track.track_number),
track_total: track.album.as_ref().and_then(|a| a.tracks_count),
disc_number: Some(track.media_number),
disc_total: None,
genre: track.album.as_ref().and_then(|a| {
if !a.genres.is_empty() {
Some(a.genres.join(", "))
} else {
None
}
}),
sample_rate: track.sample_rate,
channels: track.channels,
bitrate: None, // Qobuz doesn't provide bitrate directly
};
// 3. Cache audio via manager
let cached_audio_pk = self.inner.cache_manager.cache_audio(&stream_url, Some(metadata)).await.ok();
// Cache the audio asynchronously
match audio_cache.add_from_url(&stream_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
}
// 4. Store metadata
self.inner.cache_manager.update_metadata(
track_id.clone(),
pmosource::TrackMetadata {
original_uri: stream_url,
cached_audio_pk,
cached_cover_pk,
}
} else {
None
};
// Store metadata
{
let mut cache = self.inner.track_cache.write().await;
cache.insert(
track_id.clone(),
TrackMetadata {
original_uri: stream_url,
#[cfg(feature = "cache")]
cached_audio_pk,
#[cfg(feature = "cache")]
cached_cover_pk,
},
);
}
).await;
Ok(track_id)
}
@@ -484,33 +347,15 @@ impl MusicSource for QobuzSource {
}
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
// Check if we have cached metadata for this track
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: Return original stream URI (already fetched)
return Ok(metadata.original_uri.clone());
// Try cache manager first
if let Ok(uri) = self.inner.cache_manager.resolve_uri(object_id).await {
return Ok(uri);
}
// If not in cache, extract track ID and get streaming URL from Qobuz
// 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
};
// If not cached, extract track ID and get streaming URL from Qobuz
let track_id = object_id.strip_prefix("qobuz://track/").unwrap_or(object_id);
// Get streaming URL from Qobuz
self.inner
.client
.get_stream_url(track_id)
.await
self.inner.client.get_stream_url(track_id).await
.map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))
}
@@ -662,60 +507,22 @@ impl MusicSource for QobuzSource {
}
async fn get_cache_status(&self, object_id: &str) -> Result<pmosource::CacheStatus> {
use pmosource::CacheStatus;
let cache = self.inner.track_cache.read().await;
if let Some(metadata) = cache.get(object_id) {
#[cfg(feature = "cache")]
{
if let Some(ref _audio_cache) = self.inner.audio_cache {
if let Some(ref _pk) = metadata.cached_audio_pk {
// TODO: AudioCache doesn't have get_info method yet
// For now, just return that it's cached without size info
return Ok(CacheStatus::Cached {
size_bytes: 0,
});
}
}
}
}
Ok(CacheStatus::NotCached)
self.inner.cache_manager.get_cache_status(object_id).await
}
async fn cache_item(&self, object_id: &str) -> Result<pmosource::CacheStatus> {
#[cfg(not(feature = "cache"))]
{
let _ = object_id;
return Err(MusicSourceError::NotSupported("Caching not enabled".to_string()));
}
// Extract track ID
let track_id = object_id.strip_prefix("qobuz://track/").unwrap_or(object_id);
#[cfg(feature = "cache")]
{
use pmosource::CacheStatus;
// Get track details from Qobuz
let track = self.inner.client.get_track(track_id).await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
// Extract track ID
let track_id = if let Some(id) = object_id.strip_prefix("qobuz://track/") {
id
} else {
object_id
};
// Add track to cache (via manager)
let cached_id = self.add_track(&track).await?;
// Get track details
let track = self
.inner
.client
.get_track(track_id)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
// Add track to cache
let cached_id = self.add_track(&track).await?;
// Return the cache status
self.get_cache_status(&cached_id).await
}
// Return the cache status
self.get_cache_status(&cached_id).await
}
async fn add_favorite(&self, object_id: &str) -> Result<()> {
@@ -942,18 +749,9 @@ impl MusicSource for QobuzSource {
stats.total_items = Some(tracks.len());
}
// Get cache statistics
#[cfg(feature = "cache")]
{
let cache = self.inner.track_cache.read().await;
stats.cached_items = Some(cache.len());
// TODO: AudioCache doesn't have statistics method yet
// For now, just count cached items
if let Some(ref _audio_cache) = self.inner.audio_cache {
// stats.cache_size_bytes will remain None
}
}
// Get cache statistics from manager
let cache_stats = self.inner.cache_manager.statistics().await;
stats.cached_items = Some(cache_stats.cached_tracks);
Ok(stats)
}

242
pmosource/src/cache.rs Normal file
View File

@@ -0,0 +1,242 @@
//! Gestion du cache pour les sources musicales
//!
//! Ce module fournit `SourceCacheManager` qui permet aux sources
//! d'utiliser les caches centralisés du serveur.
//!
//! ## Architecture
//!
//! Les caches (couvertures et audio) sont centralisés au niveau du serveur UPnP.
//! Chaque source utilise ces caches partagés avec sa propre collection.
//!
//! ```text
//! UpnpServer
//! ├─ CoverCache (partagé)
//! │ ├─ collection: "radio-paradise"
//! │ └─ collection: "qobuz"
//! └─ AudioCache (partagé)
//! ├─ collection: "radio-paradise"
//! └─ collection: "qobuz"
//! ```
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use pmocovers::Cache as CoverCache;
use pmoaudiocache::{Cache as AudioCache, AudioMetadata};
use crate::{MusicSourceError, Result, CacheStatus};
/// Métadonnées d'une piste en cache
#[derive(Debug, Clone)]
pub struct TrackMetadata {
/// URI originale de la piste
pub original_uri: String,
/// Clé primaire du fichier audio en cache
pub cached_audio_pk: Option<String>,
/// Clé primaire de la couverture en cache
pub cached_cover_pk: Option<String>,
}
/// Manager centralisé pour gérer le cache d'une source
///
/// Utilise les caches centralisés du serveur avec la collection de la source.
/// Chaque source a son propre `SourceCacheManager` mais partage les mêmes
/// caches (cover et audio) avec les autres sources.
pub struct SourceCacheManager {
/// Métadonnées des pistes (track_id → metadata)
track_cache: RwLock<HashMap<String, TrackMetadata>>,
/// URL de base du serveur
cache_base_url: String,
/// ID de collection pour cette source (ex: "radio-paradise", "qobuz")
collection_id: String,
/// Référence au cache de couvertures centralisé
cover_cache: Arc<CoverCache>,
/// Référence au cache audio centralisé
audio_cache: Arc<AudioCache>,
}
impl SourceCacheManager {
/// Créer un nouveau manager
///
/// # Arguments
///
/// * `cache_base_url` - URL de base du serveur
/// * `collection_id` - ID de collection (source ID)
/// * `cover_cache` - Cache de couvertures centralisé
/// * `audio_cache` - Cache audio centralisé
pub fn new(
cache_base_url: String,
collection_id: String,
cover_cache: Arc<CoverCache>,
audio_cache: Arc<AudioCache>,
) -> Self {
Self {
track_cache: RwLock::new(HashMap::new()),
cache_base_url,
collection_id,
cover_cache,
audio_cache,
}
}
/// Résoudre l'URI d'une piste (priorité au cache)
///
/// Retourne l'URI du fichier audio en cache si disponible,
/// sinon l'URI originale.
pub async fn resolve_uri(&self, object_id: &str) -> Result<String> {
let cache = self.track_cache.read().await;
if let Some(metadata) = cache.get(object_id) {
if let Some(ref pk) = metadata.cached_audio_pk {
return Ok(format!("{}/audio/tracks/{}/stream", self.cache_base_url, pk));
}
return Ok(metadata.original_uri.clone());
}
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
}
/// Obtenir le statut du cache pour une piste
pub async fn get_cache_status(&self, object_id: &str) -> Result<CacheStatus> {
let cache = self.track_cache.read().await;
if let Some(metadata) = cache.get(object_id) {
if let Some(ref pk) = metadata.cached_audio_pk {
// TODO: Ajouter get_info() à AudioCache
// Pour l'instant, on retourne juste Cached sans taille
return Ok(CacheStatus::Cached { size_bytes: 0 });
}
}
Ok(CacheStatus::NotCached)
}
/// Cacher une couverture depuis une URL
///
/// Utilise la collection de cette source pour organiser les images.
///
/// # Returns
///
/// La clé primaire (pk) de l'image dans le cache
pub async fn cache_cover(&self, url: &str) -> Result<String> {
self.cover_cache
.add_from_url(url, Some(&self.collection_id))
.await
.map_err(|e| MusicSourceError::CacheError(e.to_string()))
}
/// Obtenir l'URL d'une couverture en cache
///
/// # Arguments
///
/// * `pk` - Clé primaire de l'image dans le cache
/// * `size` - Taille optionnelle (génère une variante si spécifiée)
///
/// # Returns
///
/// L'URL complète de l'image
pub fn cover_url(&self, pk: &str, size: Option<usize>) -> String {
if let Some(s) = size {
format!("{}/covers/images/{}/{}", self.cache_base_url, pk, s)
} else {
format!("{}/covers/images/{}", self.cache_base_url, pk)
}
}
/// Cacher une piste audio depuis une URL
///
/// Utilise la collection de cette source pour organiser les pistes.
///
/// # Arguments
///
/// * `url` - URL source de la piste
/// * `_metadata` - Métadonnées audio optionnelles (unused, kept for API compatibility)
///
/// # Returns
///
/// La clé primaire (pk) de la piste dans le cache
pub async fn cache_audio(&self, url: &str, _metadata: Option<AudioMetadata>)
-> Result<String> {
// Note: Les métadonnées seront extraites automatiquement par le cache audio
// lors de la conversion FLAC
let pk = self.audio_cache
.add_from_url(url, Some(&self.collection_id))
.await
.map_err(|e| MusicSourceError::CacheError(e.to_string()))?;
Ok(pk)
}
/// Mettre à jour les métadonnées d'une piste
///
/// Enregistre ou met à jour les métadonnées de cache pour une piste.
pub async fn update_metadata(&self, track_id: String, metadata: TrackMetadata) {
let mut cache = self.track_cache.write().await;
cache.insert(track_id, metadata);
}
/// Récupérer les métadonnées d'une piste
pub async fn get_metadata(&self, track_id: &str) -> Option<TrackMetadata> {
let cache = self.track_cache.read().await;
cache.get(track_id).cloned()
}
/// Supprimer une piste du cache
pub async fn remove_track(&self, track_id: &str) {
let mut cache = self.track_cache.write().await;
cache.remove(track_id);
}
/// Obtenir l'ID de collection
pub fn collection_id(&self) -> &str {
&self.collection_id
}
/// Obtenir les statistiques du cache pour cette source
pub async fn statistics(&self) -> CacheStatistics {
let cache = self.track_cache.read().await;
let cached_count = cache.values()
.filter(|m| m.cached_audio_pk.is_some())
.count();
CacheStatistics {
total_tracks: cache.len(),
cached_tracks: cached_count,
collection_id: self.collection_id.clone(),
}
}
}
/// Statistiques du cache pour une source
#[derive(Debug, Clone)]
pub struct CacheStatistics {
/// Nombre total de pistes connues
pub total_tracks: usize,
/// Nombre de pistes en cache
pub cached_tracks: usize,
/// ID de collection
pub collection_id: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_track_metadata() {
let metadata = TrackMetadata {
original_uri: "http://example.com/track.flac".to_string(),
cached_audio_pk: Some("abc123".to_string()),
cached_cover_pk: Some("def456".to_string()),
};
assert_eq!(metadata.original_uri, "http://example.com/track.flac");
assert_eq!(metadata.cached_audio_pk, Some("abc123".to_string()));
}
}

View File

@@ -35,6 +35,8 @@
//! server.register_music_source(Arc::new(my_source)).await;
//! ```
pub mod cache;
use pmodidl::{Container, Item};
use std::fmt::Debug;
use std::time::SystemTime;
@@ -904,6 +906,9 @@ pub use async_trait::async_trait;
pub use pmodidl;
pub use pmoplaylist;
// Re-export cache types
pub use cache::{TrackMetadata, SourceCacheManager, CacheStatistics};
// Server extension modules (feature-gated)
#[cfg(feature = "server")]
pub mod pmoserver_ext;

View File

@@ -8,12 +8,16 @@ pmoconfig = { path = "../pmoconfig" }
pmodidl = { path = "../pmodidl"}
pmoutils = { path = "../pmoutils" }
pmoserver = { path = "../pmoserver" }
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"] }
pmocache = { path = "../pmocache" }
url = "2.5.7"
uuid = "1.18.1"
hex = "0.4.3"
base64 = "0.22.1"
thiserror = "2.0.16"
anyhow = "1.0"
xmltree = "0.11.0"
axum = "0.8.4"
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync"] }
@@ -27,3 +31,4 @@ tracing = "0.1"
bevy_reflect = "0.17.1"
bevy_reflect_derive = "0.17.1"
reqwest = "0.12.23"
utoipa = { version = "5.3", features = ["axum_extras"] }

View File

@@ -0,0 +1,112 @@
//! Registre centralisé des caches pour le serveur UPnP
//!
//! Ce module gère les caches partagés entre toutes les sources musicales :
//! - Cache de couvertures d'albums (WebP)
//! - Cache de pistes audio (FLAC)
//!
//! Les caches supportent les collections, permettant à chaque source
//! d'avoir sa propre collection dans le cache partagé.
use std::sync::Arc;
use once_cell::sync::Lazy;
use std::sync::RwLock;
use pmocovers::Cache as CoverCache;
use pmoaudiocache::Cache as AudioCache;
/// Registre global des caches
///
/// Contient les instances partagées des caches de couvertures et audio.
/// Ces caches sont uniques et partagés entre toutes les sources musicales.
pub struct CacheRegistry {
/// Cache de couvertures (WebP)
cover_cache: Option<Arc<CoverCache>>,
/// Cache audio (FLAC)
audio_cache: Option<Arc<AudioCache>>,
}
impl CacheRegistry {
/// Créer un nouveau registre vide
pub fn new() -> Self {
Self {
cover_cache: None,
audio_cache: None,
}
}
/// Enregistrer le cache de couvertures
pub fn set_cover_cache(&mut self, cache: Arc<CoverCache>) {
self.cover_cache = Some(cache);
}
/// Récupérer le cache de couvertures
pub fn cover_cache(&self) -> Option<Arc<CoverCache>> {
self.cover_cache.clone()
}
/// Enregistrer le cache audio
pub fn set_audio_cache(&mut self, cache: Arc<AudioCache>) {
self.audio_cache = Some(cache);
}
/// Récupérer le cache audio
pub fn audio_cache(&self) -> Option<Arc<AudioCache>> {
self.audio_cache.clone()
}
}
impl Default for CacheRegistry {
fn default() -> Self {
Self::new()
}
}
/// Registre global thread-safe
///
/// Utilise Lazy pour une initialisation paresseuse et RwLock pour le partage entre threads.
/// Permet aux handlers et aux sources d'accéder aux caches depuis n'importe où.
pub(crate) static CACHE_REGISTRY: Lazy<RwLock<CacheRegistry>> = Lazy::new(|| {
RwLock::new(CacheRegistry::new())
});
/// Accès global au cache de couvertures
///
/// # Examples
///
/// ```rust,ignore
/// use pmoupnp::cache_registry::get_cover_cache;
///
/// if let Some(cache) = get_cover_cache() {
/// let pk = cache.add_from_url("http://example.com/cover.jpg").await?;
/// }
/// ```
pub fn get_cover_cache() -> Option<Arc<CoverCache>> {
CACHE_REGISTRY.read().unwrap().cover_cache()
}
/// Accès global au cache audio
///
/// # Examples
///
/// ```rust,ignore
/// use pmoupnp::cache_registry::get_audio_cache;
///
/// if let Some(cache) = get_audio_cache() {
/// let (pk, _) = cache.add_from_url("http://example.com/track.flac", None).await?;
/// }
/// ```
pub fn get_audio_cache() -> Option<Arc<AudioCache>> {
CACHE_REGISTRY.read().unwrap().audio_cache()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_registry_empty() {
let registry = CacheRegistry::new();
assert!(registry.cover_cache().is_none());
assert!(registry.audio_cache().is_none());
}
}

View File

@@ -1,6 +1,7 @@
mod object_trait;
mod object_set;
pub mod cache_registry;
pub mod upnp_server;
pub mod upnp_api;
pub mod actions;
@@ -12,15 +13,12 @@ pub mod state_variables;
pub mod value_ranges;
pub mod variable_types;
use std::{collections::HashMap, sync::Arc};
use std::sync::RwLock;
pub use crate::object_trait::*;
pub use crate::upnp_server::UpnpServerExt;
pub use crate::cache_registry::{get_cover_cache, get_audio_cache};
#[derive(Debug, Clone)]
pub struct UpnpObjectType {

View File

@@ -23,10 +23,15 @@ use std::sync::RwLock;
use once_cell::sync::Lazy;
use pmoserver::Server;
use utoipa::OpenApi;
use crate::devices::errors::DeviceError;
use crate::devices::{Device, DeviceInstance, DeviceRegistry};
use crate::UpnpModel;
use crate::cache_registry::CACHE_REGISTRY;
use pmocovers::Cache as CoverCache;
use pmoaudiocache::Cache as AudioCache;
/// Registre de devices global et thread-safe.
///
@@ -70,6 +75,8 @@ static DEVICE_REGISTRY: Lazy<RwLock<DeviceRegistry>> = Lazy::new(|| {
/// let devices = server.device_registry().list_devices();
/// ```
pub trait UpnpServerExt {
// ========= Device Management (existant) =========
/// Enregistre un device UPnP et toutes ses URLs.
///
/// # Arguments
@@ -89,6 +96,57 @@ pub trait UpnpServerExt {
/// Récupère un device par son UDN.
fn get_device(&self, udn: &str) -> Option<Arc<DeviceInstance>>;
// ========= Cache Management (NOUVEAU) =========
/// Initialiser le cache de couvertures centralisé
///
/// Crée le cache et enregistre les routes HTTP.
/// Toutes les sources musicales utiliseront ce cache partagé.
///
/// # Arguments
///
/// * `cache_dir` - Répertoire de stockage
/// * `limit` - Limite de taille (nombre d'images)
///
/// # Returns
///
/// Instance partagée du cache
async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize)
-> Result<Arc<CoverCache>, anyhow::Error>;
/// Initialiser le cache audio centralisé
///
/// Crée le cache et enregistre les routes HTTP.
/// Toutes les sources musicales utiliseront ce cache partagé.
///
/// # Arguments
///
/// * `cache_dir` - Répertoire de stockage
/// * `limit` - Limite de taille (nombre de pistes)
///
/// # Returns
///
/// Instance partagée du cache
async fn init_audio_cache(&mut self, cache_dir: &str, limit: usize)
-> Result<Arc<AudioCache>, anyhow::Error>;
/// Initialiser les caches depuis la configuration
///
/// Utilise pmoconfig pour charger les paramètres et initialiser
/// automatiquement les deux caches.
///
/// # Returns
///
/// Tuple (cache de couvertures, cache audio)
async fn init_caches(&mut self)
-> Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>;
/// Récupérer le cache de couvertures
fn cover_cache(&self) -> Option<Arc<CoverCache>>;
/// Récupérer le cache audio
fn audio_cache(&self) -> Option<Arc<AudioCache>>;
}
// Implémentation du trait UpnpServer pour pmoserver::Server
@@ -120,6 +178,101 @@ impl UpnpServerExt for Server {
fn get_device(&self, udn: &str) -> Option<Arc<DeviceInstance>> {
DEVICE_REGISTRY.read().unwrap().get_device(udn)
}
// ========= Cache Management Implementation =========
async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize)
-> Result<Arc<CoverCache>, anyhow::Error> {
use pmocovers::new_cache;
use pmocache::pmoserver_ext::{create_file_router_with_generator, create_api_router};
let base_url = self.info().base_url;
let cache = Arc::new(new_cache(cache_dir, limit, &base_url)?);
// Routes de fichiers avec génération de variantes
// Routes: GET /covers/image/{pk} et GET /covers/image/{pk}/{size}
let variant_generator: pmocache::pmoserver_ext::ParamGenerator<pmocovers::CoversConfig> =
Arc::new(|cache, pk, param| {
Box::pin(async move {
// Si le param est numérique, c'est une taille de variante
if let Ok(size) = param.parse::<usize>() {
match pmocovers::webp::generate_variant(&cache, &pk, size).await {
Ok(data) => return Some(data),
Err(e) => {
tracing::warn!("Cannot generate variant {}x{} for {}: {}", size, size, pk, e);
return None;
}
}
}
None
})
});
let file_router = create_file_router_with_generator(
cache.clone(),
"image/webp",
Some(variant_generator)
);
self.add_router("/", file_router).await;
// API REST générique (pmocache)
let api_router = create_api_router(cache.clone());
let openapi = pmocovers::ApiDoc::openapi();
self.add_openapi(api_router, openapi, "covers").await;
// Enregistrer dans le registre global
CACHE_REGISTRY.write().unwrap().set_cover_cache(cache.clone());
Ok(cache)
}
async fn init_audio_cache(&mut self, cache_dir: &str, limit: usize)
-> Result<Arc<AudioCache>, anyhow::Error> {
use pmoaudiocache::new_cache;
use pmocache::pmoserver_ext::{create_file_router, create_api_router};
let base_url = self.info().base_url;
let cache = Arc::new(new_cache(cache_dir, limit, &base_url)?);
// Routes de fichiers pour servir les pistes FLAC
let file_router = create_file_router(cache.clone(), "audio/flac");
self.add_router("/", file_router).await;
// API REST générique (pmocache)
let api_router = create_api_router(cache.clone());
let openapi = pmoaudiocache::ApiDoc::openapi();
self.add_openapi(api_router, openapi, "audio").await;
// Enregistrer dans le registre global
CACHE_REGISTRY.write().unwrap().set_audio_cache(cache.clone());
Ok(cache)
}
async fn init_caches(&mut self)
-> Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error> {
let config = pmoconfig::get_config();
let cover_cache = self.init_cover_cache(
&config.get_cover_cache_dir()?,
config.get_cover_cache_size()?
).await?;
let audio_cache = self.init_audio_cache(
&config.get_audio_cache_dir()?,
config.get_audio_cache_size()?
).await?;
Ok((cover_cache, audio_cache))
}
fn cover_cache(&self) -> Option<Arc<CoverCache>> {
crate::cache_registry::get_cover_cache()
}
fn audio_cache(&self) -> Option<Arc<AudioCache>> {
crate::cache_registry::get_audio_cache()
}
}
/// Fonctions helper pour accéder au registre depuis les handlers.