Big Debug of PMOQobuz step 2

This commit is contained in:
2025-12-13 13:36:37 +01:00
parent cd19d68703
commit 50693aaf7a
11 changed files with 248 additions and 157 deletions

View File

@@ -92,8 +92,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.await?; .await?;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
println!("✅ Album added: {} tracks in {:.2}s", count, elapsed.as_secs_f64()); println!(
println!(" Average: {:.0}ms per track\n", elapsed.as_millis() as f64 / count as f64); "✅ 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 // Step 8: Verify lazy PKs
println!("🔍 Verifying lazy PKs..."); println!("🔍 Verifying lazy PKs...");
@@ -120,10 +127,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let is_lazy = pmocache::is_lazy_pk(&first_track_pk); let is_lazy = pmocache::is_lazy_pk(&first_track_pk);
println!(" First track PK: {}", first_track_pk); println!(" First track PK: {}", first_track_pk);
println!(" Is lazy: {}", if is_lazy { "✅ YES (starts with 'L:')" } else { "❌ NO" }); println!(
" Is lazy: {}",
if is_lazy {
"✅ YES (starts with 'L:')"
} else {
"❌ NO"
}
);
// Count lazy vs downloaded // Count lazy vs downloaded
let lazy_count = tracks.iter().filter(|t| pmocache::is_lazy_pk(t.cache_pk())).count(); let lazy_count = tracks
.iter()
.filter(|t| pmocache::is_lazy_pk(t.cache_pk()))
.count();
let downloaded_count = tracks.len() - lazy_count; let downloaded_count = tracks.len() - lazy_count;
println!("\n📊 Track status:"); println!("\n📊 Track status:");
@@ -153,7 +170,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("│ 🎉 Lazy Loading Demo Complete! │"); println!("│ 🎉 Lazy Loading Demo Complete! │");
println!("╰─────────────────────────────────────────╯"); println!("╰─────────────────────────────────────────╯");
println!("\n📈 Benefits demonstrated:"); println!("\n📈 Benefits demonstrated:");
println!(" ✓ Fast album loading (~{}ms per track)", elapsed.as_millis() / count as u128); println!(
" ✓ Fast album loading (~{}ms per track)",
elapsed.as_millis() / count as u128
);
println!(" ✓ Minimal initial download (covers only)"); println!(" ✓ Minimal initial download (covers only)");
println!(" ✓ Audio downloaded on-demand"); println!(" ✓ Audio downloaded on-demand");
println!(" ✓ Rate limiting active (respectful to Qobuz)"); println!(" ✓ Rate limiting active (respectful to Qobuz)");

View File

@@ -55,8 +55,9 @@ impl Spoofer {
.await?; .await?;
// Extraire l'URL du bundle // Extraire l'URL du bundle
let bundle_url_regex = let bundle_url_regex = Regex::new(
Regex::new(r#"<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>"#)?; r#"<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>"#,
)?;
let bundle_url = bundle_url_regex let bundle_url = bundle_url_regex
.captures(&login_page) .captures(&login_page)
.and_then(|cap| cap.get(1)) .and_then(|cap| cap.get(1))
@@ -182,19 +183,14 @@ impl Spoofer {
// Décoder en base64 // Décoder en base64
match STANDARD.decode(trimmed) { match STANDARD.decode(trimmed) {
Ok(decoded_bytes) => { Ok(decoded_bytes) => match String::from_utf8(decoded_bytes) {
match String::from_utf8(decoded_bytes) { Ok(decoded_str) => {
Ok(decoded_str) => { decoded_secrets.insert(timezone, decoded_str);
decoded_secrets.insert(timezone, decoded_str);
}
Err(e) => {
eprintln!(
"Erreur UTF-8 pour timezone {}: {}",
timezone, e
);
}
} }
} Err(e) => {
eprintln!("Erreur UTF-8 pour timezone {}: {}", timezone, e);
}
},
Err(e) => { Err(e) => {
eprintln!( eprintln!(
"Erreur de décodage base64 pour timezone {}: {}", "Erreur de décodage base64 pour timezone {}: {}",

View File

@@ -234,6 +234,10 @@ impl QobuzApi {
let format_id = self.format_id.id().to_string(); let format_id = self.format_id.id().to_string();
let intent = "stream"; let intent = "stream";
let timestamp = signing::get_timestamp(); let timestamp = signing::get_timestamp();
let app_id = self.app_id();
let user_auth_token = self
.auth_token()
.ok_or_else(|| QobuzError::Unauthorized("Missing auth token".to_string()))?;
// Signer la requête (comme Python: track_getFileUrl) // Signer la requête (comme Python: track_getFileUrl)
let signature = let signature =
@@ -251,6 +255,8 @@ impl QobuzApi {
("intent", intent), ("intent", intent),
("request_ts", timestamp.as_str()), ("request_ts", timestamp.as_str()),
("request_sig", signature.as_str()), ("request_sig", signature.as_str()),
("app_id", app_id.as_str()),
("user_auth_token", user_auth_token.as_str()),
]; ];
// Utiliser GET (comme Python après sept 2024 selon le commentaire) // Utiliser GET (comme Python après sept 2024 selon le commentaire)

View File

@@ -38,13 +38,13 @@ pub struct QobuzApi {
/// Client HTTP /// Client HTTP
client: Client, client: Client,
/// App ID pour l'authentification /// App ID pour l'authentification
app_id: String, app_id: RwLock<String>,
/// Secret s4 pour signer les requêtes sensibles (track/getFileUrl, userLibrary/*) /// Secret s4 pour signer les requêtes sensibles (track/getFileUrl, userLibrary/*)
/// ///
/// Ce secret est obtenu soit : /// Ce secret est obtenu soit :
/// - En décodant un `configvalue` (base64) et XOR avec l'app_id /// - En décodant un `configvalue` (base64) et XOR avec l'app_id
/// - Depuis le Spoofer (secrets dynamiques) /// - Depuis le Spoofer (secrets dynamiques)
secret: Option<Vec<u8>>, secret: RwLock<Option<Vec<u8>>>,
/// Token d'authentification utilisateur /// Token d'authentification utilisateur
user_auth_token: RwLock<Option<String>>, user_auth_token: RwLock<Option<String>>,
/// ID utilisateur /// ID utilisateur
@@ -65,8 +65,8 @@ impl QobuzApi {
Ok(Self { Ok(Self {
client, client,
app_id: app_id.into(), app_id: RwLock::new(app_id.into()),
secret: None, secret: RwLock::new(None),
user_auth_token: RwLock::new(None), user_auth_token: RwLock::new(None),
user_id: RwLock::new(None), user_id: RwLock::new(None),
format_id: AudioFormat::default(), format_id: AudioFormat::default(),
@@ -86,7 +86,7 @@ impl QobuzApi {
/// Le configvalue est décodé depuis base64, puis XORé avec l'app_id /// Le configvalue est décodé depuis base64, puis XORé avec l'app_id
/// pour obtenir le secret s4. /// pour obtenir le secret s4.
pub fn with_secret(app_id: impl Into<String>, configvalue: &str) -> Result<Self> { pub fn with_secret(app_id: impl Into<String>, configvalue: &str) -> Result<Self> {
let mut api = Self::new(app_id)?; let api = Self::new(app_id)?;
api.set_secret_from_configvalue(configvalue)?; api.set_secret_from_configvalue(configvalue)?;
Ok(api) Ok(api)
} }
@@ -96,8 +96,8 @@ impl QobuzApi {
/// # Arguments /// # Arguments
/// ///
/// * `secret` - Secret s4 en bytes (déjà décodé et dérivé) /// * `secret` - Secret s4 en bytes (déjà décodé et dérivé)
pub fn set_secret(&mut self, secret: Vec<u8>) { pub fn set_secret(&self, secret: Vec<u8>) {
self.secret = Some(secret); *self.secret.write().unwrap() = Some(secret);
} }
/// Dérive et définit le secret s4 depuis un configvalue /// Dérive et définit le secret s4 depuis un configvalue
@@ -106,7 +106,7 @@ impl QobuzApi {
/// 1. Décode le configvalue depuis base64 /// 1. Décode le configvalue depuis base64
/// 2. XOR avec l'app_id /// 2. XOR avec l'app_id
/// 3. Stocke le résultat comme secret s4 /// 3. Stocke le résultat comme secret s4
fn set_secret_from_configvalue(&mut self, configvalue: &str) -> Result<()> { fn set_secret_from_configvalue(&self, configvalue: &str) -> Result<()> {
use base64::{engine::general_purpose::STANDARD, Engine}; use base64::{engine::general_purpose::STANDARD, Engine};
// Décoder le configvalue depuis base64 // Décoder le configvalue depuis base64
@@ -115,7 +115,8 @@ impl QobuzApi {
.map_err(|e| QobuzError::Configuration(format!("Invalid configvalue: {}", e)))?; .map_err(|e| QobuzError::Configuration(format!("Invalid configvalue: {}", e)))?;
// XOR avec l'app_id // XOR avec l'app_id
let app_id_bytes = self.app_id.as_bytes(); let app_id = self.app_id.read().unwrap();
let app_id_bytes = app_id.as_bytes();
let mut s4 = Vec::with_capacity(s3s.len()); let mut s4 = Vec::with_capacity(s3s.len());
for (i, &byte) in s3s.iter().enumerate() { for (i, &byte) in s3s.iter().enumerate() {
@@ -123,13 +124,13 @@ impl QobuzApi {
s4.push(byte ^ app_byte); s4.push(byte ^ app_byte);
} }
self.secret = Some(s4); *self.secret.write().unwrap() = Some(s4);
Ok(()) Ok(())
} }
/// Retourne le secret s4 si disponible /// Retourne le secret s4 si disponible
pub fn secret(&self) -> Option<&[u8]> { pub fn secret(&self) -> Option<Vec<u8>> {
self.secret.as_deref() self.secret.read().unwrap().clone()
} }
/// Définit le token d'authentification /// Définit le token d'authentification
@@ -155,8 +156,18 @@ impl QobuzApi {
} }
/// Retourne l'App ID /// Retourne l'App ID
pub fn app_id(&self) -> &str { pub fn app_id(&self) -> String {
&self.app_id self.app_id.read().unwrap().clone()
}
/// Met à jour dynamiquement l'app_id et le secret associés
pub fn update_credentials(&self, app_id: impl Into<String>, configvalue: &str) -> Result<()> {
{
let mut current = self.app_id.write().unwrap();
*current = app_id.into();
}
self.set_secret_from_configvalue(configvalue)
} }
/// Retourne le token d'authentification si disponible /// Retourne le token d'authentification si disponible
@@ -205,7 +216,8 @@ impl QobuzApi {
}; };
// Ajouter les headers // Ajouter les headers
request = request.header("X-App-Id", &self.app_id); let app_id = self.app_id.read().unwrap().clone();
request = request.header("X-App-Id", app_id);
if let Some(token) = self.auth_token() { if let Some(token) = self.auth_token() {
request = request.header("X-User-Auth-Token", token); request = request.header("X-User-Auth-Token", token);
@@ -270,7 +282,7 @@ mod tests {
#[test] #[test]
fn test_api_creation() { fn test_api_creation() {
let api = QobuzApi::new("test_app_id").unwrap(); let api = QobuzApi::new("test_app_id").unwrap();
assert_eq!(api.app_id(), "test_app_id"); assert_eq!(api.app_id(), "test_app_id".to_string());
assert!(api.auth_token().is_none()); assert!(api.auth_token().is_none());
} }

View File

@@ -234,10 +234,11 @@ impl QobuzApi {
// Signer la requête (comme Python: userlib_getAlbums) // Signer la requête (comme Python: userlib_getAlbums)
let signature = signing::sign_userlib_get_albums(&timestamp, &secret); let signature = signing::sign_userlib_get_albums(&timestamp, &secret);
let app_id = self.app_id();
debug!( debug!(
"Signing userLibrary/getAlbumsList: app_id={}, ts={}", "Signing userLibrary/getAlbumsList: app_id={}, ts={}",
self.app_id(), app_id, timestamp
timestamp
); );
// Construire les paramètres signés // Construire les paramètres signés
@@ -246,7 +247,7 @@ impl QobuzApi {
.ok_or_else(|| QobuzError::Unauthorized("No auth token".to_string()))?; .ok_or_else(|| QobuzError::Unauthorized("No auth token".to_string()))?;
let params = [ let params = [
("app_id", self.app_id()), ("app_id", app_id.as_str()),
("user_auth_token", user_auth_token.as_str()), ("user_auth_token", user_auth_token.as_str()),
("request_ts", timestamp.as_str()), ("request_ts", timestamp.as_str()),
("request_sig", signature.as_str()), ("request_sig", signature.as_str()),

View File

@@ -11,7 +11,7 @@ use crate::models::*;
use pmoconfig::{self, Config}; use pmoconfig::{self, Config};
use std::future::Future; use std::future::Future;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tracing::{debug, info}; use tracing::{debug, info, warn};
/// Client Qobuz haut-niveau avec cache /// Client Qobuz haut-niveau avec cache
pub struct QobuzClient { pub struct QobuzClient {
@@ -218,73 +218,99 @@ impl QobuzClient {
/// - Quand aucun appid/secret n'est configuré /// - Quand aucun appid/secret n'est configuré
/// - Quand les credentials configurés sont invalides/expirés /// - Quand les credentials configurés sont invalides/expirés
async fn try_spoofer_fallback(config: &Config) -> Result<QobuzApi> { async fn try_spoofer_fallback(config: &Config) -> Result<QobuzApi> {
if let Some((app_id, secret)) = Self::fetch_spoofer_credentials(config).await? {
return QobuzApi::with_secret(app_id, &secret);
}
info!(
"✗ No valid secret found from Spoofer, falling back to DEFAULT_APP_ID without secret"
);
QobuzApi::new(DEFAULT_APP_ID)
}
async fn fetch_spoofer_credentials(config: &Config) -> Result<Option<(String, String)>> {
match crate::api::Spoofer::new().await { match crate::api::Spoofer::new().await {
Ok(spoofer) => { Ok(spoofer) => match spoofer.get_app_id() {
match spoofer.get_app_id() { Ok(app_id) => match spoofer.get_secrets() {
Ok(app_id) => { Ok(secrets) => {
info!("Spoofer found App ID: {}", app_id); info!("Spoofer found {} secret(s), testing them...", secrets.len());
match spoofer.get_secrets() { for (timezone, secret) in secrets.iter() {
Ok(secrets) => { debug!("Testing secret for timezone: {}", timezone);
info!("Spoofer found {} secret(s), testing them...", secrets.len());
// Tester chaque secret pour trouver celui qui fonctionne match QobuzApi::with_secret(&app_id, secret) {
for (timezone, secret) in secrets.iter() { Ok(_) => {
debug!("Testing secret for timezone: {}", timezone); info!(
"✓ Successfully created API with secret from timezone: {}",
timezone
);
match QobuzApi::with_secret(&app_id, secret) { if let Err(e) = config.set_qobuz_appid(&app_id) {
Ok(test_api) => { debug!("Could not save appid to config: {}", e);
info!("✓ Successfully created API with secret from timezone: {}", timezone); }
if let Err(e) = config.set_qobuz_secret(secret) {
// Sauvegarder les credentials valides dans la config debug!("Could not save secret to config: {}", e);
if let Err(e) = config.set_qobuz_appid(&app_id) {
debug!("Could not save appid to config: {}", e);
}
if let Err(e) = config.set_qobuz_secret(secret) {
debug!("Could not save secret to config: {}", e);
}
return Ok(test_api);
}
Err(e) => {
debug!(
"Failed to create API with secret from {}: {}",
timezone, e
);
continue;
}
} }
}
// Si aucun secret n'a fonctionné, utiliser le fallback return Ok(Some((app_id.clone(), secret.clone())));
info!("✗ No valid secret found from Spoofer, falling back to DEFAULT_APP_ID without secret"); }
QobuzApi::new(DEFAULT_APP_ID) Err(e) => {
} debug!(
Err(e) => { "Failed to create API with secret from {}: {}",
info!("Spoofer failed to extract secrets: {}, falling back to DEFAULT_APP_ID", e); timezone, e
QobuzApi::new(DEFAULT_APP_ID) );
continue;
}
} }
} }
info!("✗ No valid secret from Spoofer secrets list");
Ok(None)
} }
Err(e) => { Err(e) => {
info!( info!(
"Spoofer failed to extract app_id: {}, falling back to DEFAULT_APP_ID", "Spoofer failed to extract secrets: {}, falling back to DEFAULT_APP_ID",
e e
); );
QobuzApi::new(DEFAULT_APP_ID) Ok(None)
} }
},
Err(e) => {
info!(
"Spoofer failed to extract app_id: {}, falling back to DEFAULT_APP_ID",
e
);
Ok(None)
} }
} },
Err(e) => { Err(e) => {
info!( info!(
"Spoofer failed: {}, falling back to DEFAULT_APP_ID without secret", "Spoofer failed: {}, falling back to DEFAULT_APP_ID without secret",
e e
); );
QobuzApi::new(DEFAULT_APP_ID) Ok(None)
} }
} }
} }
async fn refresh_credentials_via_spoofer(&self) -> Result<()> {
let config_arc = match &self.config {
Some(cfg) => cfg.clone(),
None => pmoconfig::get_config(),
};
match Self::fetch_spoofer_credentials(config_arc.as_ref()).await? {
Some((app_id, secret)) => {
self.api.update_credentials(app_id, &secret)?;
info!("✓ Updated Qobuz API credentials using Spoofer");
Ok(())
}
None => Err(QobuzError::Configuration(
"Unable to refresh Qobuz credentials via Spoofer".to_string(),
)),
}
}
/// Définit le format audio par défaut /// Définit le format audio par défaut
pub fn set_format(&mut self, format: AudioFormat) { pub fn set_format(&mut self, format: AudioFormat) {
self.api.set_format(format); self.api.set_format(format);
@@ -375,10 +401,7 @@ impl QobuzClient {
match global.get_qobuz_cache_dir() { match global.get_qobuz_cache_dir() {
Ok(dir) => dir, Ok(dir) => dir,
Err(err) => { Err(err) => {
debug!( debug!("Failed to read qobuz cache dir from global config: {}", err);
"Failed to read qobuz cache dir from global config: {}",
err
);
return None; return None;
} }
} }
@@ -416,6 +439,25 @@ impl QobuzClient {
{ {
match op().await { match op().await {
Ok(result) => Ok(result), Ok(result) => Ok(result),
Err(err) if err.is_signature_error() => {
warn!(
"Request signature error during {}. Qobuz app secret may be outdated.",
operation
);
if let Err(refresh_err) = self.refresh_credentials_via_spoofer().await {
warn!(
"Failed to refresh Qobuz credentials automatically: {}",
refresh_err
);
Err(err)
} else {
info!(
"Successfully refreshed Qobuz credentials. Retrying {}...",
operation
);
op().await
}
}
Err(err) if err.is_auth_error() => { Err(err) if err.is_auth_error() => {
info!( info!(
"Authentication error during {}. Attempting automatic repair...", "Authentication error during {}. Attempting automatic repair...",
@@ -577,13 +619,17 @@ impl QobuzClient {
/// Récupère les albums d'un artiste /// Récupère les albums d'un artiste
pub async fn get_artist_albums(&self, artist_id: &str) -> Result<Vec<Album>> { pub async fn get_artist_albums(&self, artist_id: &str) -> Result<Vec<Album>> {
self.call_with_auth_repair("get_artist_albums", || self.api.get_artist_albums(artist_id)) self.call_with_auth_repair("get_artist_albums", || {
self.api.get_artist_albums(artist_id)
})
.await .await
} }
/// Récupère les artistes similaires /// Récupère les artistes similaires
pub async fn get_similar_artists(&self, artist_id: &str) -> Result<Vec<Artist>> { pub async fn get_similar_artists(&self, artist_id: &str) -> Result<Vec<Artist>> {
self.call_with_auth_repair("get_similar_artists", || self.api.get_similar_artists(artist_id)) self.call_with_auth_repair("get_similar_artists", || {
self.api.get_similar_artists(artist_id)
})
.await .await
} }
@@ -740,7 +786,7 @@ impl QobuzClient {
/// Récupère les artistes favoris de l'utilisateur /// Récupère les artistes favoris de l'utilisateur
pub async fn get_favorite_artists(&self) -> Result<Vec<Artist>> { pub async fn get_favorite_artists(&self) -> Result<Vec<Artist>> {
self.call_with_auth_repair("get_favorite_artists", || self.api.get_favorite_artists()) self.call_with_auth_repair("get_favorite_artists", || self.api.get_favorite_artists())
.await .await
} }
/// Récupère les tracks favorites de l'utilisateur /// Récupère les tracks favorites de l'utilisateur

View File

@@ -263,8 +263,8 @@ impl QobuzConfigExt for Config {
match self.get_value(&["accounts", "qobuz", "appid"]) { match self.get_value(&["accounts", "qobuz", "appid"]) {
Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)),
Ok(Value::String(_)) => Ok(None), // Empty string Ok(Value::String(_)) => Ok(None), // Empty string
Ok(_) => Ok(None), // Wrong type Ok(_) => Ok(None), // Wrong type
Err(_) => Ok(None), // Not configured Err(_) => Ok(None), // Not configured
} }
} }
@@ -279,8 +279,8 @@ impl QobuzConfigExt for Config {
match self.get_value(&["accounts", "qobuz", "secret"]) { match self.get_value(&["accounts", "qobuz", "secret"]) {
Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)),
Ok(Value::String(_)) => Ok(None), // Empty string Ok(Value::String(_)) => Ok(None), // Empty string
Ok(_) => Ok(None), // Wrong type Ok(_) => Ok(None), // Wrong type
Err(_) => Ok(None), // Not configured Err(_) => Ok(None), // Not configured
} }
} }
@@ -295,8 +295,8 @@ impl QobuzConfigExt for Config {
match self.get_value(&["accounts", "qobuz", "auth_token"]) { match self.get_value(&["accounts", "qobuz", "auth_token"]) {
Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)),
Ok(Value::String(_)) => Ok(None), // Empty string Ok(Value::String(_)) => Ok(None), // Empty string
Ok(_) => Ok(None), // Wrong type Ok(_) => Ok(None), // Wrong type
Err(_) => Ok(None), // Not configured Err(_) => Ok(None), // Not configured
} }
} }
@@ -304,8 +304,8 @@ impl QobuzConfigExt for Config {
match self.get_value(&["accounts", "qobuz", "user_id"]) { match self.get_value(&["accounts", "qobuz", "user_id"]) {
Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)),
Ok(Value::String(_)) => Ok(None), // Empty string Ok(Value::String(_)) => Ok(None), // Empty string
Ok(_) => Ok(None), // Wrong type Ok(_) => Ok(None), // Wrong type
Err(_) => Ok(None), // Not configured Err(_) => Ok(None), // Not configured
} }
} }
@@ -322,8 +322,8 @@ impl QobuzConfigExt for Config {
match self.get_value(&["accounts", "qobuz", "subscription_label"]) { match self.get_value(&["accounts", "qobuz", "subscription_label"]) {
Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)),
Ok(Value::String(_)) => Ok(None), // Empty string Ok(Value::String(_)) => Ok(None), // Empty string
Ok(_) => Ok(None), // Wrong type Ok(_) => Ok(None), // Wrong type
Err(_) => Ok(None), // Not configured Err(_) => Ok(None), // Not configured
} }
} }
@@ -359,8 +359,14 @@ impl QobuzConfigExt for Config {
fn clear_qobuz_auth_info(&self) -> Result<()> { fn clear_qobuz_auth_info(&self) -> Result<()> {
// On ne propage pas les erreurs car les valeurs peuvent ne pas exister // On ne propage pas les erreurs car les valeurs peuvent ne pas exister
let _ = self.set_value(&["accounts", "qobuz", "auth_token"], Value::String(String::new())); let _ = self.set_value(
let _ = self.set_value(&["accounts", "qobuz", "user_id"], Value::String(String::new())); &["accounts", "qobuz", "auth_token"],
Value::String(String::new()),
);
let _ = self.set_value(
&["accounts", "qobuz", "user_id"],
Value::String(String::new()),
);
let _ = self.set_value( let _ = self.set_value(
&["accounts", "qobuz", "token_expires_at"], &["accounts", "qobuz", "token_expires_at"],
Value::Number(serde_yaml::Number::from(0)), Value::Number(serde_yaml::Number::from(0)),

View File

@@ -35,12 +35,7 @@ pub trait CacheStore: Send + Sync {
value: &T, value: &T,
) -> anyhow::Result<()>; ) -> anyhow::Result<()>;
async fn invalidate( async fn invalidate(&self, user_id: &str, namespace: &str, key: &str) -> anyhow::Result<()>;
&self,
user_id: &str,
namespace: &str,
key: &str,
) -> anyhow::Result<()>;
async fn purge_expired(&self) -> anyhow::Result<usize>; async fn purge_expired(&self) -> anyhow::Result<usize>;
} }
@@ -105,28 +100,21 @@ impl CacheStore for SqliteCacheStore {
WHERE user_id = ?1 AND namespace = ?2 AND key = ?3", WHERE user_id = ?1 AND namespace = ?2 AND key = ?3",
)?; )?;
let result = stmt.query_row( let result = stmt.query_row(params![user_id, namespace, key], |row| {
params![user_id, namespace, key], let fetched_at: i64 = row.get(0)?;
|row| { let ttl_seconds: i64 = row.get(1)?;
let fetched_at: i64 = row.get(0)?; let data: Vec<u8> = row.get(2)?;
let ttl_seconds: i64 = row.get(1)?; let now = Self::now_seconds();
let data: Vec<u8> = row.get(2)?; let fresh = now <= fetched_at + ttl_seconds;
let now = Self::now_seconds(); let age_secs = if now >= fetched_at {
let fresh = now <= fetched_at + ttl_seconds; (now - fetched_at) as u64
let age_secs = if now >= fetched_at { } else {
(now - fetched_at) as u64 0
} else { };
0 let age = Duration::from_secs(age_secs);
}; let value = serde_json::from_slice(&data)?;
let age = Duration::from_secs(age_secs); Ok(CacheEntry { value, age, fresh })
let value = serde_json::from_slice(&data)?; });
Ok(CacheEntry {
value,
age,
fresh,
})
},
);
match result { match result {
Ok(entry) => Ok(Some(entry)), Ok(entry) => Ok(Some(entry)),
@@ -170,12 +158,7 @@ impl CacheStore for SqliteCacheStore {
.map_err(|err| anyhow!(err))? .map_err(|err| anyhow!(err))?
} }
async fn invalidate( async fn invalidate(&self, user_id: &str, namespace: &str, key: &str) -> anyhow::Result<()> {
&self,
user_id: &str,
namespace: &str,
key: &str,
) -> anyhow::Result<()> {
let user_id = user_id.to_owned(); let user_id = user_id.to_owned();
let namespace = namespace.to_owned(); let namespace = namespace.to_owned();
let key = key.to_owned(); let key = key.to_owned();

View File

@@ -80,8 +80,25 @@ impl QobuzError {
pub fn is_auth_error(&self) -> bool { pub fn is_auth_error(&self) -> bool {
match self { match self {
QobuzError::Unauthorized(_) => true, QobuzError::Unauthorized(_) => true,
QobuzError::ApiError {
code: 401 | 403, ..
} => true,
QobuzError::ApiError { code: 400, message } QobuzError::ApiError { code: 400, message }
if message.contains("app_id") || message.contains("Invalid") => true, if message.contains("app_id") || message.contains("App ID") =>
{
true
}
_ => false,
}
}
/// Vérifie si l'erreur indique un problème de signature des requêtes
pub fn is_signature_error(&self) -> bool {
match self {
QobuzError::ApiError { code: 400, message } => {
let lowered = message.to_lowercase();
lowered.contains("request_signature") || lowered.contains("request_sig")
}
_ => false, _ => false,
} }
} }

View File

@@ -318,12 +318,8 @@ impl QobuzSource {
/// # Returns /// # Returns
/// ///
/// Number of tracks successfully added /// Number of tracks successfully added
pub async fn add_album_to_playlist( pub async fn add_album_to_playlist(&self, playlist_id: &str, album_id: &str) -> Result<usize> {
&self, use tracing::{debug, info, warn};
playlist_id: &str,
album_id: &str,
) -> Result<usize> {
use tracing::{info, warn, debug};
// 1. Get tracks from Qobuz (goes through rate limiter) // 1. Get tracks from Qobuz (goes through rate limiter)
let tracks = self let tracks = self
@@ -360,12 +356,7 @@ impl QobuzSource {
} }
} }
Err(e) => { Err(e) => {
warn!( warn!("Failed to add track {} ({}): {}", i + 1, track.title, e);
"Failed to add track {} ({}): {}",
i + 1,
track.title,
e
);
// Continue with other tracks // Continue with other tracks
} }
} }
@@ -451,12 +442,7 @@ impl QobuzSource {
} }
} }
Err(e) => { Err(e) => {
warn!( warn!("Failed to add track {} ({}): {}", i + 1, track.title, e);
"Failed to add track {} ({}): {}",
i + 1,
track.title,
e
);
// Continue with other tracks // Continue with other tracks
} }
} }

View File

@@ -12,7 +12,13 @@ async fn sqlite_cache_returns_fresh_entries() -> anyhow::Result<()> {
let data = vec!["album".to_string()]; let data = vec!["album".to_string()];
store store
.put_json("user", "favorites_albums", "all", Duration::from_secs(3600), &data) .put_json(
"user",
"favorites_albums",
"all",
Duration::from_secs(3600),
&data,
)
.await?; .await?;
let entry = store let entry = store
@@ -34,7 +40,13 @@ async fn sqlite_cache_marks_entries_as_stale_after_ttl() -> anyhow::Result<()> {
let data = vec!["track".to_string()]; let data = vec!["track".to_string()];
store store
.put_json("user", "favorites_tracks", "all", Duration::from_secs(1), &data) .put_json(
"user",
"favorites_tracks",
"all",
Duration::from_secs(1),
&data,
)
.await?; .await?;
sleep(Duration::from_secs(2)).await; sleep(Duration::from_secs(2)).await;
@@ -57,7 +69,13 @@ async fn sqlite_cache_purge_expired_removes_entries() -> anyhow::Result<()> {
let data = vec!["playlist".to_string()]; let data = vec!["playlist".to_string()];
store store
.put_json("user", "user_playlists", "all", Duration::from_secs(1), &data) .put_json(
"user",
"user_playlists",
"all",
Duration::from_secs(1),
&data,
)
.await?; .await?;
sleep(Duration::from_secs(2)).await; sleep(Duration::from_secs(2)).await;