ajoute à pmoqobuz la feature cache
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2393,6 +2393,7 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"mockito",
|
"mockito",
|
||||||
"moka",
|
"moka",
|
||||||
|
"pmoaudiocache",
|
||||||
"pmoconfig",
|
"pmoconfig",
|
||||||
"pmocovers",
|
"pmocovers",
|
||||||
"pmodidl",
|
"pmodidl",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
158
pmoqobuz/examples/with_cache.rs
Normal file
158
pmoqobuz/examples/with_cache.rs
Normal 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(())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user