ajoute à pmoqobuz la feature cache

This commit is contained in:
2025-10-17 07:56:21 +02:00
parent 5c06a0f0e9
commit fef3b6f645
6 changed files with 517 additions and 12 deletions

1
Cargo.lock generated
View File

@@ -2393,6 +2393,7 @@ dependencies = [
"hex", "hex",
"mockito", "mockito",
"moka", "moka",
"pmoaudiocache",
"pmoconfig", "pmoconfig",
"pmocovers", "pmocovers",
"pmodidl", "pmodidl",

View File

@@ -37,6 +37,9 @@ pmoconfig = { path = "../pmoconfig" }
# Intégration avec pmocovers pour le cache d'images # Intégration avec pmocovers pour le cache d'images
pmocovers = { path = "../pmocovers", optional = true } 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 # Intégration avec pmodidl pour l'export DIDL
pmodidl = { path = "../pmodidl" } pmodidl = { path = "../pmodidl" }
@@ -56,6 +59,8 @@ default = []
pmoserver = ["dep:pmoserver", "dep:axum", "dep:utoipa"] pmoserver = ["dep:pmoserver", "dep:axum", "dep:utoipa"]
# Feature pour activer le cache d'images via pmocovers # Feature pour activer le cache d'images via pmocovers
covers = ["dep:pmocovers"] covers = ["dep:pmocovers"]
# Feature pour activer le cache complet (images + audio)
cache = ["dep:pmocovers", "dep:pmoaudiocache"]
[dev-dependencies] [dev-dependencies]
# Tests # Tests

View File

@@ -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 -**Favoris** : Accès aux albums, artistes, tracks et playlists favoris
-**Cache en mémoire** : Minimisation des requêtes API avec TTL configurable -**Cache en mémoire** : Minimisation des requêtes API avec TTL configurable
-**Export DIDL** : Conversion automatique en format DIDL-Lite (UPnP/DLNA) -**Export DIDL** : Conversion automatique en format DIDL-Lite (UPnP/DLNA)
- 🔄 **Integration pmocovers** : Cache automatique des images (feature `covers`) - **Integration pmocovers** : Cache automatique des images (feature `covers`)
- 🔄 **API HTTP** : Endpoints REST via pmoserver (feature `pmoserver`) - **Integration pmoaudiocache** : Cache audio haute résolution avec métadonnées (feature `cache`)
-**API HTTP** : Endpoints REST via pmoserver (feature `pmoserver`)
## Installation ## Installation
@@ -143,12 +144,62 @@ println!("Total: {}", stats.total_count());
client.cache().clear_all().await; 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 ## Exemples
Exécutez l'exemple : Exécutez les exemples :
```bash ```bash
# Exemple basique
cargo run --example basic_usage cargo run --example basic_usage
# Exemple avec cache (nécessite la feature cache)
cargo run --example with_cache --features cache
``` ```
## Architecture ## Architecture
@@ -185,6 +236,12 @@ Générez la documentation :
cargo doc -p pmoqobuz --open 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 ## Dépendances principales
- `reqwest` : Client HTTP - `reqwest` : Client HTTP
@@ -193,6 +250,8 @@ cargo doc -p pmoqobuz --open
- `moka` : Cache en mémoire avec TTL - `moka` : Cache en mémoire avec TTL
- `pmodidl` : Export DIDL-Lite - `pmodidl` : Export DIDL-Lite
- `pmoconfig` : Configuration - `pmoconfig` : Configuration
- `pmocovers` : Cache d'images (optionnel)
- `pmoaudiocache` : Cache audio (optionnel)
## Licence ## Licence

View File

@@ -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<dyn std::error::Error>> {
// 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(())
}

View File

@@ -105,16 +105,83 @@
//! - Résultats de recherche : 15 minutes //! - Résultats de recherche : 15 minutes
//! - URLs de streaming : 5 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 //! ### Cache d'images (pmocovers)
//! let album = client.get_album("12345").await?; //!
//! // L'image est automatiquement ajoutée au cache pmocovers //! Les images de couverture sont automatiquement téléchargées et converties en WebP :
//! let cover_url = album.cover_url_cached; // URL vers le cache local //!
//! ```rust,no_run
//! use pmoqobuz::{QobuzSource, QobuzClient};
//! use pmocovers::Cache as CoverCache;
//! use std::sync::Arc;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! 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<dyn std::error::Error>> {
//! 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 //! ## Formats audio supportés
//! //!
//! Qobuz propose plusieurs formats : //! Qobuz propose plusieurs formats :
@@ -142,6 +209,7 @@
//! //!
//! - [`pmodidl`] : Format DIDL-Lite //! - [`pmodidl`] : Format DIDL-Lite
//! - [`pmocovers`] : Cache d'images //! - [`pmocovers`] : Cache d'images
//! - [`pmoaudiocache`] : Cache audio
//! - [`pmoconfig`] : Configuration //! - [`pmoconfig`] : Configuration
//! - [`pmoserver`] : Serveur HTTP //! - [`pmoserver`] : Serveur HTTP

View File

@@ -8,10 +8,16 @@ use crate::didl::ToDIDL;
use crate::models::{Album, Track}; use crate::models::{Album, Track};
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result}; use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
use pmodidl::{Container, Item}; use pmodidl::{Container, Item};
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::SystemTime; use std::time::SystemTime;
use tokio::sync::RwLock; 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) /// Default image for Qobuz (300x300 WebP, embedded in binary)
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp"); const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
@@ -65,11 +71,34 @@ struct QobuzSourceInner {
/// Qobuz API client /// Qobuz API client
client: QobuzClient, 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>>,
/// Update tracking /// Update tracking
update_counter: RwLock<u32>, update_counter: RwLock<u32>,
last_change: RwLock<SystemTime>, 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>,
}
impl std::fmt::Debug for QobuzSource { impl std::fmt::Debug for QobuzSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("QobuzSource").finish() f.debug_struct("QobuzSource").finish()
@@ -82,6 +111,7 @@ impl QobuzSource {
/// # Arguments /// # Arguments
/// ///
/// * `client` - Authenticated Qobuz API client /// * `client` - Authenticated Qobuz API client
/// * `cache_base_url` - Base URL for the cache server (e.g., "http://localhost:8080")
/// ///
/// # Examples /// # Examples
/// ///
@@ -91,14 +121,72 @@ impl QobuzSource {
/// #[tokio::main] /// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> { /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = QobuzClient::from_config().await?; /// let client = QobuzClient::from_config().await?;
/// let source = QobuzSource::new(client); /// let source = QobuzSource::new(client, "http://localhost:8080");
/// Ok(()) /// Ok(())
/// } /// }
/// ``` /// ```
pub fn new(client: QobuzClient) -> Self { pub fn new(client: QobuzClient, cache_base_url: impl Into<String>) -> Self {
Self { Self {
inner: Arc::new(QobuzSourceInner { inner: Arc::new(QobuzSourceInner {
client, 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(
client: QobuzClient,
cache_base_url: impl Into<String>,
cover_cache: Option<Arc<CoverCache>>,
audio_cache: Option<Arc<AudioCache>>,
) -> 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), update_counter: RwLock::new(0),
last_change: RwLock::new(SystemTime::now()), last_change: RwLock::new(SystemTime::now()),
}), }),
@@ -110,6 +198,118 @@ impl QobuzSource {
&self.inner.client &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<String> {
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) /// Increment update counter (called on catalog changes)
async fn increment_update_id(&self) { async fn increment_update_id(&self) {
let mut counter = self.inner.update_counter.write().await; 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<String> { async fn resolve_uri(&self, object_id: &str) -> Result<String> {
// 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}" // Object IDs for tracks follow pattern: "qobuz://track/{id}"
let track_id = if let Some(id) = object_id.strip_prefix("qobuz://track/") { let track_id = if let Some(id) = object_id.strip_prefix("qobuz://track/") {
id id