From fef3b6f6454ef9b9c06767fb69c4b29b82b2f998 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 17 Oct 2025 07:56:21 +0200 Subject: [PATCH] =?UTF-8?q?ajoute=20=C3=A0=20pmoqobuz=20la=20feature=20cac?= =?UTF-8?q?he?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + pmoqobuz/Cargo.toml | 5 + pmoqobuz/README.md | 65 +++++++++- pmoqobuz/examples/with_cache.rs | 158 +++++++++++++++++++++++ pmoqobuz/src/lib.rs | 80 +++++++++++- pmoqobuz/src/source.rs | 220 +++++++++++++++++++++++++++++++- 6 files changed, 517 insertions(+), 12 deletions(-) create mode 100644 pmoqobuz/examples/with_cache.rs diff --git a/Cargo.lock b/Cargo.lock index f2f73927..ac31d726 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2393,6 +2393,7 @@ dependencies = [ "hex", "mockito", "moka", + "pmoaudiocache", "pmoconfig", "pmocovers", "pmodidl", diff --git a/pmoqobuz/Cargo.toml b/pmoqobuz/Cargo.toml index eb22f0e7..cc1ae03e 100644 --- a/pmoqobuz/Cargo.toml +++ b/pmoqobuz/Cargo.toml @@ -37,6 +37,9 @@ pmoconfig = { path = "../pmoconfig" } # Intégration avec pmocovers pour le cache d'images pmocovers = { path = "../pmocovers", optional = true } +# Intégration avec pmoaudiocache pour le cache audio +pmoaudiocache = { path = "../pmoaudiocache", optional = true } + # Intégration avec pmodidl pour l'export DIDL pmodidl = { path = "../pmodidl" } @@ -56,6 +59,8 @@ default = [] 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"] [dev-dependencies] # Tests diff --git a/pmoqobuz/README.md b/pmoqobuz/README.md index c508bc2c..bb500e69 100644 --- a/pmoqobuz/README.md +++ b/pmoqobuz/README.md @@ -10,8 +10,9 @@ Client Rust pour l'API Qobuz avec cache en mémoire, inspiré de l'implémentati - ✅ **Favoris** : Accès aux albums, artistes, tracks et playlists favoris - ✅ **Cache en mémoire** : Minimisation des requêtes API avec TTL configurable - ✅ **Export DIDL** : Conversion automatique en format DIDL-Lite (UPnP/DLNA) -- 🔄 **Integration pmocovers** : Cache automatique des images (feature `covers`) -- 🔄 **API HTTP** : Endpoints REST via pmoserver (feature `pmoserver`) +- ✅ **Integration pmocovers** : Cache automatique des images (feature `covers`) +- ✅ **Integration pmoaudiocache** : Cache audio haute résolution avec métadonnées (feature `cache`) +- ✅ **API HTTP** : Endpoints REST via pmoserver (feature `pmoserver`) ## Installation @@ -143,12 +144,62 @@ println!("Total: {}", stats.total_count()); client.cache().clear_all().await; ``` +## Cache avancé (feature `cache`) + +La feature `cache` active le support complet de pmocovers et pmoaudiocache pour télécharger et cacher localement les images et l'audio haute résolution : + +```rust +use pmoqobuz::{QobuzSource, QobuzClient}; +use pmocovers::Cache as CoverCache; +use pmoaudiocache::AudioCache; +use std::sync::Arc; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialize caches + let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?); + let audio_cache = Arc::new(AudioCache::new("./cache/audio", 100)?); + + // Create source with caching + let client = QobuzClient::from_config().await?; + let source = QobuzSource::new_with_cache( + client, + "http://localhost:8080", + Some(cover_cache), + Some(audio_cache), + ); + + // Add tracks with automatic caching + let tracks = source.client().get_favorite_tracks().await?; + for track in tracks.iter().take(5) { + let track_id = source.add_track(track).await?; + // Audio and cover are now cached locally + let uri = source.resolve_uri(&track_id).await?; + println!("Cached: {}", uri); + } + + Ok(()) +} +``` + +**Métadonnées enrichies préservées** : +- Titre, artiste, album +- Numéro de piste et de disque +- Année de sortie +- Genre(s) et label +- Qualité audio (sample rate, bit depth, channels) +- Durée + ## Exemples -Exécutez l'exemple : +Exécutez les exemples : ```bash +# Exemple basique cargo run --example basic_usage + +# Exemple avec cache (nécessite la feature cache) +cargo run --example with_cache --features cache ``` ## Architecture @@ -185,6 +236,12 @@ Générez la documentation : cargo doc -p pmoqobuz --open ``` +## Features + +- `covers` : Active pmocovers pour le cache d'images +- `cache` : Active pmocovers + pmoaudiocache pour le cache complet (images + audio) +- `pmoserver` : Active les endpoints REST via pmoserver + ## Dépendances principales - `reqwest` : Client HTTP @@ -193,6 +250,8 @@ cargo doc -p pmoqobuz --open - `moka` : Cache en mémoire avec TTL - `pmodidl` : Export DIDL-Lite - `pmoconfig` : Configuration +- `pmocovers` : Cache d'images (optionnel) +- `pmoaudiocache` : Cache audio (optionnel) ## Licence diff --git a/pmoqobuz/examples/with_cache.rs b/pmoqobuz/examples/with_cache.rs new file mode 100644 index 00000000..24af8aa2 --- /dev/null +++ b/pmoqobuz/examples/with_cache.rs @@ -0,0 +1,158 @@ +//! Example demonstrating Qobuz with cache support +//! +//! This example shows how to use the QobuzSource with pmocovers +//! and pmoaudiocache to cache both cover images and audio tracks. +//! +//! Run with: +//! ```bash +//! cargo run --example with_cache --features cache +//! ``` + +use pmoqobuz::{QobuzClient, QobuzSource}; +use pmocovers::Cache as CoverCache; +use pmoaudiocache::AudioCache; +use pmosource::MusicSource; +use std::sync::Arc; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing + tracing_subscriber::fmt::init(); + + println!("🎵 Qobuz with Cache Support"); + println!("============================\n"); + + // Create the Qobuz client using configuration + println!("📡 Connecting to Qobuz..."); + let client = QobuzClient::from_config().await?; + println!("✅ Connected!\n"); + + // Initialize caches + println!("💾 Initializing caches..."); + let cover_cache = Arc::new(CoverCache::new("./cache/qobuz-covers", 500)?); + let audio_cache = Arc::new(AudioCache::new("./cache/qobuz-audio", 100)?); + println!("✅ Caches initialized!\n"); + + // Create the source with caching enabled + let source = QobuzSource::new_with_cache( + client, + "http://localhost:8080", + Some(cover_cache.clone()), + Some(audio_cache.clone()), + ); + + println!("📻 Source: {}", source.name()); + println!("🆔 ID: {}", source.id()); + println!("📝 Supports FIFO: {}\n", source.supports_fifo()); + + // Get user's favorite tracks + println!("🎧 Fetching your favorite tracks..."); + let favorite_tracks = source.client().get_favorite_tracks().await?; + + if favorite_tracks.is_empty() { + println!("⚠️ No favorite tracks found. Add some favorites on Qobuz first!"); + println!("\n💡 Tip: You can also search for tracks:"); + + // Example: Search for tracks + println!("\n🔍 Searching for 'Miles Davis'..."); + let search_results = source.client().search("Miles Davis", None).await?; + + if !search_results.tracks.is_empty() { + println!("\n📋 Found {} tracks:", search_results.tracks.len()); + for (i, track) in search_results.tracks.iter().enumerate().take(3) { + println!(" {}. {} - {}", + i + 1, + track.performer.as_ref().map(|p| p.name.as_str()).unwrap_or("Unknown"), + track.title + ); + + // Demonstrate adding a track with caching + if i == 0 { + println!("\n➕ Adding first track to cache..."); + let track_id = source.add_track(track).await?; + println!("✅ Track added with ID: {}", track_id); + println!(" - Cover image caching started"); + println!(" - Audio caching started (high-quality FLAC)"); + + // Show resolved URI (will use cached version if available) + if let Ok(uri) = source.resolve_uri(&track_id).await { + println!(" - Stream URI: {}", uri); + } + } + } + } + } else { + println!("✅ Found {} favorite tracks!\n", favorite_tracks.len()); + + // Add first 3 favorite tracks with caching + for (i, track) in favorite_tracks.iter().enumerate().take(3) { + println!("{}. {} - {}", + i + 1, + track.performer.as_ref().map(|p| p.name.as_str()).unwrap_or("Unknown"), + track.title + ); + + if let Some(album) = &track.album { + println!(" Album: {}", album.title); + if let Some(label) = &album.label { + println!(" Label: {}", label); + } + if let Some(sample_rate) = album.maximum_sampling_rate { + println!(" Max Sample Rate: {} kHz", sample_rate / 1000.0); + } + if let Some(bit_depth) = album.maximum_bit_depth { + println!(" Max Bit Depth: {} bit", bit_depth); + } + } + + println!("\n ➕ Adding to cache..."); + match source.add_track(track).await { + Ok(track_id) => { + println!(" ✅ Track cached successfully!"); + + // Show resolved URI + if let Ok(uri) = source.resolve_uri(&track_id).await { + println!(" 📍 Stream URI: {}", uri); + } + } + Err(e) => { + println!(" ⚠️ Failed to cache track: {}", e); + } + } + println!(); + } + } + + // Browse favorite albums + println!("\n📚 Browsing your favorite albums..."); + let favorite_albums = source.client().get_favorite_albums().await?; + + if !favorite_albums.is_empty() { + println!("✅ Found {} favorite albums!\n", favorite_albums.len()); + + for (i, album) in favorite_albums.iter().enumerate().take(3) { + println!("{}. {} - {}", i + 1, album.artist.name, album.title); + if let Some(release_date) = &album.release_date { + println!(" Released: {}", release_date); + } + if let Some(tracks_count) = album.tracks_count { + println!(" Tracks: {}", tracks_count); + } + if !album.genres.is_empty() { + println!(" Genres: {}", album.genres.join(", ")); + } + } + } else { + println!("⚠️ No favorite albums found."); + } + + println!("\n✨ Example complete!"); + println!("\n💡 Tips:"); + println!(" - Run the example again to see faster loading from cache"); + println!(" - Check ./cache/qobuz-covers/ for cached cover images (WebP)"); + println!(" - Check ./cache/qobuz-audio/ for cached Hi-Res FLAC files"); + println!(" - Qobuz provides rich metadata (label, ISRC, sample rate, bit depth)"); + println!(" - Cached audio retains original quality (up to 24bit/192kHz)"); + + Ok(()) +} diff --git a/pmoqobuz/src/lib.rs b/pmoqobuz/src/lib.rs index cc776f5c..6c21f69f 100644 --- a/pmoqobuz/src/lib.rs +++ b/pmoqobuz/src/lib.rs @@ -105,16 +105,83 @@ //! - Résultats de recherche : 15 minutes //! - URLs de streaming : 5 minutes //! -//! ## Intégration pmocovers +//! ## Intégration pmocovers et pmoaudiocache //! -//! Les images d'albums sont automatiquement cachées via `pmocovers` (feature `covers`) : +//! La feature `cache` active le support complet du cache pour les images et l'audio. //! -//! ```rust,ignore -//! let album = client.get_album("12345").await?; -//! // L'image est automatiquement ajoutée au cache pmocovers -//! let cover_url = album.cover_url_cached; // URL vers le cache local +//! ### Cache d'images (pmocovers) +//! +//! Les images de couverture sont automatiquement téléchargées et converties en WebP : +//! +//! ```rust,no_run +//! use pmoqobuz::{QobuzSource, QobuzClient}; +//! use pmocovers::Cache as CoverCache; +//! use std::sync::Arc; +//! +//! # async fn example() -> Result<(), Box> { +//! let client = QobuzClient::from_config().await?; +//! let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?); +//! +//! let source = QobuzSource::new_with_cache( +//! client, +//! "http://localhost:8080", +//! Some(cover_cache), +//! None, +//! ); +//! # Ok(()) +//! # } //! ``` //! +//! ### Cache audio (pmoaudiocache) +//! +//! L'audio haute résolution est téléchargé et caché localement avec métadonnées enrichies : +//! +//! ```rust,no_run +//! use pmoqobuz::{QobuzSource, QobuzClient}; +//! use pmocovers::Cache as CoverCache; +//! use pmoaudiocache::AudioCache; +//! use std::sync::Arc; +//! +//! # async fn example() -> Result<(), Box> { +//! let client = QobuzClient::from_config().await?; +//! let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?); +//! let audio_cache = Arc::new(AudioCache::new("./cache/audio", 100)?); +//! +//! let source = QobuzSource::new_with_cache( +//! client.clone(), +//! "http://localhost:8080", +//! Some(cover_cache), +//! Some(audio_cache), +//! ); +//! +//! // Add a track with caching +//! let tracks = client.get_favorite_tracks().await?; +//! if let Some(track) = tracks.first() { +//! let track_id = source.add_track(track).await?; +//! // Audio and cover are now cached with rich metadata +//! +//! // Resolve URI (returns cached version if available) +//! let uri = source.resolve_uri(&track_id).await?; +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! ### Métadonnées enrichies +//! +//! Qobuz fournit des métadonnées détaillées qui sont préservées dans le cache : +//! - Titre, artiste, album +//! - Numéro de piste et de disque +//! - Année de sortie +//! - Genre(s) +//! - Label +//! - Qualité audio (sample rate, bit depth, channels) +//! - Durée +//! +//! ### Exemple complet +//! +//! Voir `examples/with_cache.rs` pour un exemple complet d'utilisation avec cache. +//! //! ## Formats audio supportés //! //! Qobuz propose plusieurs formats : @@ -142,6 +209,7 @@ //! //! - [`pmodidl`] : Format DIDL-Lite //! - [`pmocovers`] : Cache d'images +//! - [`pmoaudiocache`] : Cache audio //! - [`pmoconfig`] : Configuration //! - [`pmoserver`] : Serveur HTTP diff --git a/pmoqobuz/src/source.rs b/pmoqobuz/src/source.rs index 74e46894..5c4e75d4 100644 --- a/pmoqobuz/src/source.rs +++ b/pmoqobuz/src/source.rs @@ -8,10 +8,16 @@ use crate::didl::ToDIDL; use crate::models::{Album, Track}; use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result}; 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; +#[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"); @@ -65,11 +71,34 @@ 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>, + /// 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, +} + impl std::fmt::Debug for QobuzSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("QobuzSource").finish() @@ -82,6 +111,7 @@ impl QobuzSource { /// # Arguments /// /// * `client` - Authenticated Qobuz API client + /// * `cache_base_url` - Base URL for the cache server (e.g., "http://localhost:8080") /// /// # Examples /// @@ -91,14 +121,72 @@ impl QobuzSource { /// #[tokio::main] /// async fn main() -> Result<(), Box> { /// let client = QobuzClient::from_config().await?; - /// let source = QobuzSource::new(client); + /// let source = QobuzSource::new(client, "http://localhost:8080"); /// Ok(()) /// } /// ``` - pub fn new(client: QobuzClient) -> Self { + 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( + client: QobuzClient, + cache_base_url: impl Into, + cover_cache: Option>, + audio_cache: Option>, + ) -> Self { + 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()), }), @@ -110,6 +198,118 @@ impl QobuzSource { &self.inner.client } + /// Add a track from Qobuz with optional 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. + 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 + .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_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 + }; + + // 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 + }; + + // 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 + } + } + } 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, + }, + ); + } + + Ok(track_id) + } + /// Increment update counter (called on catalog changes) async fn increment_update_id(&self) { let mut counter = self.inner.update_counter.write().await; @@ -284,7 +484,21 @@ impl MusicSource for QobuzSource { } async fn resolve_uri(&self, object_id: &str) -> Result { - // Extract track ID from object_id + // 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()); + } + + // 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