From d86cfe46df851c14dfbc768f014fee0d9ec9a86d Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 17 Oct 2025 23:45:01 +0200 Subject: [PATCH] Refactoring des pmosource --- Cargo.lock | 5 + pmoaudiocache/src/lib.rs | 70 +++- pmoaudiocache/src/pmoserver_ext.rs | 26 -- pmoaudiocache/src/pmoserver_impl.rs | 41 --- pmoparadise/Cargo.toml | 16 +- pmoparadise/src/source.rs | 507 ++++++++++------------------ pmoqobuz/Cargo.toml | 14 +- pmoqobuz/src/source.rs | 366 +++++--------------- pmosource/src/cache.rs | 242 +++++++++++++ pmosource/src/lib.rs | 5 + pmoupnp/Cargo.toml | 5 + pmoupnp/src/cache_registry.rs | 112 ++++++ pmoupnp/src/lib.rs | 6 +- pmoupnp/src/upnp_server.rs | 153 +++++++++ 14 files changed, 865 insertions(+), 703 deletions(-) delete mode 100644 pmoaudiocache/src/pmoserver_ext.rs delete mode 100644 pmoaudiocache/src/pmoserver_impl.rs create mode 100644 pmosource/src/cache.rs create mode 100644 pmoupnp/src/cache_registry.rs diff --git a/Cargo.lock b/Cargo.lock index 0fcc78f7..c7984a2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/pmoaudiocache/src/lib.rs b/pmoaudiocache/src/lib.rs index aa46188b..6799c2ca 100644 --- a/pmoaudiocache/src/lib.rs +++ b/pmoaudiocache/src/lib.rs @@ -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` - Instance partagée du cache + async fn init_audio_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result>; + + /// 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>; +} #[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> { + 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> { + 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 + } +} diff --git a/pmoaudiocache/src/pmoserver_ext.rs b/pmoaudiocache/src/pmoserver_ext.rs deleted file mode 100644 index 8dae7456..00000000 --- a/pmoaudiocache/src/pmoserver_ext.rs +++ /dev/null @@ -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` - Instance partagée du cache - async fn init_audio_cache(&mut self, cache_dir: &str, limit: usize) -> anyhow::Result>; - - /// 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>; -} diff --git a/pmoaudiocache/src/pmoserver_impl.rs b/pmoaudiocache/src/pmoserver_impl.rs deleted file mode 100644 index aa8a34cf..00000000 --- a/pmoaudiocache/src/pmoserver_impl.rs +++ /dev/null @@ -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> { - 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> { - 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 - } -} diff --git a/pmoparadise/Cargo.toml b/pmoparadise/Cargo.toml index fe2f6d40..c4d3cf8a 100644 --- a/pmoparadise/Cargo.toml +++ b/pmoparadise/Cargo.toml @@ -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 diff --git a/pmoparadise/src/source.rs b/pmoparadise/src/source.rs index 62bf6201..e1fa9d78 100644 --- a/pmoparadise/src/source.rs +++ b/pmoparadise/src/source.rs @@ -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>, - - /// Cover image cache (optional) - #[cfg(feature = "cache")] - cover_cache: Option>, - - /// Audio cache (optional) - #[cfg(feature = "cache")] - audio_cache: Option>, -} - -#[derive(Debug, Clone)] -struct TrackMetadata { - original_uri: String, - cached_pk: Option, - block: Arc, - song_index: usize, - #[cfg(feature = "cache")] - cached_audio_pk: Option, - #[cfg(feature = "cache")] - cached_cover_pk: Option, + /// Blocks cache pour retrouver les métadonnées originales + /// (track_id -> (block, song_index)) + blocks: tokio::sync::RwLock, 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, fifo_capacity: usize, + cover_cache: Arc, + audio_cache: Arc, ) -> 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) -> 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, - fifo_capacity: usize, - cover_cache: Option>, - audio_cache: Option>, + cover_cache: Arc, + audio_cache: Arc, ) -> 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 { - 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> { 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 { - 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 { - #[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 { - 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, Arc) { + 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(); diff --git a/pmoqobuz/Cargo.toml b/pmoqobuz/Cargo.toml index 64f34339..314b344f 100644 --- a/pmoqobuz/Cargo.toml +++ b/pmoqobuz/Cargo.toml @@ -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 diff --git a/pmoqobuz/src/source.rs b/pmoqobuz/src/source.rs index e48afc05..a794871a 100644 --- a/pmoqobuz/src/source.rs +++ b/pmoqobuz/src/source.rs @@ -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>, - - /// Cover image cache (optional) - #[cfg(feature = "cache")] - cover_cache: Option>, - - /// Audio cache (optional) - #[cfg(feature = "cache")] - audio_cache: Option>, + /// Cache manager (centralisé) + cache_manager: SourceCacheManager, /// Update tracking - update_counter: RwLock, - last_change: RwLock, -} - -#[derive(Debug, Clone)] -struct TrackMetadata { - original_uri: String, - #[cfg(feature = "cache")] - cached_audio_pk: Option, - #[cfg(feature = "cache")] - cached_cover_pk: Option, + update_counter: tokio::sync::RwLock, + last_change: tokio::sync::RwLock, } 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> { - /// 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) -> 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> { - /// 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, - cover_cache: Option>, - audio_cache: Option>, + cover_cache: Arc, + audio_cache: Arc, ) -> 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 { 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 { - // 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 { - 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 { - #[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) } diff --git a/pmosource/src/cache.rs b/pmosource/src/cache.rs new file mode 100644 index 00000000..e4b65ff6 --- /dev/null +++ b/pmosource/src/cache.rs @@ -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, + + /// Clé primaire de la couverture en cache + pub cached_cover_pk: Option, +} + +/// 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>, + + /// 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, + + /// Référence au cache audio centralisé + audio_cache: Arc, +} + +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, + audio_cache: Arc, + ) -> 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 { + 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 { + 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 { + 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) -> 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) + -> Result { + // 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 { + 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())); + } +} diff --git a/pmosource/src/lib.rs b/pmosource/src/lib.rs index 5eaa90b0..5ba38290 100644 --- a/pmosource/src/lib.rs +++ b/pmosource/src/lib.rs @@ -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; diff --git a/pmoupnp/Cargo.toml b/pmoupnp/Cargo.toml index ec4eef6b..925f80d4 100644 --- a/pmoupnp/Cargo.toml +++ b/pmoupnp/Cargo.toml @@ -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"] } diff --git a/pmoupnp/src/cache_registry.rs b/pmoupnp/src/cache_registry.rs new file mode 100644 index 00000000..94c126c0 --- /dev/null +++ b/pmoupnp/src/cache_registry.rs @@ -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>, + + /// Cache audio (FLAC) + audio_cache: Option>, +} + +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) { + self.cover_cache = Some(cache); + } + + /// Récupérer le cache de couvertures + pub fn cover_cache(&self) -> Option> { + self.cover_cache.clone() + } + + /// Enregistrer le cache audio + pub fn set_audio_cache(&mut self, cache: Arc) { + self.audio_cache = Some(cache); + } + + /// Récupérer le cache audio + pub fn audio_cache(&self) -> Option> { + 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> = 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> { + 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> { + 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()); + } +} diff --git a/pmoupnp/src/lib.rs b/pmoupnp/src/lib.rs index f845019e..52cc8507 100644 --- a/pmoupnp/src/lib.rs +++ b/pmoupnp/src/lib.rs @@ -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 { diff --git a/pmoupnp/src/upnp_server.rs b/pmoupnp/src/upnp_server.rs index d9726203..3ddf53dd 100644 --- a/pmoupnp/src/upnp_server.rs +++ b/pmoupnp/src/upnp_server.rs @@ -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> = 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>; + + // ========= 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, 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, 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, Arc), anyhow::Error>; + + /// Récupérer le cache de couvertures + fn cover_cache(&self) -> Option>; + + /// Récupérer le cache audio + fn audio_cache(&self) -> Option>; } // Implémentation du trait UpnpServer pour pmoserver::Server @@ -120,6 +178,101 @@ impl UpnpServerExt for Server { fn get_device(&self, udn: &str) -> Option> { DEVICE_REGISTRY.read().unwrap().get_device(udn) } + + // ========= Cache Management Implementation ========= + + async fn init_cover_cache(&mut self, cache_dir: &str, limit: usize) + -> Result, 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 = + 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::() { + 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, 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, Arc), 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> { + crate::cache_registry::get_cover_cache() + } + + fn audio_cache(&self) -> Option> { + crate::cache_registry::get_audio_cache() + } } /// Fonctions helper pour accéder au registre depuis les handlers.