Première version testable avec la construction d'une image docker #22
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -3535,6 +3535,7 @@ name = "pmoqobuz"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum 0.8.7",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -3544,13 +3545,16 @@ dependencies = [
|
||||
"mockito",
|
||||
"moka",
|
||||
"pmoaudiocache",
|
||||
"pmocache",
|
||||
"pmoconfig",
|
||||
"pmocovers",
|
||||
"pmodidl",
|
||||
"pmoplaylist",
|
||||
"pmoserver",
|
||||
"pmosource",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
|
||||
@@ -118,7 +118,8 @@ impl DB {
|
||||
collection TEXT,
|
||||
id TEXT,
|
||||
hits INTEGER DEFAULT 0,
|
||||
last_used TEXT
|
||||
last_used TEXT,
|
||||
lazy_pk TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
@@ -156,16 +157,7 @@ impl DB {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// LAZY PK SUPPORT : Ajouter colonne lazy_pk pour mode deferred
|
||||
// Cette colonne permet de stocker un PK temporaire calculé à partir de l'URL
|
||||
// sans télécharger le fichier. Quand le fichier est téléchargé, le real pk
|
||||
// est calculé et stocké, mais le lazy_pk est conservé pour compatibilité UPnP.
|
||||
conn.execute(
|
||||
"ALTER TABLE asset ADD COLUMN IF NOT EXISTS lazy_pk TEXT",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Index sur lazy_pk pour lookups rapides (lazy_pk → real pk)
|
||||
// LAZY PK SUPPORT: Index sur lazy_pk pour lookups rapides (lazy_pk → real pk)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asset_lazy_pk ON asset (lazy_pk)",
|
||||
[],
|
||||
@@ -953,7 +945,7 @@ impl DB {
|
||||
/// Version générique de set_a_metadata qui accepte une clé arbitraire
|
||||
///
|
||||
/// Utilisé en interne pour stocker des métadonnées avec lazy_pk au lieu de pk
|
||||
fn set_a_metadata_by_key(
|
||||
pub fn set_a_metadata_by_key(
|
||||
&self,
|
||||
key: &str,
|
||||
metadata_key: &str,
|
||||
|
||||
@@ -7,6 +7,7 @@ edition = "2021"
|
||||
regex = "1.12"
|
||||
base64 = "0.22"
|
||||
indexmap = "2.0"
|
||||
async-trait = { version = "0.1", optional = true }
|
||||
|
||||
# HTTP client pour les requêtes à l'API Qobuz
|
||||
reqwest = { version = "0.12", features = ["json", "cookies"] }
|
||||
@@ -52,6 +53,7 @@ pmodidl = { path = "../pmodidl" }
|
||||
# Intégration avec pmoserver pour l'API HTTP
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
axum = { version = "0.8", optional = true }
|
||||
rusqlite = { version = "0.37", features = ["bundled"], optional = true }
|
||||
|
||||
# Documentation OpenAPI
|
||||
utoipa = { version = "5.3", optional = true }
|
||||
@@ -59,6 +61,9 @@ utoipa = { version = "5.3", optional = true }
|
||||
# Common music source traits
|
||||
pmosource = { path = "../pmosource" }
|
||||
|
||||
# Playlist management
|
||||
pmoplaylist = { path = "../pmoplaylist" }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Feature pour activer les extensions pmoserver
|
||||
@@ -67,6 +72,7 @@ pmoserver = ["dep:pmoserver", "dep:axum", "dep:utoipa"]
|
||||
server = ["pmosource/server"]
|
||||
# Feature cache (deprecated - toujours actif maintenant)
|
||||
cache = []
|
||||
disk-cache = ["dep:rusqlite", "dep:async-trait"]
|
||||
|
||||
[dev-dependencies]
|
||||
# Tests
|
||||
@@ -75,6 +81,7 @@ mockito = "1.0"
|
||||
tempfile = "3.0"
|
||||
# Pour les exemples
|
||||
tracing-subscriber = "0.3"
|
||||
pmocache = { path = "../pmocache" }
|
||||
# Pour l'exemple spoofer
|
||||
|
||||
# Specify that the with_cache example requires the cache feature
|
||||
|
||||
213
pmoqobuz/examples/lazy_loading.rs
Normal file
213
pmoqobuz/examples/lazy_loading.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
//! Example demonstrating Qobuz lazy loading with rate limiting
|
||||
//!
|
||||
//! This example shows how to use the new lazy loading feature to add albums
|
||||
//! to playlists without downloading all audio files immediately. Only covers
|
||||
//! are downloaded eagerly, audio is downloaded on-demand when played.
|
||||
//!
|
||||
//! Features demonstrated:
|
||||
//! - Rate limiting (max 2 concurrent requests, 400ms delay)
|
||||
//! - Lazy audio loading (saves ~99% initial bandwidth)
|
||||
//! - Eager cover loading (UI responsiveness)
|
||||
//! - Automatic PK switching when audio is downloaded
|
||||
//! - Prefetch of next 2 tracks during playback
|
||||
//!
|
||||
//! Run with:
|
||||
//! ```bash
|
||||
//! cargo run -p pmoqobuz --example lazy_loading
|
||||
//! ```
|
||||
|
||||
use pmoaudiocache::Cache as AudioCache;
|
||||
use pmocovers::Cache as CoverCache;
|
||||
use pmoplaylist::PlaylistManager;
|
||||
use pmoqobuz::{QobuzClient, QobuzSource};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialize tracing with debug level to see rate limiting
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::INFO)
|
||||
.with_target(false)
|
||||
.init();
|
||||
|
||||
println!("🎵 Qobuz Lazy Loading Demo");
|
||||
println!("============================\n");
|
||||
|
||||
// Step 1: Connect to Qobuz with rate limiting enabled
|
||||
println!("📡 Connecting to Qobuz (rate limiting enabled)...");
|
||||
let client = QobuzClient::from_config().await?;
|
||||
println!("✅ Connected with rate limiting:");
|
||||
println!(" - Max 2 concurrent requests");
|
||||
println!(" - 400ms minimum delay between requests\n");
|
||||
|
||||
// Step 2: 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");
|
||||
|
||||
// Step 3: Create QobuzSource with caches
|
||||
let source = QobuzSource::new(client, cover_cache.clone(), audio_cache.clone());
|
||||
|
||||
// Step 4: Get user's favorite albums
|
||||
println!("🎧 Fetching your favorite albums...");
|
||||
let favorite_albums = source.client().get_favorite_albums().await?;
|
||||
|
||||
if favorite_albums.is_empty() {
|
||||
println!("⚠️ No favorite albums found!");
|
||||
println!(" Please add some albums to your Qobuz favorites first.\n");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("✅ Found {} favorite albums\n", favorite_albums.len());
|
||||
|
||||
// Step 5: Select first album for testing
|
||||
let album = &favorite_albums[0];
|
||||
println!("📀 Selected album: {} - {}", album.artist.name, album.title);
|
||||
println!(" Tracks: {}", album.tracks_count.unwrap_or(0));
|
||||
println!(" Album ID: {}\n", album.id);
|
||||
|
||||
// Step 6: Create a test playlist
|
||||
println!("📝 Creating test playlist...");
|
||||
let playlist_manager = PlaylistManager();
|
||||
let playlist_id = {
|
||||
let writer = playlist_manager
|
||||
.create_persistent_playlist("lazy-test".to_string())
|
||||
.await?;
|
||||
writer.id().to_string()
|
||||
}; // Drop writer here to release the lock
|
||||
println!("✅ Playlist created: {}\n", playlist_id);
|
||||
|
||||
// Step 7: Add album with lazy loading (measure time and track downloads)
|
||||
println!("⏱️ Adding album to playlist with LAZY loading...");
|
||||
println!(" This will:");
|
||||
println!(" - Download covers immediately (~400 KB each)");
|
||||
println!(" - Create lazy PKs for audio (NO download)");
|
||||
println!(" - Enable prefetch for next 2 tracks\n");
|
||||
|
||||
let start = Instant::now();
|
||||
let count = source
|
||||
.add_album_to_playlist(&playlist_id, &album.id)
|
||||
.await?;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
println!("✅ Album added: {} tracks in {:.2}s", count, elapsed.as_secs_f64());
|
||||
println!(" Average: {:.0}ms per track\n", elapsed.as_millis() as f64 / count as f64);
|
||||
|
||||
// Step 8: Verify lazy PKs
|
||||
println!("🔍 Verifying lazy PKs...");
|
||||
let reader = playlist_manager.get_read_handle(&playlist_id).await?;
|
||||
|
||||
// Read all tracks from playlist
|
||||
let mut tracks = Vec::new();
|
||||
loop {
|
||||
match reader.peek().await? {
|
||||
Some(track) => {
|
||||
tracks.push(track);
|
||||
reader.pop().await?;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
if tracks.is_empty() {
|
||||
println!("⚠️ No tracks in playlist!");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let first_track_pk = tracks[0].cache_pk();
|
||||
let is_lazy = pmocache::is_lazy_pk(&first_track_pk);
|
||||
|
||||
println!(" First track PK: {}", first_track_pk);
|
||||
println!(" Is lazy: {}", if is_lazy { "✅ YES (starts with 'L:')" } else { "❌ NO" });
|
||||
|
||||
// Count lazy vs downloaded
|
||||
let lazy_count = tracks.iter().filter(|t| pmocache::is_lazy_pk(t.cache_pk())).count();
|
||||
let downloaded_count = tracks.len() - lazy_count;
|
||||
|
||||
println!("\n📊 Track status:");
|
||||
println!(" Lazy (not downloaded): {} tracks", lazy_count);
|
||||
println!(" Downloaded: {} tracks", downloaded_count);
|
||||
|
||||
// Step 9: Check cache sizes
|
||||
println!("\n💾 Cache disk usage:");
|
||||
println!(" Covers: {:?}", get_dir_size("./cache/qobuz-covers")?);
|
||||
println!(" Audio: {:?}", get_dir_size("./cache/qobuz-audio")?);
|
||||
|
||||
// Step 10: Demonstrate on-demand download
|
||||
if is_lazy {
|
||||
println!("\n🎵 Simulating playback of first track...");
|
||||
println!(" This would trigger download via HTTP request to:");
|
||||
println!(" GET /cache/flac/{}", first_track_pk);
|
||||
println!("\n The lazy PK will automatically:");
|
||||
println!(" 1. Download the audio file from Qobuz");
|
||||
println!(" 2. Convert to FLAC");
|
||||
println!(" 3. Calculate real PK from content");
|
||||
println!(" 4. Update playlist (lazy_pk → real_pk)");
|
||||
println!(" 5. Prefetch next 2 tracks in background");
|
||||
}
|
||||
|
||||
// Step 11: Summary
|
||||
println!("\n╭─────────────────────────────────────────╮");
|
||||
println!("│ 🎉 Lazy Loading Demo Complete! │");
|
||||
println!("╰─────────────────────────────────────────╯");
|
||||
println!("\n📈 Benefits demonstrated:");
|
||||
println!(" ✓ Fast album loading (~{}ms per track)", elapsed.as_millis() / count as u128);
|
||||
println!(" ✓ Minimal initial download (covers only)");
|
||||
println!(" ✓ Audio downloaded on-demand");
|
||||
println!(" ✓ Rate limiting active (respectful to Qobuz)");
|
||||
println!(" ✓ Automatic prefetching during playback");
|
||||
|
||||
println!("\n💡 For 375 favorite albums (~3750 tracks):");
|
||||
println!(" Without lazy: ~15 GB download, ~75s (no rate limit)");
|
||||
println!(" With lazy: ~150 MB download, ~5 min (rate limited)");
|
||||
println!(" Savings: ~99% bandwidth, natural request pattern");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calculate directory size recursively
|
||||
fn get_dir_size(path: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
use std::fs;
|
||||
|
||||
let mut total: u64 = 0;
|
||||
|
||||
if let Ok(entries) = fs::read_dir(path) {
|
||||
for entry in entries.flatten() {
|
||||
if let Ok(metadata) = entry.metadata() {
|
||||
if metadata.is_file() {
|
||||
total += metadata.len();
|
||||
} else if metadata.is_dir() {
|
||||
if let Ok(size_str) = get_dir_size(&entry.path().to_string_lossy()) {
|
||||
// Parse size from string (hacky but works for this example)
|
||||
if let Some(num) = size_str.split_whitespace().next() {
|
||||
if let Ok(size) = num.parse::<f64>() {
|
||||
total += (size * 1024.0 * 1024.0) as u64;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(format_size(total))
|
||||
}
|
||||
|
||||
/// Format bytes to human-readable size
|
||||
fn format_size(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = KB * 1024;
|
||||
const GB: u64 = MB * 1024;
|
||||
|
||||
if bytes >= GB {
|
||||
format!("{:.2} GB", bytes as f64 / GB as f64)
|
||||
} else if bytes >= MB {
|
||||
format!("{:.2} MB", bytes as f64 / MB as f64)
|
||||
} else if bytes >= KB {
|
||||
format!("{:.2} KB", bytes as f64 / KB as f64)
|
||||
} else {
|
||||
format!("{} B", bytes)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
//!
|
||||
//! Pour tester :
|
||||
//! ```bash
|
||||
//! cargo run --example server_with_covers --features "pmoserver,covers"
|
||||
//! cargo run --example server_with_covers --features "pmoserver"
|
||||
//! ```
|
||||
//!
|
||||
//! Endpoints disponibles :
|
||||
@@ -20,16 +20,16 @@
|
||||
//! - GET /api/covers - API REST du cache d'images
|
||||
//! - GET /swagger-ui - Documentation interactive
|
||||
|
||||
#[cfg(all(feature = "pmoserver", feature = "covers"))]
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use pmocovers::CoverCacheExt;
|
||||
|
||||
#[cfg(all(feature = "pmoserver", feature = "covers"))]
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use pmoqobuz::QobuzServerExt;
|
||||
|
||||
#[cfg(all(feature = "pmoserver", feature = "covers"))]
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use pmoserver::ServerBuilder;
|
||||
|
||||
#[cfg(all(feature = "pmoserver", feature = "covers"))]
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Initialiser le logging
|
||||
@@ -94,9 +94,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "pmoserver", feature = "covers")))]
|
||||
#[cfg(not(feature = "pmoserver"))]
|
||||
fn main() {
|
||||
eprintln!("Cet exemple nécessite les features 'pmoserver' et 'covers'");
|
||||
eprintln!("Exécutez: cargo run --example server_with_covers --features \"pmoserver,covers\"");
|
||||
eprintln!("Cet exemple nécessite la feature 'pmoserver'");
|
||||
eprintln!("Exécutez: cargo run --example server_with_covers --features \"pmoserver\"");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
@@ -5,16 +5,27 @@
|
||||
//! - Accessing the embedded WebP image
|
||||
//! - Optionally saving it to a file
|
||||
|
||||
use pmoaudiocache::cache as audio_cache;
|
||||
use pmocovers::cache as cover_cache;
|
||||
use pmoqobuz::{QobuzClient, QobuzSource};
|
||||
use pmosource::MusicSource;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create the client and source
|
||||
let client = QobuzClient::from_config().await?;
|
||||
let source = QobuzSource::new(client, "http://localhost:8080");
|
||||
let temp_dir = std::env::temp_dir().join("pmoqobuz_show_source_image");
|
||||
let covers_dir = temp_dir.join("covers");
|
||||
let audio_dir = temp_dir.join("audio");
|
||||
fs::create_dir_all(&covers_dir)?;
|
||||
fs::create_dir_all(&audio_dir)?;
|
||||
|
||||
let cover_cache = Arc::new(cover_cache::new_cache(&covers_dir.to_string_lossy(), 128)?);
|
||||
let audio_cache = Arc::new(audio_cache::new_cache(&audio_dir.to_string_lossy(), 32)?);
|
||||
let source = QobuzSource::new(client, cover_cache, audio_cache);
|
||||
|
||||
// Display source information
|
||||
println!("Music Source Information");
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! cargo run --example with_cache --features cache
|
||||
//! ```
|
||||
|
||||
use pmoaudiocache::AudioCache;
|
||||
use pmoaudiocache::Cache as AudioCache;
|
||||
use pmocovers::Cache as CoverCache;
|
||||
use pmoqobuz::{QobuzClient, QobuzSource};
|
||||
use pmosource::MusicSource;
|
||||
@@ -34,12 +34,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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()),
|
||||
);
|
||||
let source = QobuzSource::new(client, cover_cache.clone(), audio_cache.clone());
|
||||
|
||||
println!("📻 Source: {}", source.name());
|
||||
println!("🆔 ID: {}", source.id());
|
||||
|
||||
@@ -67,7 +67,7 @@ impl QobuzApi {
|
||||
///
|
||||
/// * `QobuzError::Unauthorized` - Credentials invalides
|
||||
/// * `QobuzError::SubscriptionRequired` - Compte gratuit (non éligible)
|
||||
pub async fn login(&mut self, username: &str, password: &str) -> Result<AuthInfo> {
|
||||
pub async fn login(&self, username: &str, password: &str) -> Result<AuthInfo> {
|
||||
info!("Attempting to login to Qobuz as {}", username);
|
||||
|
||||
let params = [("username", username), ("password", password)];
|
||||
@@ -105,14 +105,13 @@ impl QobuzApi {
|
||||
|
||||
/// Vérifie si le client est authentifié
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
self.user_auth_token.is_some() && self.user_id.is_some()
|
||||
self.auth_token().is_some() && self.user_id().is_some()
|
||||
}
|
||||
|
||||
/// Déconnecte l'utilisateur
|
||||
pub fn logout(&mut self) {
|
||||
pub fn logout(&self) {
|
||||
debug!("Logging out");
|
||||
self.user_auth_token = None;
|
||||
self.user_id = None;
|
||||
self.clear_auth();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +121,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_is_authenticated() {
|
||||
let mut api = QobuzApi::new("test_app_id").unwrap();
|
||||
let api = QobuzApi::new("test_app_id").unwrap();
|
||||
assert!(!api.is_authenticated());
|
||||
|
||||
api.set_auth_token("token".to_string(), "user123".to_string());
|
||||
|
||||
@@ -225,26 +225,19 @@ impl QobuzApi {
|
||||
debug!("Fetching file URL for track {}", track_id);
|
||||
|
||||
// Vérifier que le secret est disponible
|
||||
let secret = self
|
||||
.secret()
|
||||
.ok_or_else(|| {
|
||||
QobuzError::Configuration(
|
||||
"Secret not configured. Cannot sign track/getFileUrl request.".to_string(),
|
||||
)
|
||||
})?;
|
||||
let secret = self.secret().ok_or_else(|| {
|
||||
QobuzError::Configuration(
|
||||
"Secret not configured. Cannot sign track/getFileUrl request.".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let format_id = self.format_id.id().to_string();
|
||||
let intent = "stream";
|
||||
let timestamp = signing::get_timestamp();
|
||||
|
||||
// Signer la requête (comme Python: track_getFileUrl)
|
||||
let signature = signing::sign_track_get_file_url(
|
||||
&format_id,
|
||||
intent,
|
||||
track_id,
|
||||
×tamp,
|
||||
secret,
|
||||
);
|
||||
let signature =
|
||||
signing::sign_track_get_file_url(&format_id, intent, track_id, ×tamp, &secret);
|
||||
|
||||
debug!(
|
||||
"Signing track/getFileUrl: track_id={}, format_id={}, ts={}",
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::models::AudioFormat;
|
||||
use reqwest::{Client, Response};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
@@ -45,9 +46,9 @@ pub struct QobuzApi {
|
||||
/// - Depuis le Spoofer (secrets dynamiques)
|
||||
secret: Option<Vec<u8>>,
|
||||
/// Token d'authentification utilisateur
|
||||
user_auth_token: Option<String>,
|
||||
user_auth_token: RwLock<Option<String>>,
|
||||
/// ID utilisateur
|
||||
user_id: Option<String>,
|
||||
user_id: RwLock<Option<String>>,
|
||||
/// Format audio par défaut
|
||||
format_id: AudioFormat,
|
||||
}
|
||||
@@ -66,8 +67,8 @@ impl QobuzApi {
|
||||
client,
|
||||
app_id: app_id.into(),
|
||||
secret: None,
|
||||
user_auth_token: None,
|
||||
user_id: None,
|
||||
user_auth_token: RwLock::new(None),
|
||||
user_id: RwLock::new(None),
|
||||
format_id: AudioFormat::default(),
|
||||
})
|
||||
}
|
||||
@@ -132,9 +133,15 @@ impl QobuzApi {
|
||||
}
|
||||
|
||||
/// Définit le token d'authentification
|
||||
pub fn set_auth_token(&mut self, token: String, user_id: String) {
|
||||
self.user_auth_token = Some(token);
|
||||
self.user_id = Some(user_id);
|
||||
pub fn set_auth_token(&self, token: String, user_id: String) {
|
||||
*self.user_auth_token.write().unwrap() = Some(token);
|
||||
*self.user_id.write().unwrap() = Some(user_id);
|
||||
}
|
||||
|
||||
/// Efface les informations d'authentification
|
||||
pub fn clear_auth(&self) {
|
||||
*self.user_auth_token.write().unwrap() = None;
|
||||
*self.user_id.write().unwrap() = None;
|
||||
}
|
||||
|
||||
/// Définit le format audio par défaut
|
||||
@@ -153,13 +160,13 @@ impl QobuzApi {
|
||||
}
|
||||
|
||||
/// Retourne le token d'authentification si disponible
|
||||
pub fn auth_token(&self) -> Option<&str> {
|
||||
self.user_auth_token.as_deref()
|
||||
pub fn auth_token(&self) -> Option<String> {
|
||||
self.user_auth_token.read().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Retourne l'ID utilisateur si disponible
|
||||
pub fn user_id(&self) -> Option<&str> {
|
||||
self.user_id.as_deref()
|
||||
pub fn user_id(&self) -> Option<String> {
|
||||
self.user_id.read().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Effectue une requête GET à l'API
|
||||
@@ -200,7 +207,7 @@ impl QobuzApi {
|
||||
// Ajouter les headers
|
||||
request = request.header("X-App-Id", &self.app_id);
|
||||
|
||||
if let Some(ref token) = self.user_auth_token {
|
||||
if let Some(token) = self.auth_token() {
|
||||
request = request.header("X-User-Auth-Token", token);
|
||||
}
|
||||
|
||||
@@ -269,10 +276,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_set_auth_token() {
|
||||
let mut api = QobuzApi::new("test_app_id").unwrap();
|
||||
let api = QobuzApi::new("test_app_id").unwrap();
|
||||
api.set_auth_token("test_token".to_string(), "user123".to_string());
|
||||
assert_eq!(api.auth_token(), Some("test_token"));
|
||||
assert_eq!(api.user_id(), Some("user123"));
|
||||
assert_eq!(api.auth_token().as_deref(), Some("test_token"));
|
||||
assert_eq!(api.user_id().as_deref(), Some("user123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -115,13 +115,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_sign_track_get_file_url() {
|
||||
let signature = sign_track_get_file_url(
|
||||
"27",
|
||||
"stream",
|
||||
"12345",
|
||||
"1234567890.123",
|
||||
b"test_secret",
|
||||
);
|
||||
let signature =
|
||||
sign_track_get_file_url("27", "stream", "12345", "1234567890.123", b"test_secret");
|
||||
|
||||
// Vérifier que c'est un hash MD5 valide (32 caractères hex)
|
||||
assert_eq!(signature.len(), 32);
|
||||
|
||||
@@ -41,8 +41,9 @@ impl Spoofer {
|
||||
.await?;
|
||||
|
||||
// Extraire l'URL du bundle
|
||||
let bundle_url_regex =
|
||||
Regex::new(r#"<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>"#)?;
|
||||
let bundle_url_regex = Regex::new(
|
||||
r#"<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>"#,
|
||||
)?;
|
||||
let bundle_url = bundle_url_regex
|
||||
.captures(&login_page)
|
||||
.and_then(|cap| cap.get(1))
|
||||
@@ -168,19 +169,14 @@ impl Spoofer {
|
||||
|
||||
// Décoder en base64
|
||||
match STANDARD.decode(trimmed) {
|
||||
Ok(decoded_bytes) => {
|
||||
match String::from_utf8(decoded_bytes) {
|
||||
Ok(decoded_str) => {
|
||||
decoded_secrets.insert(timezone, decoded_str);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Erreur UTF-8 pour timezone {}: {}",
|
||||
timezone, e
|
||||
);
|
||||
}
|
||||
Ok(decoded_bytes) => match String::from_utf8(decoded_bytes) {
|
||||
Ok(decoded_str) => {
|
||||
decoded_secrets.insert(timezone, decoded_str);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Erreur UTF-8 pour timezone {}: {}", timezone, e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Erreur de décodage base64 pour timezone {}: {}",
|
||||
|
||||
@@ -32,9 +32,8 @@ struct UserPlaylistsResponse {
|
||||
|
||||
impl QobuzApi {
|
||||
/// Vérifie que l'utilisateur est authentifié
|
||||
fn ensure_authenticated(&self) -> Result<&str> {
|
||||
self.user_id
|
||||
.as_deref()
|
||||
fn ensure_authenticated(&self) -> Result<String> {
|
||||
self.user_id()
|
||||
.ok_or_else(|| QobuzError::Unauthorized("Not authenticated".to_string()))
|
||||
}
|
||||
|
||||
@@ -43,7 +42,11 @@ impl QobuzApi {
|
||||
let user_id = self.ensure_authenticated()?;
|
||||
debug!("Fetching favorite albums for user {}", user_id);
|
||||
|
||||
let params = [("user_id", user_id), ("type", "albums"), ("limit", "1000")];
|
||||
let params = [
|
||||
("user_id", user_id.as_str()),
|
||||
("type", "albums"),
|
||||
("limit", "1000"),
|
||||
];
|
||||
|
||||
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?;
|
||||
|
||||
@@ -64,7 +67,11 @@ impl QobuzApi {
|
||||
let user_id = self.ensure_authenticated()?;
|
||||
debug!("Fetching favorite artists for user {}", user_id);
|
||||
|
||||
let params = [("user_id", user_id), ("type", "artists"), ("limit", "1000")];
|
||||
let params = [
|
||||
("user_id", user_id.as_str()),
|
||||
("type", "artists"),
|
||||
("limit", "1000"),
|
||||
];
|
||||
|
||||
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?;
|
||||
|
||||
@@ -84,7 +91,11 @@ impl QobuzApi {
|
||||
let user_id = self.ensure_authenticated()?;
|
||||
debug!("Fetching favorite tracks for user {}", user_id);
|
||||
|
||||
let params = [("user_id", user_id), ("type", "tracks"), ("limit", "1000")];
|
||||
let params = [
|
||||
("user_id", user_id.as_str()),
|
||||
("type", "tracks"),
|
||||
("limit", "1000"),
|
||||
];
|
||||
|
||||
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?;
|
||||
|
||||
@@ -105,7 +116,7 @@ impl QobuzApi {
|
||||
let user_id = self.ensure_authenticated()?;
|
||||
debug!("Fetching playlists for user {}", user_id);
|
||||
|
||||
let params = [("user_id", user_id), ("limit", "1000")];
|
||||
let params = [("user_id", user_id.as_str()), ("limit", "1000")];
|
||||
|
||||
let response: UserPlaylistsResponse =
|
||||
self.get("/playlist/getUserPlaylists", ¶ms).await?;
|
||||
@@ -126,7 +137,7 @@ impl QobuzApi {
|
||||
album_id, user_id
|
||||
);
|
||||
|
||||
let params = [("album_id", album_id), ("user_id", user_id)];
|
||||
let params = [("album_id", album_id), ("user_id", user_id.as_str())];
|
||||
|
||||
self.get::<serde_json::Value>("/favorite/create", ¶ms)
|
||||
.await?;
|
||||
@@ -141,7 +152,7 @@ impl QobuzApi {
|
||||
album_id, user_id
|
||||
);
|
||||
|
||||
let params = [("album_ids", album_id), ("user_id", user_id)];
|
||||
let params = [("album_ids", album_id), ("user_id", user_id.as_str())];
|
||||
|
||||
self.get::<serde_json::Value>("/favorite/delete", ¶ms)
|
||||
.await?;
|
||||
@@ -156,7 +167,7 @@ impl QobuzApi {
|
||||
track_id, user_id
|
||||
);
|
||||
|
||||
let params = [("track_id", track_id), ("user_id", user_id)];
|
||||
let params = [("track_id", track_id), ("user_id", user_id.as_str())];
|
||||
|
||||
self.get::<serde_json::Value>("/favorite/create", ¶ms)
|
||||
.await?;
|
||||
@@ -171,7 +182,7 @@ impl QobuzApi {
|
||||
track_id, user_id
|
||||
);
|
||||
|
||||
let params = [("track_ids", track_id), ("user_id", user_id)];
|
||||
let params = [("track_ids", track_id), ("user_id", user_id.as_str())];
|
||||
|
||||
self.get::<serde_json::Value>("/favorite/delete", ¶ms)
|
||||
.await?;
|
||||
@@ -212,19 +223,16 @@ impl QobuzApi {
|
||||
self.ensure_authenticated()?;
|
||||
|
||||
// Vérifier que le secret est disponible
|
||||
let secret = self
|
||||
.secret()
|
||||
.ok_or_else(|| {
|
||||
QobuzError::Configuration(
|
||||
"Secret not configured. Cannot sign userLibrary/getAlbumsList request."
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let secret = self.secret().ok_or_else(|| {
|
||||
QobuzError::Configuration(
|
||||
"Secret not configured. Cannot sign userLibrary/getAlbumsList request.".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let timestamp = signing::get_timestamp();
|
||||
|
||||
// Signer la requête (comme Python: userlib_getAlbums)
|
||||
let signature = signing::sign_userlib_get_albums(×tamp, secret);
|
||||
let signature = signing::sign_userlib_get_albums(×tamp, &secret);
|
||||
|
||||
debug!(
|
||||
"Signing userLibrary/getAlbumsList: app_id={}, ts={}",
|
||||
@@ -239,7 +247,7 @@ impl QobuzApi {
|
||||
|
||||
let params = [
|
||||
("app_id", self.app_id()),
|
||||
("user_auth_token", user_auth_token),
|
||||
("user_auth_token", user_auth_token.as_str()),
|
||||
("request_ts", timestamp.as_str()),
|
||||
("request_sig", signature.as_str()),
|
||||
];
|
||||
@@ -252,9 +260,9 @@ impl QobuzApi {
|
||||
///
|
||||
/// Cette méthode est équivalente au test fait dans `setSec()` en Python.
|
||||
/// Elle retourne `true` si le secret fonctionne, `false` sinon.
|
||||
pub async fn test_secret(&self, secret: &[u8]) -> bool {
|
||||
pub async fn test_secret(&self, _secret: &[u8]) -> bool {
|
||||
// Sauvegarder le secret actuel
|
||||
let current_secret = self.secret().map(|s| s.to_vec());
|
||||
let _current_secret = self.secret();
|
||||
|
||||
// Définir temporairement le nouveau secret
|
||||
// Note: cette méthode nécessite &mut self, donc on doit la rendre mutable
|
||||
|
||||
@@ -24,7 +24,6 @@ use crate::{client::QobuzClient, error::QobuzError, models::*};
|
||||
#[derive(Clone)]
|
||||
pub struct QobuzState {
|
||||
pub client: Arc<QobuzClient>,
|
||||
#[cfg(feature = "covers")]
|
||||
pub cover_cache: Option<Arc<pmocovers::Cache>>,
|
||||
}
|
||||
|
||||
@@ -121,7 +120,6 @@ async fn get_album(
|
||||
) -> Result<Json<Album>, AppError> {
|
||||
let mut album = state.client.get_album(&id).await?;
|
||||
|
||||
#[cfg(feature = "covers")]
|
||||
if let Some(ref cover_cache) = state.cover_cache {
|
||||
album = cache_album_image(album, cover_cache).await;
|
||||
}
|
||||
@@ -163,7 +161,6 @@ async fn get_artist_albums(
|
||||
) -> Result<Json<Vec<Album>>, AppError> {
|
||||
let mut albums = state.client.get_artist_albums(&id).await?;
|
||||
|
||||
#[cfg(feature = "covers")]
|
||||
if let Some(ref cover_cache) = state.cover_cache {
|
||||
albums = cache_albums_images(albums, cover_cache).await;
|
||||
}
|
||||
@@ -208,7 +205,6 @@ async fn search(
|
||||
.search(¶ms.q, params.search_type.as_deref())
|
||||
.await?;
|
||||
|
||||
#[cfg(feature = "covers")]
|
||||
if let Some(ref cover_cache) = state.cover_cache {
|
||||
result.albums = cache_albums_images(result.albums, cover_cache).await;
|
||||
}
|
||||
@@ -222,7 +218,6 @@ async fn get_favorite_albums(
|
||||
) -> Result<Json<Vec<Album>>, AppError> {
|
||||
let mut albums = state.client.get_favorite_albums().await?;
|
||||
|
||||
#[cfg(feature = "covers")]
|
||||
if let Some(ref cover_cache) = state.cover_cache {
|
||||
albums = cache_albums_images(albums, cover_cache).await;
|
||||
}
|
||||
@@ -270,7 +265,6 @@ async fn get_featured_albums(
|
||||
.get_featured_albums(params.genre_id.as_deref(), ¶ms.type_)
|
||||
.await?;
|
||||
|
||||
#[cfg(feature = "covers")]
|
||||
if let Some(ref cover_cache) = state.cover_cache {
|
||||
albums = cache_albums_images(albums, cover_cache).await;
|
||||
}
|
||||
@@ -300,7 +294,7 @@ async fn get_cache_stats(
|
||||
|
||||
// ============ Helpers pour le cache d'images ============
|
||||
|
||||
#[cfg(all(feature = "pmoserver", feature = "covers"))]
|
||||
#[cfg(feature = "pmoserver")]
|
||||
async fn cache_album_image(mut album: Album, cover_cache: &Arc<pmocovers::Cache>) -> Album {
|
||||
if let Some(ref image_url) = album.image {
|
||||
match cover_cache.add_from_url(image_url, None).await {
|
||||
@@ -315,7 +309,7 @@ async fn cache_album_image(mut album: Album, cover_cache: &Arc<pmocovers::Cache>
|
||||
album
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "pmoserver", feature = "covers"))]
|
||||
#[cfg(feature = "pmoserver")]
|
||||
async fn cache_albums_images(
|
||||
albums: Vec<Album>,
|
||||
cover_cache: &Arc<pmocovers::Cache>,
|
||||
|
||||
@@ -188,6 +188,13 @@ impl QobuzCache {
|
||||
|
||||
/// Retourne des statistiques sur le cache
|
||||
pub async fn stats(&self) -> CacheStats {
|
||||
self.albums.run_pending_tasks().await;
|
||||
self.tracks.run_pending_tasks().await;
|
||||
self.artists.run_pending_tasks().await;
|
||||
self.playlists.run_pending_tasks().await;
|
||||
self.searches.run_pending_tasks().await;
|
||||
self.stream_urls.run_pending_tasks().await;
|
||||
|
||||
CacheStats {
|
||||
albums_count: self.albums.entry_count(),
|
||||
tracks_count: self.tracks.entry_count(),
|
||||
|
||||
@@ -8,8 +8,9 @@ use crate::cache::QobuzCache;
|
||||
use crate::config_ext::QobuzConfigExt;
|
||||
use crate::error::{QobuzError, Result};
|
||||
use crate::models::*;
|
||||
use pmoconfig::Config;
|
||||
use std::sync::Arc;
|
||||
use pmoconfig::{self, Config};
|
||||
use std::future::Future;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Client Qobuz haut-niveau avec cache
|
||||
@@ -19,10 +20,38 @@ pub struct QobuzClient {
|
||||
/// Cache en mémoire
|
||||
cache: Arc<QobuzCache>,
|
||||
/// Informations d'authentification
|
||||
auth_info: Option<AuthInfo>,
|
||||
auth_info: Mutex<Option<AuthInfo>>,
|
||||
/// Identifiants utilisateur pour relogin automatique
|
||||
credentials: Option<(String, String)>,
|
||||
/// Configuration partagée (pour persister les tokens)
|
||||
config: Option<Arc<Config>>,
|
||||
#[cfg(feature = "disk-cache")]
|
||||
/// Cache disque optionnel
|
||||
disk_cache: Option<Arc<dyn crate::disk_cache::CacheStore>>,
|
||||
}
|
||||
|
||||
impl QobuzClient {
|
||||
fn build_client(
|
||||
api: QobuzApi,
|
||||
auth_info: Option<AuthInfo>,
|
||||
credentials: Option<(String, String)>,
|
||||
config: Option<Arc<Config>>,
|
||||
) -> Self {
|
||||
if let Some(info) = &auth_info {
|
||||
api.set_auth_token(info.token.clone(), info.user_id.clone());
|
||||
}
|
||||
|
||||
Self {
|
||||
api,
|
||||
cache: Arc::new(QobuzCache::new()),
|
||||
auth_info: Mutex::new(auth_info),
|
||||
credentials,
|
||||
config,
|
||||
#[cfg(feature = "disk-cache")]
|
||||
disk_cache: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un nouveau client et authentifie avec les credentials fournis
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -49,14 +78,17 @@ impl QobuzClient {
|
||||
pub async fn with_app_id(app_id: &str, username: &str, password: &str) -> Result<Self> {
|
||||
info!("Creating Qobuz client with app ID: {}", app_id);
|
||||
|
||||
let mut api = QobuzApi::new(app_id)?;
|
||||
let api = QobuzApi::new(app_id)?;
|
||||
let auth_info = api.login(username, password).await?;
|
||||
|
||||
Ok(Self {
|
||||
let client = Self::build_client(
|
||||
api,
|
||||
cache: Arc::new(QobuzCache::new()),
|
||||
auth_info: Some(auth_info),
|
||||
})
|
||||
Some(auth_info),
|
||||
Some((username.to_string(), password.to_string())),
|
||||
None,
|
||||
);
|
||||
|
||||
Ok(client.finalize_disk_cache().await)
|
||||
}
|
||||
|
||||
/// Crée un client en utilisant la configuration de pmoconfig
|
||||
@@ -90,160 +122,94 @@ impl QobuzClient {
|
||||
/// 4. Fallback ultime → utilise DEFAULT_APP_ID sans secret (requêtes signées échoueront)
|
||||
pub async fn from_config_obj(config: &Config) -> Result<Self> {
|
||||
let (username, password) = config.get_qobuz_credentials()?;
|
||||
let credentials = (username.clone(), password.clone());
|
||||
let config_arc = Arc::new(config.clone());
|
||||
|
||||
// Étape 0 : Essayer de réutiliser le token stocké dans la configuration
|
||||
// Note: On ne vérifie PAS l'expiration - si le token est invalide, les requêtes
|
||||
// échoueront avec 401/403 et déclencheront un re-login automatique
|
||||
if let (Ok(Some(token)), Ok(Some(user_id))) =
|
||||
(config.get_qobuz_auth_token(), config.get_qobuz_user_id())
|
||||
{
|
||||
info!("✓ Found stored authentication token in configuration");
|
||||
|
||||
// Récupérer l'App ID et le secret depuis la config pour créer l'API
|
||||
let config_appid = config.get_qobuz_appid()?;
|
||||
let config_secret = config.get_qobuz_secret()?;
|
||||
|
||||
match (config_appid, config_secret) {
|
||||
(Some(app_id), Some(secret)) => match QobuzApi::with_secret(&app_id, &secret) {
|
||||
Ok(mut api) => {
|
||||
// Réutiliser le token de la configuration
|
||||
api.set_auth_token(token.clone(), user_id.clone());
|
||||
|
||||
info!("✓ Reusing authentication token (no login required)");
|
||||
info!(" → Token will be validated on first API request");
|
||||
|
||||
let auth_info = AuthInfo {
|
||||
token,
|
||||
user_id,
|
||||
subscription_label: config.get_qobuz_subscription_label().ok().flatten(),
|
||||
};
|
||||
|
||||
return Ok(Self {
|
||||
api,
|
||||
cache: Arc::new(QobuzCache::new()),
|
||||
auth_info: Some(auth_info),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Failed to create API with stored credentials: {}", e);
|
||||
info!("→ Credentials in config are invalid, will perform login");
|
||||
// Continuer vers le login normal
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
debug!("No appid/secret in config, cannot reuse token");
|
||||
info!("→ Missing AppID/secret, will perform login");
|
||||
// Continuer vers le login normal
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!("No stored authentication token found in configuration, will perform login");
|
||||
}
|
||||
|
||||
// Récupérer l'App ID et le secret depuis la config
|
||||
let config_appid = config.get_qobuz_appid()?;
|
||||
let config_secret = config.get_qobuz_secret()?;
|
||||
|
||||
// Déterminer comment créer l'API
|
||||
let mut used_config_credentials = false;
|
||||
|
||||
let mut api = match (config_appid, config_secret) {
|
||||
// Cas 1: AppID ET secret configurés → test avec authentification
|
||||
(Some(app_id), Some(secret)) => {
|
||||
info!(
|
||||
"Creating Qobuz API with configured App ID: {} and secret",
|
||||
app_id
|
||||
);
|
||||
|
||||
match QobuzApi::with_secret(&app_id, &secret) {
|
||||
Ok(mut test_api) => {
|
||||
// Tenter l'authentification pour valider les credentials
|
||||
debug!("Testing configured credentials with login...");
|
||||
match test_api.login(&username, &password).await {
|
||||
Ok(auth_info) => {
|
||||
info!("✓ Configured credentials are valid");
|
||||
|
||||
// Sauvegarder le token dans la configuration
|
||||
use std::time::{SystemTime, UNIX_EPOCH, Duration};
|
||||
let expires_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
+ Duration::from_secs(24 * 3600).as_secs(); // 24h
|
||||
|
||||
if let Err(e) = config.set_qobuz_auth_info(
|
||||
&auth_info.token,
|
||||
&auth_info.user_id,
|
||||
auth_info.subscription_label.as_deref(),
|
||||
expires_at,
|
||||
) {
|
||||
debug!("Failed to save authentication to config: {}", e);
|
||||
} else {
|
||||
info!("✓ Saved authentication token to configuration");
|
||||
}
|
||||
|
||||
// Les credentials sont valides, retourner directement
|
||||
return Ok(Self {
|
||||
api: test_api,
|
||||
cache: Arc::new(QobuzCache::new()),
|
||||
auth_info: Some(auth_info),
|
||||
});
|
||||
}
|
||||
Err(e) if e.is_auth_error() => {
|
||||
info!("✗ Configured credentials failed authentication: {}", e);
|
||||
info!("→ Falling back to Spoofer to obtain new credentials...");
|
||||
// Continuer vers le Spoofer (voir après le match)
|
||||
}
|
||||
Err(e) => {
|
||||
// Autre erreur (réseau, etc.) → propager
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Ok(api) => {
|
||||
used_config_credentials = true;
|
||||
api
|
||||
}
|
||||
Err(e) => {
|
||||
info!("✗ Failed to create API with configured credentials: {}", e);
|
||||
info!("→ Falling back to Spoofer...");
|
||||
// Continuer vers le Spoofer
|
||||
info!(
|
||||
"✗ Failed to create API with configured credentials: {}. Falling back to Spoofer...",
|
||||
e
|
||||
);
|
||||
Self::try_spoofer_fallback(config).await?
|
||||
}
|
||||
}
|
||||
|
||||
// Si on arrive ici, les credentials configurés ont échoué
|
||||
// → Appel du Spoofer
|
||||
Self::try_spoofer_fallback(config).await?
|
||||
}
|
||||
|
||||
// Cas 2: Aucun ou seulement l'un des deux → utiliser directement le Spoofer
|
||||
_ => {
|
||||
info!("AppID or secret not configured, using Spoofer to obtain valid credentials...");
|
||||
info!(
|
||||
"AppID or secret not configured, using Spoofer to obtain valid credentials..."
|
||||
);
|
||||
Self::try_spoofer_fallback(config).await?
|
||||
}
|
||||
};
|
||||
|
||||
// Authentifier l'utilisateur
|
||||
let auth_info = api.login(&username, &password).await?;
|
||||
if config.is_qobuz_auth_valid() {
|
||||
match (config.get_qobuz_auth_token(), config.get_qobuz_user_id()) {
|
||||
(Ok(Some(token)), Ok(Some(user_id)))
|
||||
if !token.is_empty() && !user_id.is_empty() =>
|
||||
{
|
||||
info!("✓ Reusing authentication token (optimistic, no login)");
|
||||
api.set_auth_token(token.clone(), user_id.clone());
|
||||
|
||||
// Sauvegarder le token dans la configuration pour éviter de re-login la prochaine fois
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
let expires_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
+ Duration::from_secs(24 * 3600).as_secs(); // 24h
|
||||
let auth_info = AuthInfo {
|
||||
token,
|
||||
user_id,
|
||||
subscription_label: config.get_qobuz_subscription_label().ok().flatten(),
|
||||
};
|
||||
|
||||
if let Err(e) = config.set_qobuz_auth_info(
|
||||
&auth_info.token,
|
||||
&auth_info.user_id,
|
||||
auth_info.subscription_label.as_deref(),
|
||||
expires_at,
|
||||
) {
|
||||
debug!("Failed to save authentication to config: {}", e);
|
||||
} else {
|
||||
info!("✓ Saved authentication token to configuration");
|
||||
let client = Self::build_client(
|
||||
api,
|
||||
Some(auth_info),
|
||||
Some(credentials.clone()),
|
||||
Some(config_arc.clone()),
|
||||
);
|
||||
|
||||
return Ok(client.finalize_disk_cache().await);
|
||||
}
|
||||
_ => {
|
||||
debug!(
|
||||
"Auth marked valid in config but token/user_id missing or invalid, performing login"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
api,
|
||||
cache: Arc::new(QobuzCache::new()),
|
||||
auth_info: Some(auth_info),
|
||||
})
|
||||
// Authentifier l'utilisateur
|
||||
let mut auth_result = api.login(&username, &password).await;
|
||||
|
||||
if used_config_credentials {
|
||||
if let Err(err) = &auth_result {
|
||||
if err.is_auth_error() {
|
||||
info!("✗ Configured credentials failed authentication: {}", err);
|
||||
info!("→ Falling back to Spoofer to obtain new credentials...");
|
||||
api = Self::try_spoofer_fallback(config).await?;
|
||||
auth_result = api.login(&username, &password).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let auth_info = auth_result?;
|
||||
|
||||
Self::persist_auth_info(config, &auth_info);
|
||||
|
||||
let client = Self::build_client(api, Some(auth_info), Some(credentials), Some(config_arc));
|
||||
|
||||
Ok(client.finalize_disk_cache().await)
|
||||
}
|
||||
|
||||
/// Tente d'utiliser le Spoofer pour obtenir des credentials valides
|
||||
@@ -281,7 +247,10 @@ impl QobuzClient {
|
||||
return Ok(test_api);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Failed to create API with secret from {}: {}", timezone, e);
|
||||
debug!(
|
||||
"Failed to create API with secret from {}: {}",
|
||||
timezone, e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -298,13 +267,19 @@ impl QobuzClient {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Spoofer failed to extract app_id: {}, falling back to DEFAULT_APP_ID", e);
|
||||
info!(
|
||||
"Spoofer failed to extract app_id: {}, falling back to DEFAULT_APP_ID",
|
||||
e
|
||||
);
|
||||
QobuzApi::new(DEFAULT_APP_ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Spoofer failed: {}, falling back to DEFAULT_APP_ID without secret", e);
|
||||
info!(
|
||||
"Spoofer failed: {}, falling back to DEFAULT_APP_ID without secret",
|
||||
e
|
||||
);
|
||||
QobuzApi::new(DEFAULT_APP_ID)
|
||||
}
|
||||
}
|
||||
@@ -321,8 +296,17 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
/// Retourne les informations d'authentification
|
||||
pub fn auth_info(&self) -> Option<&AuthInfo> {
|
||||
self.auth_info.as_ref()
|
||||
pub fn auth_info(&self) -> Option<AuthInfo> {
|
||||
self.auth_info.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[cfg(feature = "disk-cache")]
|
||||
fn user_id(&self) -> Option<String> {
|
||||
self.auth_info
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.map(|info| info.user_id.clone())
|
||||
}
|
||||
|
||||
/// Retourne une référence au cache
|
||||
@@ -330,6 +314,159 @@ impl QobuzClient {
|
||||
self.cache.clone()
|
||||
}
|
||||
|
||||
#[cfg(feature = "disk-cache")]
|
||||
pub async fn purge_disk_cache(&self) -> Result<usize> {
|
||||
if let Some(disk) = &self.disk_cache {
|
||||
disk.purge_expired()
|
||||
.await
|
||||
.map_err(|err| QobuzError::Cache(err.to_string()))
|
||||
} else {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "disk-cache")]
|
||||
pub fn with_disk_cache(mut self, store: Arc<dyn crate::disk_cache::CacheStore>) -> Self {
|
||||
self.disk_cache = Some(store);
|
||||
self
|
||||
}
|
||||
|
||||
#[cfg(feature = "disk-cache")]
|
||||
fn attach_default_disk_cache(mut self) -> Self {
|
||||
if let Some(store) = Self::default_disk_cache_store(self.config.as_deref()) {
|
||||
self = self.with_disk_cache(store);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "disk-cache"))]
|
||||
fn attach_default_disk_cache(self) -> Self {
|
||||
self
|
||||
}
|
||||
|
||||
#[cfg(feature = "disk-cache")]
|
||||
async fn finalize_disk_cache(self) -> Self {
|
||||
let client = self.attach_default_disk_cache();
|
||||
if let Err(err) = client.purge_disk_cache().await {
|
||||
debug!("Failed to purge disk cache on startup: {}", err);
|
||||
}
|
||||
client
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "disk-cache"))]
|
||||
async fn finalize_disk_cache(self) -> Self {
|
||||
self.attach_default_disk_cache()
|
||||
}
|
||||
|
||||
#[cfg(feature = "disk-cache")]
|
||||
fn default_disk_cache_store(
|
||||
config: Option<&Config>,
|
||||
) -> Option<Arc<dyn crate::disk_cache::CacheStore>> {
|
||||
let cache_dir = match config {
|
||||
Some(cfg) => match cfg.get_qobuz_cache_dir() {
|
||||
Ok(dir) => dir,
|
||||
Err(err) => {
|
||||
debug!("Failed to read qobuz cache dir from config: {}", err);
|
||||
return None;
|
||||
}
|
||||
},
|
||||
None => {
|
||||
let global = pmoconfig::get_config();
|
||||
match global.get_qobuz_cache_dir() {
|
||||
Ok(dir) => dir,
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"Failed to read qobuz cache dir from global config: {}",
|
||||
err
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let dir_path = std::path::PathBuf::from(&cache_dir);
|
||||
if let Err(err) = std::fs::create_dir_all(&dir_path) {
|
||||
debug!(
|
||||
"Failed to create disk cache directory {}: {}",
|
||||
dir_path.display(),
|
||||
err
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let db_path = dir_path.join("qobuz_cache.sqlite");
|
||||
|
||||
match crate::disk_cache::SqliteCacheStore::new(db_path) {
|
||||
Ok(store) => {
|
||||
let store: Arc<dyn crate::disk_cache::CacheStore> = Arc::new(store);
|
||||
Some(store)
|
||||
}
|
||||
Err(err) => {
|
||||
debug!("Failed to initialize SQLite disk cache: {}", err);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn call_with_auth_repair<T, F, Fut>(&self, operation: &str, mut op: F) -> Result<T>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = Result<T>> + Send,
|
||||
{
|
||||
match op().await {
|
||||
Ok(result) => Ok(result),
|
||||
Err(err) if err.is_auth_error() => {
|
||||
info!(
|
||||
"Authentication error during {}. Attempting automatic repair...",
|
||||
operation
|
||||
);
|
||||
self.repair_auth().await?;
|
||||
op().await
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_auth(&self) -> Result<()> {
|
||||
let (username, password) = self.credentials.as_ref().cloned().ok_or_else(|| {
|
||||
QobuzError::Unauthorized(
|
||||
"Cannot repair authentication without stored credentials".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let auth_info = self.api.login(&username, &password).await?;
|
||||
|
||||
if let Some(config) = &self.config {
|
||||
Self::persist_auth_info(config.as_ref(), &auth_info);
|
||||
}
|
||||
|
||||
let mut guard = self.auth_info.lock().unwrap();
|
||||
*guard = Some(auth_info);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_auth_info(config: &Config, auth_info: &AuthInfo) {
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
let expires_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
+ Duration::from_secs(24 * 3600).as_secs(); // 24h
|
||||
|
||||
if let Err(e) = config.set_qobuz_auth_info(
|
||||
&auth_info.token,
|
||||
&auth_info.user_id,
|
||||
auth_info.subscription_label.as_deref(),
|
||||
expires_at,
|
||||
) {
|
||||
debug!("Failed to save authentication to config: {}", e);
|
||||
} else {
|
||||
info!("✓ Saved authentication token to configuration");
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Albums ============
|
||||
|
||||
/// Récupère un album par son ID
|
||||
@@ -341,7 +478,9 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
// Sinon, récupérer depuis l'API
|
||||
let album = self.api.get_album(album_id).await?;
|
||||
let album = self
|
||||
.call_with_auth_repair("get_album", || self.api.get_album(album_id))
|
||||
.await?;
|
||||
|
||||
// Mettre en cache
|
||||
self.cache
|
||||
@@ -353,7 +492,9 @@ impl QobuzClient {
|
||||
|
||||
/// Récupère les tracks d'un album
|
||||
pub async fn get_album_tracks(&self, album_id: &str) -> Result<Vec<Track>> {
|
||||
let tracks = self.api.get_album_tracks(album_id).await?;
|
||||
let tracks = self
|
||||
.call_with_auth_repair("get_album_tracks", || self.api.get_album_tracks(album_id))
|
||||
.await?;
|
||||
|
||||
// Mettre les tracks en cache
|
||||
for track in &tracks {
|
||||
@@ -372,7 +513,9 @@ impl QobuzClient {
|
||||
return Ok(track);
|
||||
}
|
||||
|
||||
let track = self.api.get_track(track_id).await?;
|
||||
let track = self
|
||||
.call_with_auth_repair("get_track", || self.api.get_track(track_id))
|
||||
.await?;
|
||||
self.cache
|
||||
.put_track(track_id.to_string(), track.clone())
|
||||
.await;
|
||||
@@ -391,7 +534,9 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
// Sinon, récupérer depuis l'API
|
||||
let info = self.api.get_file_url(track_id).await?;
|
||||
let info = self
|
||||
.call_with_auth_repair("get_file_url", || self.api.get_file_url(track_id))
|
||||
.await?;
|
||||
let url = info.url.clone();
|
||||
|
||||
// Mettre en cache
|
||||
@@ -410,7 +555,11 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
// Pour récupérer un artiste, on doit passer par get_artist_albums
|
||||
let albums = self.api.get_artist_albums(artist_id).await?;
|
||||
let albums = self
|
||||
.call_with_auth_repair("get_artist_albums_for_artist", || {
|
||||
self.api.get_artist_albums(artist_id)
|
||||
})
|
||||
.await?;
|
||||
|
||||
if let Some(first_album) = albums.first() {
|
||||
let artist = first_album.artist.clone();
|
||||
@@ -428,12 +577,14 @@ impl QobuzClient {
|
||||
|
||||
/// Récupère les albums d'un artiste
|
||||
pub async fn get_artist_albums(&self, artist_id: &str) -> Result<Vec<Album>> {
|
||||
self.api.get_artist_albums(artist_id).await
|
||||
self.call_with_auth_repair("get_artist_albums", || self.api.get_artist_albums(artist_id))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Récupère les artistes similaires
|
||||
pub async fn get_similar_artists(&self, artist_id: &str) -> Result<Vec<Artist>> {
|
||||
self.api.get_similar_artists(artist_id).await
|
||||
self.call_with_auth_repair("get_similar_artists", || self.api.get_similar_artists(artist_id))
|
||||
.await
|
||||
}
|
||||
|
||||
// ============ Playlists ============
|
||||
@@ -445,7 +596,9 @@ impl QobuzClient {
|
||||
return Ok(playlist);
|
||||
}
|
||||
|
||||
let playlist = self.api.get_playlist(playlist_id).await?;
|
||||
let playlist = self
|
||||
.call_with_auth_repair("get_playlist", || self.api.get_playlist(playlist_id))
|
||||
.await?;
|
||||
self.cache
|
||||
.put_playlist(playlist_id.to_string(), playlist.clone())
|
||||
.await;
|
||||
@@ -455,14 +608,18 @@ impl QobuzClient {
|
||||
|
||||
/// Récupère les tracks d'une playlist
|
||||
pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result<Vec<Track>> {
|
||||
self.api.get_playlist_tracks(playlist_id).await
|
||||
self.call_with_auth_repair("get_playlist_tracks", || {
|
||||
self.api.get_playlist_tracks(playlist_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// ============ Catalogue ============
|
||||
|
||||
/// Récupère la liste des genres
|
||||
pub async fn get_genres(&self) -> Result<Vec<Genre>> {
|
||||
self.api.get_genres().await
|
||||
self.call_with_auth_repair("get_genres", || self.api.get_genres())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Récupère les albums featured (nouveautés, éditeur, etc.)
|
||||
@@ -471,7 +628,10 @@ impl QobuzClient {
|
||||
genre_id: Option<&str>,
|
||||
type_: &str,
|
||||
) -> Result<Vec<Album>> {
|
||||
self.api.get_featured_albums(genre_id, type_).await
|
||||
self.call_with_auth_repair("get_featured_albums", || {
|
||||
self.api.get_featured_albums(genre_id, type_)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Récupère les playlists featured
|
||||
@@ -480,7 +640,10 @@ impl QobuzClient {
|
||||
genre_id: Option<&str>,
|
||||
tags: Option<&str>,
|
||||
) -> Result<Vec<Playlist>> {
|
||||
self.api.get_featured_playlists(genre_id, tags).await
|
||||
self.call_with_auth_repair("get_featured_playlists", || {
|
||||
self.api.get_featured_playlists(genre_id, tags)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// ============ Recherche ============
|
||||
@@ -502,7 +665,9 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
// Sinon, rechercher via l'API
|
||||
let result = self.api.search(query, type_).await?;
|
||||
let result = self
|
||||
.call_with_auth_repair("search", || self.api.search(query, type_))
|
||||
.await?;
|
||||
|
||||
// Mettre en cache
|
||||
self.cache.put_search(cache_key, result.clone()).await;
|
||||
@@ -538,56 +703,188 @@ impl QobuzClient {
|
||||
|
||||
/// Récupère les albums favoris de l'utilisateur
|
||||
pub async fn get_favorite_albums(&self) -> Result<Vec<Album>> {
|
||||
self.api.get_favorite_albums().await
|
||||
#[cfg(feature = "disk-cache")]
|
||||
let user_id = self
|
||||
.user_id()
|
||||
.ok_or_else(|| QobuzError::Unauthorized("Missing authenticated user ID".into()))?;
|
||||
|
||||
#[cfg(feature = "disk-cache")]
|
||||
let ttl = std::time::Duration::from_secs(6 * 3600);
|
||||
|
||||
#[cfg(feature = "disk-cache")]
|
||||
if let Some(disk) = &self.disk_cache {
|
||||
if let Some(entry) = disk
|
||||
.get_json::<Vec<Album>>(&user_id, "favorites_albums", "all")
|
||||
.await?
|
||||
{
|
||||
if entry.fresh {
|
||||
return Ok(entry.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let albums = self
|
||||
.call_with_auth_repair("get_favorite_albums", || self.api.get_favorite_albums())
|
||||
.await?;
|
||||
|
||||
#[cfg(feature = "disk-cache")]
|
||||
if let Some(disk) = &self.disk_cache {
|
||||
let _ = disk
|
||||
.put_json(&user_id, "favorites_albums", "all", ttl, &albums)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(albums)
|
||||
}
|
||||
|
||||
/// Récupère les artistes favoris de l'utilisateur
|
||||
pub async fn get_favorite_artists(&self) -> Result<Vec<Artist>> {
|
||||
self.api.get_favorite_artists().await
|
||||
self.call_with_auth_repair("get_favorite_artists", || self.api.get_favorite_artists())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Récupère les tracks favorites de l'utilisateur
|
||||
pub async fn get_favorite_tracks(&self) -> Result<Vec<Track>> {
|
||||
self.api.get_favorite_tracks().await
|
||||
#[cfg(feature = "disk-cache")]
|
||||
let user_id = self
|
||||
.user_id()
|
||||
.ok_or_else(|| QobuzError::Unauthorized("Missing authenticated user ID".into()))?;
|
||||
|
||||
#[cfg(feature = "disk-cache")]
|
||||
let ttl = std::time::Duration::from_secs(6 * 3600);
|
||||
|
||||
#[cfg(feature = "disk-cache")]
|
||||
if let Some(disk) = &self.disk_cache {
|
||||
if let Some(entry) = disk
|
||||
.get_json::<Vec<Track>>(&user_id, "favorites_tracks", "all")
|
||||
.await?
|
||||
{
|
||||
if entry.fresh {
|
||||
return Ok(entry.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tracks = self
|
||||
.call_with_auth_repair("get_favorite_tracks", || self.api.get_favorite_tracks())
|
||||
.await?;
|
||||
|
||||
#[cfg(feature = "disk-cache")]
|
||||
if let Some(disk) = &self.disk_cache {
|
||||
let _ = disk
|
||||
.put_json(&user_id, "favorites_tracks", "all", ttl, &tracks)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(tracks)
|
||||
}
|
||||
|
||||
/// Récupère les playlists de l'utilisateur
|
||||
pub async fn get_user_playlists(&self) -> Result<Vec<Playlist>> {
|
||||
self.api.get_user_playlists().await
|
||||
#[cfg(feature = "disk-cache")]
|
||||
let user_id = self
|
||||
.user_id()
|
||||
.ok_or_else(|| QobuzError::Unauthorized("Missing authenticated user ID".into()))?;
|
||||
|
||||
#[cfg(feature = "disk-cache")]
|
||||
let ttl = std::time::Duration::from_secs(6 * 3600);
|
||||
|
||||
#[cfg(feature = "disk-cache")]
|
||||
if let Some(disk) = &self.disk_cache {
|
||||
if let Some(entry) = disk
|
||||
.get_json::<Vec<Playlist>>(&user_id, "user_playlists", "all")
|
||||
.await?
|
||||
{
|
||||
if entry.fresh {
|
||||
return Ok(entry.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let playlists = self
|
||||
.call_with_auth_repair("get_user_playlists", || self.api.get_user_playlists())
|
||||
.await?;
|
||||
|
||||
#[cfg(feature = "disk-cache")]
|
||||
if let Some(disk) = &self.disk_cache {
|
||||
let _ = disk
|
||||
.put_json(&user_id, "user_playlists", "all", ttl, &playlists)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(playlists)
|
||||
}
|
||||
|
||||
/// Ajoute un album aux favoris
|
||||
pub async fn add_favorite_album(&self, album_id: &str) -> Result<()> {
|
||||
self.api.add_favorite_album(album_id).await
|
||||
self.call_with_auth_repair("add_favorite_album", || {
|
||||
self.api.add_favorite_album(album_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Supprime un album des favoris
|
||||
pub async fn remove_favorite_album(&self, album_id: &str) -> Result<()> {
|
||||
self.api.remove_favorite_album(album_id).await
|
||||
self.call_with_auth_repair("remove_favorite_album", || {
|
||||
self.api.remove_favorite_album(album_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Ajoute un track aux favoris
|
||||
pub async fn add_favorite_track(&self, track_id: &str) -> Result<()> {
|
||||
self.api.add_favorite_track(track_id).await
|
||||
self.call_with_auth_repair("add_favorite_track", || {
|
||||
self.api.add_favorite_track(track_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Supprime un track des favoris
|
||||
pub async fn remove_favorite_track(&self, track_id: &str) -> Result<()> {
|
||||
self.api.remove_favorite_track(track_id).await
|
||||
self.call_with_auth_repair("remove_favorite_track", || {
|
||||
self.api.remove_favorite_track(track_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Ajoute un track à une playlist
|
||||
pub async fn add_to_playlist(&self, playlist_id: &str, track_id: &str) -> Result<()> {
|
||||
self.api.add_to_playlist(playlist_id, track_id).await
|
||||
self.call_with_auth_repair("add_to_playlist", || {
|
||||
self.api.add_to_playlist(playlist_id, track_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config_ext::QobuzConfigExt;
|
||||
|
||||
#[test]
|
||||
fn test_audio_format() {
|
||||
assert_eq!(AudioFormat::default(), AudioFormat::Flac_Lossless);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_config_reuses_token_without_login() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let config_path = temp_dir.path().to_string_lossy().to_string();
|
||||
let config = pmoconfig::Config::load_config(&config_path).unwrap();
|
||||
|
||||
config.set_qobuz_username("user@example.com").unwrap();
|
||||
config.set_qobuz_password("password").unwrap();
|
||||
config.set_qobuz_appid("1401488693436528").unwrap();
|
||||
// base64 for "secret"
|
||||
config.set_qobuz_secret("c2VjcmV0").unwrap();
|
||||
config
|
||||
.set_qobuz_auth_info("token123", "user123", Some("Hi-Fi"), 1_700_000_000)
|
||||
.unwrap();
|
||||
|
||||
let client = QobuzClient::from_config_obj(&config).await.unwrap();
|
||||
let auth_info = client.auth_info().expect("auth info");
|
||||
|
||||
assert_eq!(auth_info.token, "token123");
|
||||
assert_eq!(auth_info.user_id, "user123");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,36 @@ pub trait QobuzConfigExt {
|
||||
|
||||
/// Définit le répertoire de cache Qobuz
|
||||
fn set_qobuz_cache_dir(&self, directory: String) -> Result<()>;
|
||||
|
||||
/// Récupère le nombre maximum de requêtes concurrentes
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Le nombre maximum de requêtes concurrentes, ou None si non configuré (défaut: 2)
|
||||
fn get_qobuz_rate_limit_max_concurrent(&self) -> Result<Option<usize>>;
|
||||
|
||||
/// Définit le nombre maximum de requêtes concurrentes
|
||||
fn set_qobuz_rate_limit_max_concurrent(&self, max: usize) -> Result<()>;
|
||||
|
||||
/// Récupère le délai minimum entre requêtes en millisecondes
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Le délai minimum en ms, ou None si non configuré (défaut: 400ms)
|
||||
fn get_qobuz_rate_limit_min_delay_ms(&self) -> Result<Option<u64>>;
|
||||
|
||||
/// Définit le délai minimum entre requêtes
|
||||
fn set_qobuz_rate_limit_min_delay_ms(&self, delay_ms: u64) -> Result<()>;
|
||||
|
||||
/// Vérifie si le rate limiting est activé
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// true si activé (défaut), false sinon
|
||||
fn is_qobuz_rate_limiting_enabled(&self) -> bool;
|
||||
|
||||
/// Active ou désactive le rate limiting
|
||||
fn set_qobuz_rate_limiting_enabled(&self, enabled: bool) -> Result<()>;
|
||||
}
|
||||
|
||||
impl QobuzConfigExt for Config {
|
||||
@@ -343,23 +373,21 @@ impl QobuzConfigExt for Config {
|
||||
}
|
||||
|
||||
fn is_qobuz_auth_valid(&self) -> bool {
|
||||
// Vérifier si un token existe
|
||||
if self.get_qobuz_auth_token().ok().flatten().is_none() {
|
||||
return false;
|
||||
}
|
||||
let token_present = self
|
||||
.get_qobuz_auth_token()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|token| !token.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Vérifier si le token n'est pas expiré
|
||||
if let Ok(Some(expires_at)) = self.get_qobuz_token_expires_at() {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let user_present = self
|
||||
.get_qobuz_user_id()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|user_id| !user_id.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
now < expires_at
|
||||
} else {
|
||||
false
|
||||
}
|
||||
token_present && user_present
|
||||
}
|
||||
|
||||
fn get_qobuz_cache_dir(&self) -> Result<String> {
|
||||
@@ -369,4 +397,48 @@ impl QobuzConfigExt for Config {
|
||||
fn set_qobuz_cache_dir(&self, directory: String) -> Result<()> {
|
||||
self.set_managed_dir(&["host", "qobuz_cache", "directory"], directory)
|
||||
}
|
||||
|
||||
fn get_qobuz_rate_limit_max_concurrent(&self) -> Result<Option<usize>> {
|
||||
match self.get_value(&["accounts", "qobuz", "rate_limit", "max_concurrent"]) {
|
||||
Ok(Value::Number(n)) if n.is_u64() => Ok(Some(n.as_u64().unwrap() as usize)),
|
||||
Ok(_) => Ok(None),
|
||||
Err(_) => Ok(Some(2)), // Default: 2 concurrent requests
|
||||
}
|
||||
}
|
||||
|
||||
fn set_qobuz_rate_limit_max_concurrent(&self, max: usize) -> Result<()> {
|
||||
self.set_value(
|
||||
&["accounts", "qobuz", "rate_limit", "max_concurrent"],
|
||||
Value::Number(serde_yaml::Number::from(max)),
|
||||
)
|
||||
}
|
||||
|
||||
fn get_qobuz_rate_limit_min_delay_ms(&self) -> Result<Option<u64>> {
|
||||
match self.get_value(&["accounts", "qobuz", "rate_limit", "min_delay_ms"]) {
|
||||
Ok(Value::Number(n)) if n.is_u64() => Ok(Some(n.as_u64().unwrap())),
|
||||
Ok(_) => Ok(None),
|
||||
Err(_) => Ok(Some(400)), // Default: 400ms
|
||||
}
|
||||
}
|
||||
|
||||
fn set_qobuz_rate_limit_min_delay_ms(&self, delay_ms: u64) -> Result<()> {
|
||||
self.set_value(
|
||||
&["accounts", "qobuz", "rate_limit", "min_delay_ms"],
|
||||
Value::Number(serde_yaml::Number::from(delay_ms)),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_qobuz_rate_limiting_enabled(&self) -> bool {
|
||||
match self.get_value(&["accounts", "qobuz", "rate_limit", "enabled"]) {
|
||||
Ok(Value::Bool(b)) => b,
|
||||
_ => true, // Default: enabled
|
||||
}
|
||||
}
|
||||
|
||||
fn set_qobuz_rate_limiting_enabled(&self, enabled: bool) -> Result<()> {
|
||||
self.set_value(
|
||||
&["accounts", "qobuz", "rate_limit", "enabled"],
|
||||
Value::Bool(enabled),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,366 +1,215 @@
|
||||
//! Cache disque simple pour les données volumineuses de l'API Qobuz
|
||||
//!
|
||||
//! Ce module gère le cache sur disque des données qui changent rarement :
|
||||
//! - Favoris (albums, tracks, artistes)
|
||||
//! - Playlists utilisateur
|
||||
//! - Bibliothèque
|
||||
//!
|
||||
//! Contrairement à pmocache (conçu pour des fichiers binaires avec téléchargement),
|
||||
//! ce cache est optimisé pour du JSON provenant de l'API.
|
||||
#![cfg(feature = "disk-cache")]
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::anyhow;
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use tracing::{debug, info};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
/// Cache disque pour données JSON de l'API Qobuz
|
||||
pub struct DiskCache {
|
||||
/// Répertoire de cache
|
||||
cache_dir: PathBuf,
|
||||
use rusqlite::{params, Connection};
|
||||
use tokio::task;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CacheEntry<T> {
|
||||
pub value: T,
|
||||
pub age: Duration,
|
||||
pub fresh: bool,
|
||||
}
|
||||
|
||||
impl DiskCache {
|
||||
/// Crée un nouveau cache disque
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cache_dir` - Répertoire où stocker les fichiers cachés
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pmoqobuz::disk_cache::DiskCache;
|
||||
///
|
||||
/// let cache = DiskCache::new(".pmomusic/cache/qobuz")?;
|
||||
/// # Ok::<(), anyhow::Error>(())
|
||||
/// ```
|
||||
pub fn new<P: AsRef<Path>>(cache_dir: P) -> Result<Self> {
|
||||
let cache_dir = cache_dir.as_ref().to_path_buf();
|
||||
|
||||
// Créer le répertoire s'il n'existe pas
|
||||
if !cache_dir.exists() {
|
||||
fs::create_dir_all(&cache_dir)?;
|
||||
info!("Created cache directory: {}", cache_dir.display());
|
||||
}
|
||||
|
||||
Ok(Self { cache_dir })
|
||||
}
|
||||
|
||||
/// Construit le chemin d'un fichier de cache
|
||||
///
|
||||
/// Format: `{cache_dir}/{key}.json`
|
||||
fn cache_path(&self, key: &str) -> PathBuf {
|
||||
self.cache_dir.join(format!("{}.json", key))
|
||||
}
|
||||
|
||||
/// Sauvegarde des données dans le cache
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - Identifiant unique du cache (ex: "favorites_albums_123456")
|
||||
/// * `data` - Données à sauvegarder
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoqobuz::disk_cache::DiskCache;
|
||||
/// # use pmoqobuz::Album;
|
||||
/// # let cache = DiskCache::new(".cache")?;
|
||||
/// let albums: Vec<Album> = vec![/* ... */];
|
||||
/// cache.save("favorites_albums_123", &albums)?;
|
||||
/// # Ok::<(), anyhow::Error>(())
|
||||
/// ```
|
||||
pub fn save<T: Serialize>(&self, key: &str, data: &T) -> Result<()> {
|
||||
let path = self.cache_path(key);
|
||||
let json = serde_json::to_string_pretty(data)?;
|
||||
|
||||
fs::write(&path, json)?;
|
||||
debug!("Saved cache to {}", path.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Charge des données depuis le cache
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - Identifiant unique du cache
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Les données désérialisées, ou None si le cache n'existe pas
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoqobuz::disk_cache::DiskCache;
|
||||
/// # use pmoqobuz::Album;
|
||||
/// # let cache = DiskCache::new(".cache")?;
|
||||
/// if let Some(albums) = cache.load::<Vec<Album>>("favorites_albums_123")? {
|
||||
/// println!("Loaded {} albums from cache", albums.len());
|
||||
/// }
|
||||
/// # Ok::<(), anyhow::Error>(())
|
||||
/// ```
|
||||
pub fn load<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
|
||||
let path = self.cache_path(key);
|
||||
|
||||
if !path.exists() {
|
||||
debug!("Cache file does not exist: {}", path.display());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let json = fs::read_to_string(&path)?;
|
||||
let data: T = serde_json::from_str(&json)?;
|
||||
|
||||
debug!("Loaded cache from {}", path.display());
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
/// Charge des données avec vérification du TTL
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - Identifiant unique du cache
|
||||
/// * `ttl` - Durée de validité maximale
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Les données si le cache existe ET n'est pas expiré, None sinon
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pmoqobuz::disk_cache::DiskCache;
|
||||
/// # use pmoqobuz::Album;
|
||||
/// # use std::time::Duration;
|
||||
/// # let cache = DiskCache::new(".cache")?;
|
||||
/// // Cache valide pendant 1 heure
|
||||
/// if let Some(albums) = cache.load_with_ttl::<Vec<Album>>(
|
||||
/// "favorites_albums_123",
|
||||
/// Duration::from_secs(3600)
|
||||
/// )? {
|
||||
/// println!("Cache still valid!");
|
||||
/// } else {
|
||||
/// println!("Cache expired or missing");
|
||||
/// }
|
||||
/// # Ok::<(), anyhow::Error>(())
|
||||
/// ```
|
||||
pub fn load_with_ttl<T: DeserializeOwned>(
|
||||
#[async_trait::async_trait]
|
||||
pub trait CacheStore: Send + Sync {
|
||||
async fn get_json<T: DeserializeOwned + Send>(
|
||||
&self,
|
||||
user_id: &str,
|
||||
namespace: &str,
|
||||
key: &str,
|
||||
) -> anyhow::Result<Option<CacheEntry<T>>>;
|
||||
|
||||
async fn put_json<T: Serialize + Send + Sync>(
|
||||
&self,
|
||||
user_id: &str,
|
||||
namespace: &str,
|
||||
key: &str,
|
||||
ttl: Duration,
|
||||
) -> Result<Option<T>> {
|
||||
let path = self.cache_path(key);
|
||||
value: &T,
|
||||
) -> anyhow::Result<()>;
|
||||
|
||||
if !path.exists() {
|
||||
debug!("Cache file does not exist: {}", path.display());
|
||||
return Ok(None);
|
||||
}
|
||||
async fn invalidate(
|
||||
&self,
|
||||
user_id: &str,
|
||||
namespace: &str,
|
||||
key: &str,
|
||||
) -> anyhow::Result<()>;
|
||||
|
||||
// Vérifier l'âge du fichier
|
||||
let metadata = fs::metadata(&path)?;
|
||||
let modified = metadata.modified()?;
|
||||
let age = SystemTime::now()
|
||||
.duration_since(modified)
|
||||
.unwrap_or(Duration::MAX);
|
||||
async fn purge_expired(&self) -> anyhow::Result<usize>;
|
||||
}
|
||||
|
||||
if age > ttl {
|
||||
debug!(
|
||||
"Cache expired (age: {}s > ttl: {}s): {}",
|
||||
age.as_secs(),
|
||||
ttl.as_secs(),
|
||||
path.display()
|
||||
pub struct SqliteCacheStore {
|
||||
db_path: PathBuf,
|
||||
}
|
||||
|
||||
impl SqliteCacheStore {
|
||||
pub fn new(db_path: PathBuf) -> anyhow::Result<Self> {
|
||||
let store = Self { db_path };
|
||||
store.init_blocking()?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
fn init_blocking(&self) -> anyhow::Result<()> {
|
||||
let conn = Connection::open(&self.db_path)?;
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS qobuz_cache (
|
||||
user_id TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL,
|
||||
ttl_seconds INTEGER NOT NULL,
|
||||
json BLOB NOT NULL,
|
||||
PRIMARY KEY (user_id, namespace, key)
|
||||
);
|
||||
// Optionnel : supprimer le fichier expiré
|
||||
let _ = fs::remove_file(&path);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Cache valid (age: {}s < ttl: {}s): {}",
|
||||
age.as_secs(),
|
||||
ttl.as_secs(),
|
||||
path.display()
|
||||
);
|
||||
|
||||
let json = fs::read_to_string(&path)?;
|
||||
let data: T = serde_json::from_str(&json)?;
|
||||
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
/// Invalide (supprime) un cache
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - Identifiant unique du cache
|
||||
pub fn invalidate(&self, key: &str) -> Result<()> {
|
||||
let path = self.cache_path(key);
|
||||
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)?;
|
||||
debug!("Invalidated cache: {}", path.display());
|
||||
}
|
||||
|
||||
CREATE INDEX IF NOT EXISTS qobuz_cache_expiry
|
||||
ON qobuz_cache (fetched_at, ttl_seconds);
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Supprime tous les fichiers de cache
|
||||
pub fn clear_all(&self) -> Result<()> {
|
||||
for entry in fs::read_dir(&self.cache_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("json") {
|
||||
fs::remove_file(&path)?;
|
||||
debug!("Removed cache file: {}", path.display());
|
||||
}
|
||||
fn now_seconds() -> i64 {
|
||||
match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||
Ok(duration) => i64::try_from(duration.as_secs()).unwrap_or(i64::MAX),
|
||||
Err(_) => 0,
|
||||
}
|
||||
|
||||
info!("Cleared all cache files");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retourne la taille totale du cache en octets
|
||||
pub fn size(&self) -> Result<u64> {
|
||||
let mut total = 0u64;
|
||||
|
||||
for entry in fs::read_dir(&self.cache_dir)? {
|
||||
let entry = entry?;
|
||||
let metadata = entry.metadata()?;
|
||||
|
||||
if metadata.is_file() {
|
||||
total += metadata.len();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Retourne le nombre de fichiers en cache
|
||||
pub fn count(&self) -> Result<usize> {
|
||||
let mut count = 0;
|
||||
|
||||
for entry in fs::read_dir(&self.cache_dir)? {
|
||||
let entry = entry?;
|
||||
|
||||
if entry.path().extension().and_then(|s| s.to_str()) == Some("json") {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tempfile::tempdir;
|
||||
#[async_trait::async_trait]
|
||||
impl CacheStore for SqliteCacheStore {
|
||||
async fn get_json<T: DeserializeOwned + Send>(
|
||||
&self,
|
||||
user_id: &str,
|
||||
namespace: &str,
|
||||
key: &str,
|
||||
) -> anyhow::Result<Option<CacheEntry<T>>> {
|
||||
let user_id = user_id.to_owned();
|
||||
let namespace = namespace.to_owned();
|
||||
let key = key.to_owned();
|
||||
let db_path = self.db_path.clone();
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
struct TestData {
|
||||
id: String,
|
||||
value: i32,
|
||||
task::spawn_blocking(move || {
|
||||
let conn = Connection::open(db_path)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT fetched_at, ttl_seconds, json
|
||||
FROM qobuz_cache
|
||||
WHERE user_id = ?1 AND namespace = ?2 AND key = ?3",
|
||||
)?;
|
||||
|
||||
let result = stmt.query_row(
|
||||
params![user_id, namespace, key],
|
||||
|row| {
|
||||
let fetched_at: i64 = row.get(0)?;
|
||||
let ttl_seconds: i64 = row.get(1)?;
|
||||
let data: Vec<u8> = row.get(2)?;
|
||||
let now = Self::now_seconds();
|
||||
let fresh = now <= fetched_at + ttl_seconds;
|
||||
let age_secs = if now >= fetched_at {
|
||||
(now - fetched_at) as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let age = Duration::from_secs(age_secs);
|
||||
let value = serde_json::from_slice(&data)?;
|
||||
Ok(CacheEntry {
|
||||
value,
|
||||
age,
|
||||
fresh,
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(entry) => Ok(Some(entry)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|err| anyhow!(err))?
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load() -> Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let cache = DiskCache::new(dir.path())?;
|
||||
async fn put_json<T: Serialize + Send + Sync>(
|
||||
&self,
|
||||
user_id: &str,
|
||||
namespace: &str,
|
||||
key: &str,
|
||||
ttl: Duration,
|
||||
value: &T,
|
||||
) -> anyhow::Result<()> {
|
||||
let user_id = user_id.to_owned();
|
||||
let namespace = namespace.to_owned();
|
||||
let key = key.to_owned();
|
||||
let db_path = self.db_path.clone();
|
||||
let ttl_seconds = i64::try_from(ttl.as_secs()).unwrap_or(i64::MAX);
|
||||
let now = Self::now_seconds();
|
||||
let json = serde_json::to_vec(value)?;
|
||||
|
||||
let data = TestData {
|
||||
id: "test123".to_string(),
|
||||
value: 42,
|
||||
};
|
||||
|
||||
// Sauvegarder
|
||||
cache.save("test_key", &data)?;
|
||||
|
||||
// Charger
|
||||
let loaded: Option<TestData> = cache.load("test_key")?;
|
||||
assert!(loaded.is_some());
|
||||
assert_eq!(loaded.unwrap(), data);
|
||||
|
||||
Ok(())
|
||||
task::spawn_blocking(move || {
|
||||
let conn = Connection::open(db_path)?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO qobuz_cache
|
||||
(user_id, namespace, key, fetched_at, ttl_seconds, json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![user_id, namespace, key, now, ttl_seconds, json],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|err| anyhow!(err))?
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_nonexistent() -> Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let cache = DiskCache::new(dir.path())?;
|
||||
async fn invalidate(
|
||||
&self,
|
||||
user_id: &str,
|
||||
namespace: &str,
|
||||
key: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let user_id = user_id.to_owned();
|
||||
let namespace = namespace.to_owned();
|
||||
let key = key.to_owned();
|
||||
let db_path = self.db_path.clone();
|
||||
|
||||
let loaded: Option<TestData> = cache.load("nonexistent")?;
|
||||
assert!(loaded.is_none());
|
||||
|
||||
Ok(())
|
||||
task::spawn_blocking(move || {
|
||||
let conn = Connection::open(db_path)?;
|
||||
conn.execute(
|
||||
"DELETE FROM qobuz_cache
|
||||
WHERE user_id = ?1 AND namespace = ?2 AND key = ?3",
|
||||
params![user_id, namespace, key],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|err| anyhow!(err))?
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ttl() -> Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let cache = DiskCache::new(dir.path())?;
|
||||
async fn purge_expired(&self) -> anyhow::Result<usize> {
|
||||
let db_path = self.db_path.clone();
|
||||
let now = Self::now_seconds();
|
||||
|
||||
let data = TestData {
|
||||
id: "test123".to_string(),
|
||||
value: 42,
|
||||
};
|
||||
|
||||
cache.save("test_key", &data)?;
|
||||
|
||||
// Charger immédiatement (< TTL)
|
||||
let loaded: Option<TestData> =
|
||||
cache.load_with_ttl("test_key", Duration::from_secs(60))?;
|
||||
assert!(loaded.is_some());
|
||||
|
||||
// Charger avec TTL expiré
|
||||
let loaded: Option<TestData> = cache.load_with_ttl("test_key", Duration::from_secs(0))?;
|
||||
assert!(loaded.is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalidate() -> Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let cache = DiskCache::new(dir.path())?;
|
||||
|
||||
let data = TestData {
|
||||
id: "test123".to_string(),
|
||||
value: 42,
|
||||
};
|
||||
|
||||
cache.save("test_key", &data)?;
|
||||
assert!(cache.load::<TestData>("test_key")?.is_some());
|
||||
|
||||
cache.invalidate("test_key")?;
|
||||
assert!(cache.load::<TestData>("test_key")?.is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_size_and_count() -> Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let cache = DiskCache::new(dir.path())?;
|
||||
|
||||
assert_eq!(cache.count()?, 0);
|
||||
assert_eq!(cache.size()?, 0);
|
||||
|
||||
let data = TestData {
|
||||
id: "test123".to_string(),
|
||||
value: 42,
|
||||
};
|
||||
|
||||
cache.save("test1", &data)?;
|
||||
cache.save("test2", &data)?;
|
||||
|
||||
assert_eq!(cache.count()?, 2);
|
||||
assert!(cache.size()? > 0);
|
||||
|
||||
Ok(())
|
||||
task::spawn_blocking(move || {
|
||||
let conn = Connection::open(db_path)?;
|
||||
let changes = conn.execute(
|
||||
"DELETE FROM qobuz_cache
|
||||
WHERE (fetched_at + ttl_seconds) <= ?1",
|
||||
params![now],
|
||||
)?;
|
||||
Ok(changes)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| anyhow!(err))?
|
||||
}
|
||||
}
|
||||
|
||||
pub use {CacheEntry, CacheStore, SqliteCacheStore};
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
//! ### Exemple basique avec configuration automatique
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmoqobuz::QobuzClient;
|
||||
//! use pmoqobuz::{QobuzClient, ToDIDL};
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
@@ -66,7 +66,7 @@
|
||||
//! ### Exemple avec credentials personnalisés
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmoqobuz::QobuzClient;
|
||||
//! use pmoqobuz::{QobuzClient, ToDIDL};
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
@@ -82,7 +82,7 @@
|
||||
//! ### Export DIDL-Lite
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmoqobuz::QobuzClient;
|
||||
//! use pmoqobuz::{QobuzClient, ToDIDL};
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
@@ -115,19 +115,16 @@
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pmoqobuz::{QobuzSource, QobuzClient};
|
||||
//! use pmoaudiocache::Cache as AudioCache;
|
||||
//! 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 audio_cache = Arc::new(AudioCache::new("./cache/audio", 100)?);
|
||||
//!
|
||||
//! let source = QobuzSource::new_with_cache(
|
||||
//! client,
|
||||
//! "http://localhost:8080",
|
||||
//! Some(cover_cache),
|
||||
//! None,
|
||||
//! );
|
||||
//! let source = QobuzSource::new(client, cover_cache, audio_cache);
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
@@ -139,7 +136,8 @@
|
||||
//! ```rust,no_run
|
||||
//! use pmoqobuz::{QobuzSource, QobuzClient};
|
||||
//! use pmocovers::Cache as CoverCache;
|
||||
//! use pmoaudiocache::AudioCache;
|
||||
//! use pmoaudiocache::Cache as AudioCache;
|
||||
//! use pmosource::MusicSource;
|
||||
//! use std::sync::Arc;
|
||||
//!
|
||||
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -147,15 +145,10 @@
|
||||
//! 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),
|
||||
//! );
|
||||
//! let source = QobuzSource::new(client, cover_cache, audio_cache);
|
||||
//!
|
||||
//! // Add a track with caching
|
||||
//! let tracks = client.get_favorite_tracks().await?;
|
||||
//! let tracks = source.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
|
||||
@@ -218,6 +211,7 @@ pub mod cache;
|
||||
pub mod client;
|
||||
pub mod config_ext;
|
||||
pub mod didl;
|
||||
#[cfg(feature = "disk-cache")]
|
||||
pub mod disk_cache;
|
||||
pub mod error;
|
||||
pub mod models;
|
||||
|
||||
@@ -141,7 +141,6 @@ pub trait QobuzServerExt {
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg(feature = "covers")]
|
||||
async fn init_qobuz_client_with_covers(
|
||||
&mut self,
|
||||
username: &str,
|
||||
@@ -176,7 +175,6 @@ pub trait QobuzServerExt {
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg(feature = "covers")]
|
||||
async fn init_qobuz_client_configured_with_covers(
|
||||
&mut self,
|
||||
cover_cache: Arc<pmocovers::Cache>,
|
||||
|
||||
@@ -51,7 +51,6 @@ impl QobuzServerExt for Server {
|
||||
// Créer l'état de l'API sans cache d'images
|
||||
let state = QobuzState {
|
||||
client: client.clone(),
|
||||
#[cfg(feature = "covers")]
|
||||
cover_cache: None,
|
||||
};
|
||||
|
||||
@@ -75,7 +74,6 @@ impl QobuzServerExt for Server {
|
||||
self.init_qobuz_client(&username, &password).await
|
||||
}
|
||||
|
||||
#[cfg(feature = "covers")]
|
||||
async fn init_qobuz_client_with_covers(
|
||||
&mut self,
|
||||
username: &str,
|
||||
@@ -106,7 +104,6 @@ impl QobuzServerExt for Server {
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
#[cfg(feature = "covers")]
|
||||
async fn init_qobuz_client_configured_with_covers(
|
||||
&mut self,
|
||||
cover_cache: Arc<pmocovers::Cache>,
|
||||
|
||||
@@ -40,13 +40,18 @@ const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use std::sync::Arc;
|
||||
/// use pmoaudiocache::cache as audio_cache;
|
||||
/// use pmocovers::cache as cover_cache;
|
||||
/// use pmoqobuz::{QobuzSource, QobuzClient};
|
||||
/// use pmosource::MusicSource;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let client = QobuzClient::from_config().await?;
|
||||
/// let source = QobuzSource::new(client);
|
||||
/// let cover_cache = Arc::new(cover_cache::new_cache("/tmp/qobuz_covers", 256)?);
|
||||
/// let audio_cache = Arc::new(audio_cache::new_cache("/tmp/qobuz_audio", 64)?);
|
||||
/// let source = QobuzSource::new(client, cover_cache, audio_cache);
|
||||
///
|
||||
/// println!("Source: {}", source.name());
|
||||
/// println!("Supports FIFO: {}", source.supports_fifo());
|
||||
@@ -214,6 +219,274 @@ impl QobuzSource {
|
||||
Ok(track_id)
|
||||
}
|
||||
|
||||
/// Add track with lazy audio caching (cover eager, audio lazy)
|
||||
///
|
||||
/// This method caches cover art immediately (small, needed for UI) but
|
||||
/// defers audio download until the track is actually played.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `track` - The Qobuz track to add
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The track ID (e.g., "qobuz://track/12345")
|
||||
pub async fn add_track_lazy(&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()))?;
|
||||
|
||||
// 1. Cache cover EAGERLY (small, UI needs it)
|
||||
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,
|
||||
conversion: None,
|
||||
};
|
||||
|
||||
// 3. Cache audio LAZILY (KEY CHANGE: use cache_audio_lazy)
|
||||
let cached_audio_pk = self
|
||||
.inner
|
||||
.cache_manager
|
||||
.cache_audio_lazy(&stream_url, Some(metadata))
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// 4. Store metadata
|
||||
self.inner
|
||||
.cache_manager
|
||||
.update_metadata(
|
||||
track_id.clone(),
|
||||
pmosource::TrackMetadata {
|
||||
original_uri: stream_url,
|
||||
cached_audio_pk,
|
||||
cached_cover_pk,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(track_id)
|
||||
}
|
||||
|
||||
/// Load full album into pmoplaylist with lazy audio
|
||||
///
|
||||
/// This method fetches all tracks from a Qobuz album and adds them to a playlist
|
||||
/// with lazy audio loading. Covers are downloaded eagerly, audio lazily.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `playlist_id` - ID of the target playlist
|
||||
/// * `album_id` - Qobuz album ID
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Number of tracks successfully added
|
||||
pub async fn add_album_to_playlist(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
album_id: &str,
|
||||
) -> Result<usize> {
|
||||
use tracing::{info, warn, debug};
|
||||
|
||||
// 1. Get tracks from Qobuz (goes through rate limiter)
|
||||
let tracks = self
|
||||
.inner
|
||||
.client
|
||||
.get_album_tracks(album_id)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
if tracks.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
info!(
|
||||
"Adding album {} ({} tracks) to playlist {} with lazy audio",
|
||||
album_id,
|
||||
tracks.len(),
|
||||
playlist_id
|
||||
);
|
||||
|
||||
// 2. Add each track lazily + collect lazy PKs
|
||||
let mut lazy_pks = Vec::with_capacity(tracks.len());
|
||||
|
||||
for (i, track) in tracks.iter().enumerate() {
|
||||
match self.add_track_lazy(track).await {
|
||||
Ok(track_id) => {
|
||||
debug!("Track {}/{}: {} (lazy)", i + 1, tracks.len(), track.title);
|
||||
|
||||
// Extract lazy PK from cache manager
|
||||
if let Some(metadata) = self.inner.cache_manager.get_metadata(&track_id).await {
|
||||
if let Some(audio_pk) = metadata.cached_audio_pk {
|
||||
lazy_pks.push(audio_pk);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to add track {} ({}): {}",
|
||||
i + 1,
|
||||
track.title,
|
||||
e
|
||||
);
|
||||
// Continue with other tracks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Batch insert into playlist (single DB transaction)
|
||||
let playlist_manager = pmoplaylist::PlaylistManager();
|
||||
let writer = playlist_manager
|
||||
.get_write_handle(playlist_id.to_string())
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||
|
||||
writer
|
||||
.push_lazy_batch(lazy_pks.clone())
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||
|
||||
// 4. Enable lazy mode with lookahead of 2 tracks
|
||||
playlist_manager.enable_lazy_mode(playlist_id, 2);
|
||||
|
||||
info!(
|
||||
"Album {} added: {}/{} tracks",
|
||||
album_id,
|
||||
lazy_pks.len(),
|
||||
tracks.len()
|
||||
);
|
||||
|
||||
Ok(lazy_pks.len())
|
||||
}
|
||||
|
||||
/// Load Qobuz playlist into pmoplaylist with lazy audio
|
||||
///
|
||||
/// This method fetches all tracks from a Qobuz playlist and adds them to a pmoplaylist
|
||||
/// with lazy audio loading. Covers are downloaded eagerly, audio lazily.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `playlist_id` - ID of the target pmoplaylist
|
||||
/// * `qobuz_playlist_id` - Qobuz playlist ID
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Number of tracks successfully added
|
||||
pub async fn add_qobuz_playlist_to_playlist(
|
||||
&self,
|
||||
playlist_id: &str,
|
||||
qobuz_playlist_id: &str,
|
||||
) -> Result<usize> {
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
// 1. Get tracks from Qobuz playlist (goes through rate limiter)
|
||||
let tracks = self
|
||||
.inner
|
||||
.client
|
||||
.get_playlist_tracks(qobuz_playlist_id)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
if tracks.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
info!(
|
||||
"Adding Qobuz playlist {} ({} tracks) to pmoplaylist {} with lazy audio",
|
||||
qobuz_playlist_id,
|
||||
tracks.len(),
|
||||
playlist_id
|
||||
);
|
||||
|
||||
// 2. Add each track lazily + collect lazy PKs
|
||||
let mut lazy_pks = Vec::with_capacity(tracks.len());
|
||||
|
||||
for (i, track) in tracks.iter().enumerate() {
|
||||
match self.add_track_lazy(track).await {
|
||||
Ok(track_id) => {
|
||||
debug!("Track {}/{}: {} (lazy)", i + 1, tracks.len(), track.title);
|
||||
|
||||
// Extract lazy PK from cache manager
|
||||
if let Some(metadata) = self.inner.cache_manager.get_metadata(&track_id).await {
|
||||
if let Some(audio_pk) = metadata.cached_audio_pk {
|
||||
lazy_pks.push(audio_pk);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to add track {} ({}): {}",
|
||||
i + 1,
|
||||
track.title,
|
||||
e
|
||||
);
|
||||
// Continue with other tracks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Batch insert into playlist (single DB transaction)
|
||||
let playlist_manager = pmoplaylist::PlaylistManager();
|
||||
let writer = playlist_manager
|
||||
.get_write_handle(playlist_id.to_string())
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||
|
||||
writer
|
||||
.push_lazy_batch(lazy_pks.clone())
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||
|
||||
// 4. Enable lazy mode with lookahead of 2 tracks
|
||||
playlist_manager.enable_lazy_mode(playlist_id, 2);
|
||||
|
||||
info!(
|
||||
"Qobuz playlist {} added: {}/{} tracks",
|
||||
qobuz_playlist_id,
|
||||
lazy_pks.len(),
|
||||
tracks.len()
|
||||
);
|
||||
|
||||
Ok(lazy_pks.len())
|
||||
}
|
||||
|
||||
/// Increment update counter (called on catalog changes)
|
||||
async fn increment_update_id(&self) {
|
||||
let mut counter = self.inner.update_counter.write().await;
|
||||
|
||||
75
pmoqobuz/tests/disk_cache.rs
Normal file
75
pmoqobuz/tests/disk_cache.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
#![cfg(feature = "disk-cache")]
|
||||
|
||||
use pmoqobuz::disk_cache::{CacheStore, SqliteCacheStore};
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_cache_returns_fresh_entries() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("cache.sqlite");
|
||||
let store = SqliteCacheStore::new(path)?;
|
||||
|
||||
let data = vec!["album".to_string()];
|
||||
store
|
||||
.put_json("user", "favorites_albums", "all", Duration::from_secs(3600), &data)
|
||||
.await?;
|
||||
|
||||
let entry = store
|
||||
.get_json::<Vec<String>>("user", "favorites_albums", "all")
|
||||
.await?
|
||||
.expect("cache entry");
|
||||
|
||||
assert!(entry.fresh);
|
||||
assert_eq!(entry.value, data);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_cache_marks_entries_as_stale_after_ttl() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("cache.sqlite");
|
||||
let store = SqliteCacheStore::new(path)?;
|
||||
|
||||
let data = vec!["track".to_string()];
|
||||
store
|
||||
.put_json("user", "favorites_tracks", "all", Duration::from_secs(1), &data)
|
||||
.await?;
|
||||
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
|
||||
let entry = store
|
||||
.get_json::<Vec<String>>("user", "favorites_tracks", "all")
|
||||
.await?
|
||||
.expect("cache entry");
|
||||
|
||||
assert!(!entry.fresh);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_cache_purge_expired_removes_entries() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("cache.sqlite");
|
||||
let store = SqliteCacheStore::new(path)?;
|
||||
|
||||
let data = vec!["playlist".to_string()];
|
||||
store
|
||||
.put_json("user", "user_playlists", "all", Duration::from_secs(1), &data)
|
||||
.await?;
|
||||
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
|
||||
let removed = store.purge_expired().await?;
|
||||
assert_eq!(removed, 1);
|
||||
|
||||
let entry = store
|
||||
.get_json::<Vec<String>>("user", "user_playlists", "all")
|
||||
.await?;
|
||||
|
||||
assert!(entry.is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
8488
pmoqobuz_027.txt
Normal file
8488
pmoqobuz_027.txt
Normal file
File diff suppressed because it is too large
Load Diff
8610
pmoqobuz_028.txt
Normal file
8610
pmoqobuz_028.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -218,6 +218,45 @@ impl SourceCacheManager {
|
||||
Ok(pk)
|
||||
}
|
||||
|
||||
/// Cache audio with lazy loading (deferred download)
|
||||
///
|
||||
/// Creates a lazy PK for the audio file without downloading it immediately.
|
||||
/// The actual download will occur when the HTTP endpoint is first requested.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `url` - URL source de la piste
|
||||
/// * `metadata` - Métadonnées audio optionnelles
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// La clé primaire lazy (pk) commençant par "L:"
|
||||
pub async fn cache_audio_lazy(
|
||||
&self,
|
||||
url: &str,
|
||||
metadata: Option<AudioMetadata>,
|
||||
) -> Result<String> {
|
||||
// Use pmocache add_from_url_deferred (already implemented)
|
||||
let lazy_pk = self
|
||||
.audio_cache
|
||||
.add_from_url_deferred(url, Some(&self.collection_id))
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::CacheError(e.to_string()))?;
|
||||
|
||||
// Store metadata in lazy_pk metadata table if provided
|
||||
if let Some(meta) = metadata {
|
||||
// Serialize metadata to JSON and store
|
||||
if let Ok(json) = serde_json::to_value(&meta) {
|
||||
let _ = self
|
||||
.audio_cache
|
||||
.db
|
||||
.set_a_metadata_by_key(&lazy_pk, "audio_metadata", json);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(lazy_pk)
|
||||
}
|
||||
|
||||
/// Cache un flux audio via un reader asynchrone
|
||||
pub async fn cache_audio_from_reader<R>(
|
||||
&self,
|
||||
|
||||
Reference in New Issue
Block a user