PMOQobuz client is now working

This commit is contained in:
2025-12-15 10:23:43 +01:00
parent d2f6abf4bb
commit 4bc80dfd08
6 changed files with 113 additions and 31 deletions

View File

@@ -167,7 +167,7 @@ struct SearchResponse {
struct FileUrlResponse { struct FileUrlResponse {
url: String, url: String,
mime_type: String, mime_type: String,
sampling_rate: u32, sampling_rate: f64,
bit_depth: u32, bit_depth: u32,
format_id: u8, format_id: u8,
} }
@@ -260,8 +260,8 @@ impl QobuzApi {
("track_id", track_id), ("track_id", track_id),
]; ];
// Utiliser POST (comme Python) // Utiliser GET (comme qobuz-player-client qui fonctionne)
let response: FileUrlResponse = self.post("/track/getFileUrl", &params).await?; let response: FileUrlResponse = self.get("/track/getFileUrl", &params).await?;
Ok(StreamInfo { Ok(StreamInfo {
url: response.url, url: response.url,

View File

@@ -251,6 +251,10 @@ impl QobuzApi {
request = request.header("X-User-Auth-Token", token); request = request.header("X-User-Auth-Token", token);
} }
// Headers additionnels pour compatibilité avec qobuz-player-client
request = request.header("Accept-Language", "en,en-US;q=0.8,ko;q=0.6,zh;q=0.4,zh-CN;q=0.2");
request = request.header("Access-Control-Request-Headers", "x-user-auth-token,x-app-id");
// Ajouter les paramètres // Ajouter les paramètres
if method == "GET" { if method == "GET" {
request = request.query(params); request = request.query(params);
@@ -272,7 +276,7 @@ impl QobuzApi {
if !status.is_success() { if !status.is_success() {
let error_text = response.text().await.unwrap_or_default(); let error_text = response.text().await.unwrap_or_default();
warn!("API error ({}) on {}: {}", status_code, endpoint, error_text); debug!("API error ({}) on {}: {}", status_code, endpoint, error_text);
return Err(QobuzError::from_status_code(status_code, error_text)); return Err(QobuzError::from_status_code(status_code, error_text));
} }
@@ -286,7 +290,7 @@ impl QobuzApi {
.get("message") .get("message")
.and_then(|m| m.as_str()) .and_then(|m| m.as_str())
.unwrap_or("Unknown error"); .unwrap_or("Unknown error");
warn!("Qobuz API error on {}: {}", endpoint, message); debug!("Qobuz API error on {}: {}", endpoint, message);
return Err(QobuzError::ApiError { return Err(QobuzError::ApiError {
code: status_code, code: status_code,
message: message.to_string(), message: message.to_string(),
@@ -297,7 +301,7 @@ impl QobuzApi {
// Parser la réponse // Parser la réponse
serde_json::from_str(&text).map_err(|e| { serde_json::from_str(&text).map_err(|e| {
warn!("Failed to parse response: {}", e); debug!("Failed to parse response: {}", e);
QobuzError::JsonParse(e) QobuzError::JsonParse(e)
}) })
} }

View File

@@ -10,7 +10,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
/// ///
/// # Returns /// # Returns
/// ///
/// Timestamp Unix sous forme de string avec décimales /// Timestamp Unix sous forme de string (integer, sans décimales)
/// ///
/// # Exemple /// # Exemple
/// ///
@@ -23,7 +23,7 @@ pub fn get_timestamp() -> String {
SystemTime::now() SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap() .unwrap()
.as_secs_f64() .as_secs()
.to_string() .to_string()
} }
@@ -107,16 +107,16 @@ mod tests {
#[test] #[test]
fn test_get_timestamp() { fn test_get_timestamp() {
let ts = get_timestamp(); let ts = get_timestamp();
// Vérifier que c'est un nombre valide // Vérifier que c'est un nombre entier valide
assert!(ts.parse::<f64>().is_ok()); assert!(ts.parse::<u64>().is_ok());
// Vérifier que c'est proche du temps actuel (>= 2024) // Vérifier que c'est proche du temps actuel (>= 2024)
assert!(ts.parse::<f64>().unwrap() > 1704067200.0); // 1er janvier 2024 assert!(ts.parse::<u64>().unwrap() > 1704067200); // 1er janvier 2024
} }
#[test] #[test]
fn test_sign_track_get_file_url() { fn test_sign_track_get_file_url() {
let signature = let signature =
sign_track_get_file_url("27", "stream", "12345", "1234567890.123", b"test_secret"); sign_track_get_file_url("27", "stream", "12345", "1234567890", b"test_secret");
// Vérifier que c'est un hash MD5 valide (32 caractères hex) // Vérifier que c'est un hash MD5 valide (32 caractères hex)
assert_eq!(signature.len(), 32); assert_eq!(signature.len(), 32);
@@ -125,7 +125,7 @@ mod tests {
#[test] #[test]
fn test_sign_userlib_get_albums() { fn test_sign_userlib_get_albums() {
let signature = sign_userlib_get_albums("1234567890.123", b"test_secret"); let signature = sign_userlib_get_albums("1234567890", b"test_secret");
// Vérifier que c'est un hash MD5 valide (32 caractères hex) // Vérifier que c'est un hash MD5 valide (32 caractères hex)
assert_eq!(signature.len(), 32); assert_eq!(signature.len(), 32);

View File

@@ -127,11 +127,33 @@ impl QobuzClient {
let config_appid = config.get_qobuz_appid()?; let config_appid = config.get_qobuz_appid()?;
let config_secret = config.get_qobuz_secret()?; let config_secret = config.get_qobuz_secret()?;
let config_spoofer_secret = config.get_qobuz_spoofer_secret()?;
let mut used_config_credentials = false; let mut used_config_credentials = false;
let mut api = match (config_appid, config_secret) { let mut api = match (config_appid.clone(), config_spoofer_secret, config_secret) {
(Some(app_id), Some(secret)) => { // Priority 1: Try memorized Spoofer secret (raw, no XOR)
(Some(app_id), Some(spoofer_secret), _) => {
info!(
"Trying memorized Spoofer secret with App ID: {}",
app_id
);
match QobuzApi::with_raw_secret(&app_id, &spoofer_secret) {
Ok(api) => {
used_config_credentials = true;
api
}
Err(e) => {
info!(
"✗ Memorized Spoofer secret failed: {}. Re-fetching from Spoofer...",
e
);
Self::try_spoofer_fallback(config).await?
}
}
}
// Priority 2: Try XOR secret (legacy configvalue)
(Some(app_id), None, Some(secret)) => {
info!( info!(
"Creating Qobuz API with configured App ID: {} and XOR secret", "Creating Qobuz API with configured App ID: {} and XOR secret",
app_id app_id
@@ -150,9 +172,10 @@ impl QobuzClient {
} }
} }
} }
// Priority 3: Fallback to Spoofer
_ => { _ => {
info!( info!(
"AppID or secret not configured (or app_id without secret), using Spoofer..." "AppID or secret not configured, using Spoofer..."
); );
Self::try_spoofer_fallback(config).await? Self::try_spoofer_fallback(config).await?
} }
@@ -240,21 +263,40 @@ impl QobuzClient {
info!("Testing {} timezone secret(s)...", secrets.len()); info!("Testing {} timezone secret(s)...", secrets.len());
let (username, password) = config.get_qobuz_credentials()?; let (username, password) = config.get_qobuz_credentials()?;
for (timezone, secret) in secrets.iter() { // Optimization: Login once with first secret to get auth token
debug!("Testing timezone secret: {}", timezone); // Then test all secrets using the same token
if let Some((first_timezone, first_secret)) = secrets.first() {
if let Ok(temp_api) = QobuzApi::with_raw_secret(&app_id, first_secret) {
if let Ok(_auth_info) = temp_api.login(&username, &password).await {
// Now test each secret with the authenticated token
for (timezone, secret) in secrets.iter() {
debug!("Testing timezone secret: {}", timezone);
if let Ok(test_api) = QobuzApi::with_raw_secret(&app_id, secret) { if let Ok(test_api) = QobuzApi::with_raw_secret(&app_id, secret) {
if let Ok(auth_info) = test_api.login(&username, &password).await { // Set the auth token from our initial login
// Test the secret using userlib_get_albums (like Python's setSec()) test_api.set_auth_token(
// This matches Python's behavior exactly temp_api.auth_token().unwrap(),
if test_api.userlib_get_albums().await.is_ok() { temp_api.user_id().unwrap(),
info!("✓ Secret from timezone '{}' works!", timezone); );
if let Err(e) = config.set_qobuz_appid(&app_id) {
debug!("Could not save appid: {}", e); // Test the secret using track/getFileUrl (like qobuz-player-client)
// Use the same hardcoded track_id (64868955) as qobuz-player-client
if test_api.get_file_url("64868955").await.is_ok() {
info!("✓ Secret from timezone '{}' works!", timezone);
// Save both appid and the working secret
if let Err(e) = config.set_qobuz_appid(&app_id) {
debug!("Could not save appid: {}", e);
}
if let Err(e) = config.set_qobuz_spoofer_secret(secret) {
debug!("Could not save spoofer secret: {}", e);
}
return Ok(Some((app_id.clone(), secret.clone())));
} else {
debug!("✗ Secret from timezone '{}' failed track/getFileUrl test", timezone);
}
} }
return Ok(Some((app_id.clone(), secret.clone())));
} else {
debug!("✗ Secret from timezone '{}' failed userlib test", timezone);
} }
} }
} }

View File

@@ -129,6 +129,26 @@ pub trait QobuzConfigExt {
/// * `secret` - Le secret encodé en base64 (configvalue) /// * `secret` - Le secret encodé en base64 (configvalue)
fn set_qobuz_secret(&self, secret: &str) -> Result<()>; fn set_qobuz_secret(&self, secret: &str) -> Result<()>;
/// Récupère le secret brut du Spoofer depuis la configuration
///
/// # Returns
///
/// Le secret brut (non XORé), ou None si non configuré
///
/// # Note
///
/// Ce secret est obtenu par le Spoofer et est utilisé directement
/// sans XOR avec l'App ID. Si ce secret est présent, il est testé
/// en priorité avant de relancer le Spoofer.
fn get_qobuz_spoofer_secret(&self) -> Result<Option<String>>;
/// Définit le secret brut du Spoofer dans la configuration
///
/// # Arguments
///
/// * `secret` - Le secret brut obtenu par le Spoofer
fn set_qobuz_spoofer_secret(&self, secret: &str) -> Result<()>;
/// Récupère le token d'authentification depuis la configuration /// Récupère le token d'authentification depuis la configuration
/// ///
/// # Returns /// # Returns
@@ -291,6 +311,22 @@ impl QobuzConfigExt for Config {
) )
} }
fn get_qobuz_spoofer_secret(&self) -> Result<Option<String>> {
match self.get_value(&["accounts", "qobuz", "spoofer_secret"]) {
Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)),
Ok(Value::String(_)) => Ok(None), // Empty string
Ok(_) => Ok(None), // Wrong type
Err(_) => Ok(None), // Not configured
}
}
fn set_qobuz_spoofer_secret(&self, secret: &str) -> Result<()> {
self.set_value(
&["accounts", "qobuz", "spoofer_secret"],
Value::String(secret.to_string()),
)
}
fn get_qobuz_auth_token(&self) -> Result<Option<String>> { fn get_qobuz_auth_token(&self) -> Result<Option<String>> {
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)),

View File

@@ -191,8 +191,8 @@ pub struct StreamInfo {
pub url: String, pub url: String,
/// Type MIME /// Type MIME
pub mime_type: String, pub mime_type: String,
/// Fréquence d'échantillonnage (Hz) /// Fréquence d'échantillonnage (kHz)
pub sampling_rate: u32, pub sampling_rate: f64,
/// Profondeur de bits /// Profondeur de bits
pub bit_depth: u32, pub bit_depth: u32,
/// Format ID Qobuz /// Format ID Qobuz