Big Debug of PMOQobuz step 3

This commit is contained in:
2025-12-13 13:57:20 +01:00
parent 50693aaf7a
commit d2f6abf4bb
28 changed files with 1765 additions and 59 deletions

View File

@@ -72,7 +72,13 @@ impl QobuzApi {
let params = [("username", username), ("password", password)];
let response: LoginResponse = self.post("/user/login", &params).await?;
let response: LoginResponse = match self.post("/user/login", &params).await {
Ok(resp) => resp,
Err(e) => {
info!("✗ Login failed for {}: {}", username, e);
return Err(e);
}
};
// Vérifier que l'utilisateur a un abonnement valide
if response.user.credential.parameters.is_none() {
@@ -88,8 +94,8 @@ impl QobuzApi {
.parameters
.and_then(|p| p.short_label);
debug!(
"Login successful - User ID: {}, Subscription: {:?}",
info!(
"Login successful - User ID: {}, Subscription: {:?}",
user_id, subscription_label
);

View File

@@ -234,9 +234,9 @@ impl QobuzApi {
let format_id = self.format_id.id().to_string();
let intent = "stream";
let timestamp = signing::get_timestamp();
let app_id = self.app_id();
let user_auth_token = self
.auth_token()
// Vérifier que le token d'authentification est disponible
self.auth_token()
.ok_or_else(|| QobuzError::Unauthorized("Missing auth token".to_string()))?;
// Signer la requête (comme Python: track_getFileUrl)
@@ -249,18 +249,19 @@ impl QobuzApi {
);
// Construire les paramètres signés
// Note: app_id et user_auth_token sont envoyés automatiquement comme headers
// par la méthode request() (X-App-Id et X-User-Auth-Token)
// IMPORTANT: L'ordre doit correspondre EXACTEMENT à Python (raw.py)
let params = [
("track_id", track_id),
("format_id", format_id.as_str()),
("intent", intent),
("request_ts", timestamp.as_str()),
("request_sig", signature.as_str()),
("app_id", app_id.as_str()),
("user_auth_token", user_auth_token.as_str()),
("track_id", track_id),
];
// Utiliser GET (comme Python après sept 2024 selon le commentaire)
let response: FileUrlResponse = self.get("/track/getFileUrl", &params).await?;
// Utiliser POST (comme Python)
let response: FileUrlResponse = self.post("/track/getFileUrl", &params).await?;
Ok(StreamInfo {
url: response.url,

View File

@@ -21,6 +21,7 @@ pub use spoofer::Spoofer;
/// URL de base de l'API Qobuz
const API_BASE_URL: &str = "https://www.qobuz.com/api.json/0.2";
//const API_BASE_URL: &str = "http://localhost:8080/api.json/0.2";
/// App ID Qobuz par défaut
///
@@ -91,6 +92,23 @@ impl QobuzApi {
Ok(api)
}
/// Crée une API avec un secret brut (déjà décodé/dérivé)
///
/// # Arguments
///
/// * `app_id` - App ID Qobuz
/// * `raw_secret` - Secret prêt à l'emploi (comme ceux du Spoofer)
///
/// # Note
///
/// Utilisez cette méthode pour les secrets du Spoofer qui sont déjà
/// décodés et prêts à l'emploi (pas besoin de XOR avec l'app_id)
pub fn with_raw_secret(app_id: impl Into<String>, raw_secret: &str) -> Result<Self> {
let api = Self::new(app_id)?;
api.set_secret(raw_secret.as_bytes().to_vec());
Ok(api)
}
/// Définit le secret s4 directement
///
/// # Arguments
@@ -160,7 +178,7 @@ impl QobuzApi {
self.app_id.read().unwrap().clone()
}
/// Met à jour dynamiquement l'app_id et le secret associés
/// Met à jour dynamiquement l'app_id et le secret associés (avec XOR)
pub fn update_credentials(&self, app_id: impl Into<String>, configvalue: &str) -> Result<()> {
{
let mut current = self.app_id.write().unwrap();
@@ -170,6 +188,16 @@ impl QobuzApi {
self.set_secret_from_configvalue(configvalue)
}
/// Met à jour dynamiquement l'app_id et le secret brut (sans XOR)
pub fn update_credentials_raw(&self, app_id: impl Into<String>, raw_secret: &str) {
{
let mut current = self.app_id.write().unwrap();
*current = app_id.into();
}
self.set_secret(raw_secret.as_bytes().to_vec());
}
/// Retourne le token d'authentification si disponible
pub fn auth_token(&self) -> Option<String> {
self.user_auth_token.read().unwrap().clone()
@@ -232,11 +260,11 @@ impl QobuzApi {
// Envoyer la requête
let response = request.send().await?;
self.handle_response(response).await
self.handle_response(response, endpoint).await
}
/// Traite la réponse HTTP
async fn handle_response<T: DeserializeOwned>(&self, response: Response) -> Result<T> {
async fn handle_response<T: DeserializeOwned>(&self, response: Response, endpoint: &str) -> Result<T> {
let status = response.status();
let status_code = status.as_u16();
@@ -244,7 +272,7 @@ impl QobuzApi {
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
warn!("API error ({}): {}", status_code, error_text);
warn!("API error ({}) on {}: {}", status_code, endpoint, error_text);
return Err(QobuzError::from_status_code(status_code, error_text));
}
@@ -258,7 +286,7 @@ impl QobuzApi {
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error");
warn!("Qobuz API error: {}", message);
warn!("Qobuz API error on {}: {}", endpoint, message);
return Err(QobuzError::ApiError {
code: status_code,
message: message.to_string(),

View File

@@ -78,6 +78,23 @@ impl Spoofer {
.to_string())
}
/// Extrait l'appSecret depuis le bundle (secret MD5 à 32 caractères)
///
/// Ce secret est utilisé directement par Qobuz (nouvelle méthode)
/// au lieu d'être XORé avec l'app_id
pub fn get_app_secret(&self) -> Result<String> {
let captures = self
.app_id_regex
.captures(&self.bundle)
.ok_or_else(|| anyhow::anyhow!("AppSecret non trouvé dans le bundle"))?;
Ok(captures
.name("secret")
.ok_or_else(|| anyhow::anyhow!("Groupe secret non trouvé"))?
.as_str()
.to_string())
}
/// Extrait les secrets depuis le bundle
pub fn get_secrets(&self) -> Result<IndexMap<String, String>> {
// Étape 1: Extraire tous les seed/timezone pairs

View File

@@ -15,7 +15,7 @@ struct PaginatedResponse<T> {
/// Réponse de l'endpoint /favorite/getUserFavorites
#[derive(Debug, Deserialize)]
struct FavoritesResponse {
pub(crate) struct FavoritesResponse {
#[serde(default)]
albums: Option<PaginatedResponse<AlbumResponse>>,
#[serde(default)]

View File

@@ -133,7 +133,7 @@ impl QobuzClient {
let mut api = match (config_appid, config_secret) {
(Some(app_id), Some(secret)) => {
info!(
"Creating Qobuz API with configured App ID: {} and secret",
"Creating Qobuz API with configured App ID: {} and XOR secret",
app_id
);
match QobuzApi::with_secret(&app_id, &secret) {
@@ -152,7 +152,7 @@ impl QobuzClient {
}
_ => {
info!(
"AppID or secret not configured, using Spoofer to obtain valid credentials..."
"AppID or secret not configured (or app_id without secret), using Spoofer..."
);
Self::try_spoofer_fallback(config).await?
}
@@ -219,7 +219,8 @@ impl QobuzClient {
/// - Quand les credentials configurés sont invalides/expirés
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);
// Use raw secret from Spoofer (no XOR)
return QobuzApi::with_raw_secret(app_id, &secret);
}
info!(
@@ -231,48 +232,41 @@ impl QobuzClient {
async fn fetch_spoofer_credentials(config: &Config) -> Result<Option<(String, String)>> {
match crate::api::Spoofer::new().await {
Ok(spoofer) => match spoofer.get_app_id() {
Ok(app_id) => match spoofer.get_secrets() {
Ok(secrets) => {
info!("Spoofer found {} secret(s), testing them...", secrets.len());
Ok(app_id) => {
// Use timezone secrets (like Python)
// Note: App Secret from bundle doesn't work for signed requests
match spoofer.get_secrets() {
Ok(secrets) => {
info!("Testing {} timezone secret(s)...", secrets.len());
let (username, password) = config.get_qobuz_credentials()?;
for (timezone, secret) in secrets.iter() {
debug!("Testing secret for timezone: {}", timezone);
for (timezone, secret) in secrets.iter() {
debug!("Testing timezone secret: {}", timezone);
match QobuzApi::with_secret(&app_id, secret) {
Ok(_) => {
info!(
"✓ Successfully created API with secret from timezone: {}",
timezone
);
if let Err(e) = config.set_qobuz_appid(&app_id) {
debug!("Could not save appid to config: {}", e);
if let Ok(test_api) = QobuzApi::with_raw_secret(&app_id, secret) {
if let Ok(auth_info) = test_api.login(&username, &password).await {
// Test the secret using userlib_get_albums (like Python's setSec())
// This matches Python's behavior exactly
if test_api.userlib_get_albums().await.is_ok() {
info!("✓ Secret from timezone '{}' works!", timezone);
if let Err(e) = config.set_qobuz_appid(&app_id) {
debug!("Could not save appid: {}", e);
}
return Ok(Some((app_id.clone(), secret.clone())));
} else {
debug!("✗ Secret from timezone '{}' failed userlib test", timezone);
}
}
if let Err(e) = config.set_qobuz_secret(secret) {
debug!("Could not save secret to config: {}", e);
}
return Ok(Some((app_id.clone(), secret.clone())));
}
Err(e) => {
debug!(
"Failed to create API with secret from {}: {}",
timezone, e
);
continue;
}
}
}
info!("✗ No valid secret from Spoofer secrets list");
Ok(None)
}
Err(e) => {
info!(
"Spoofer failed to extract secrets: {}, falling back to DEFAULT_APP_ID",
e
);
Ok(None)
info!("✗ No valid secret found");
Ok(None)
}
Err(e) => {
info!("Failed to extract timezone secrets: {}", e);
Ok(None)
}
}
},
Err(e) => {
@@ -301,7 +295,8 @@ impl QobuzClient {
match Self::fetch_spoofer_credentials(config_arc.as_ref()).await? {
Some((app_id, secret)) => {
self.api.update_credentials(app_id, &secret)?;
// Use raw secret (no XOR) from Spoofer
self.api.update_credentials_raw(app_id, &secret);
info!("✓ Updated Qobuz API credentials using Spoofer");
Ok(())
}