PMOQobuz client is now working
This commit is contained in:
@@ -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", ¶ms).await?;
|
let response: FileUrlResponse = self.get("/track/getFileUrl", ¶ms).await?;
|
||||||
|
|
||||||
Ok(StreamInfo {
|
Ok(StreamInfo {
|
||||||
url: response.url,
|
url: response.url,
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user