Big Debug of PMOQobuz step 2
This commit is contained in:
@@ -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)");
|
||||||
|
|||||||
@@ -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 {}: {}",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(×tamp, &secret);
|
let signature = signing::sign_userlib_get_albums(×tamp, &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()),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user