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

@@ -0,0 +1,12 @@
host:
http_port: '8080'
cover_cache:
directory: cache_covers
size: 2000
audio_cache:
directory: cache_audio
size: 500
logger:
buffer_capacity: 200
enable_console: true
min_level: INFO

View File

@@ -45,6 +45,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("💾 Initializing caches...");
let cover_cache = Arc::new(CoverCache::new("./cache/qobuz-covers", 500)?);
let audio_cache = Arc::new(AudioCache::new("./cache/qobuz-audio", 100)?);
// IMPORTANT: Register audio cache globally so PlaylistManager can access it
pmoplaylist::register_audio_cache(audio_cache.clone());
println!("✅ Caches initialized\n");
// Step 3: Create QobuzSource with caches

View File

@@ -0,0 +1,47 @@
//! Affiche tous les secrets du Spoofer Rust
use anyhow::Result;
use pmoqobuz::api::Spoofer;
#[tokio::main]
async fn main() -> Result<()> {
println!("=== Rust Spoofer Secrets ===\n");
let spoofer = Spoofer::new().await?;
// App ID
let app_id = spoofer.get_app_id()?;
println!("App ID: {}\n", app_id);
// App Secret (MD5 hash from bundle)
match spoofer.get_app_secret() {
Ok(app_secret) => {
println!("App Secret (from bundle.js):");
println!(" Full value: {}", app_secret);
println!(" Length: {}\n", app_secret.len());
}
Err(e) => {
println!("App Secret: Error - {}\n", e);
}
}
// Timezone secrets
match spoofer.get_secrets() {
Ok(secrets) => {
println!("Timezone Secrets:");
println!(" Number of secrets: {}\n", secrets.len());
for (i, (tz, secret)) in secrets.iter().enumerate() {
println!("Secret {} (timezone: {}):", i + 1, tz);
println!(" Full value: {}", secret);
println!(" Length: {}", secret.len());
println!();
}
}
Err(e) => {
println!("Timezone Secrets: Error - {}\n", e);
}
}
Ok(())
}

View File

@@ -92,6 +92,20 @@ impl Spoofer {
.to_string())
}
/// Extrait l'appSecret depuis le bundle (secret à 32 caractères)
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
fn get_secrets(&self) -> Result<IndexMap<String, String>> {
// Étape 1: Extraire tous les seed/timezone pairs
@@ -222,8 +236,15 @@ async fn main() -> Result<()> {
Err(e) => eprintln!("Erreur lors de l'extraction de l'App ID: {}", e),
}
// Extraire les secrets
println!("\n--- Secrets ---");
// Extraire l'appSecret (32 caractères)
println!("\n--- AppSecret (32 chars) ---");
match spoofer.get_app_secret() {
Ok(secret) => println!("AppSecret: {}", secret),
Err(e) => eprintln!("Erreur lors de l'extraction de l'AppSecret: {}", e),
}
// Extraire les secrets timezone
println!("\n--- Secrets timezone (décodés base64) ---");
match spoofer.get_secrets() {
Ok(secrets) => {
for (timezone, secret) in secrets {

View File

@@ -0,0 +1,64 @@
//! Test simplifié - va directement à get_file_url
//! Pour comparaison avec Python via fake server
//!
//! Ce test est conçu pour être utilisé avec le fake_qobuz_server.py
//! Les credentials sont hardcodés car le fake server les accepte tous
use anyhow::Result;
use pmoqobuz::api::{QobuzApi, Spoofer};
#[tokio::main]
async fn main() -> Result<()> {
// Initialiser le logging
tracing_subscriber::fmt::init();
println!("=== Test get_file_url (Rust) ===\n");
// Track ID connu (récupéré du test Python)
let track_id = "19557883";
let format_id = 27;
println!("1. Creating QobuzApi (auto-initializes Spoofer)...");
let spoofer = Spoofer::new().await?;
let app_id = spoofer.get_app_id()?;
let app_secret = spoofer.get_app_secret()?;
let mut api = QobuzApi::with_raw_secret(&app_id, &app_secret)?;
// Configurer format_id = 27 (comme Python)
use pmoqobuz::AudioFormat;
api.set_format(AudioFormat::Flac_HiRes_192);
println!(" ✓ API created");
println!(" App ID: {}", app_id);
println!(" Format: 27 (Flac_HiRes_192) - same as Python");
println!("\n2. Logging in...");
println!(" Using fake credentials (fake server accepts all)");
let username = "eric@coissac.eu";
let password = "fake_password";
let user = api.login(username, password).await?;
println!(" ✓ Login successful - User ID: {}", user.user_id);
println!(" Token: {}...", &user.token[..20.min(user.token.len())]);
println!("\n3. Calling track/getFileUrl...");
println!(" Track ID: {}", track_id);
println!(" Format ID: {} (default)", format_id);
println!(" ⚠️ THIS CALL IS SIGNED - Watch fake server logs!\n");
match api.get_file_url(track_id).await {
Ok(stream_info) => {
println!(" ✓ Success!");
println!(" URL: {}...", &stream_info.url[..80.min(stream_info.url.len())]);
println!(" MIME type: {}", stream_info.mime_type);
}
Err(e) => {
println!(" ✗ Failed: {}", e);
return Err(e.into());
}
}
println!("\n=== Test completed successfully! ===");
Ok(())
}

View File

@@ -0,0 +1,57 @@
//! Test de signature MD5 avec timestamp fixe
//! Compare la signature générée par Rust avec celle de Python
use anyhow::Result;
use pmoqobuz::api::{signing, Spoofer};
#[tokio::main]
async fn main() -> Result<()> {
println!("=== Test de signature MD5 ===\n");
// Récupérer le secret depuis Spoofer (timezone secrets, comme Python)
println!("1. Getting secret from Spoofer...");
let spoofer = Spoofer::new().await?;
let timezone_secrets = spoofer.get_secrets()?;
let app_secret = timezone_secrets.values().next().unwrap(); // Premier secret (comme Python)
println!(" ✓ Secret retrieved (length: {} bytes)", app_secret.len());
println!(" Using timezone secret (like Python), not App Secret");
// Paramètres du test
let track_id = "19557883";
let format_id = "27";
let intent = "stream";
// TIMESTAMP FIXE pour comparaison
let timestamp = "1234567890.123456";
println!("\n2. Computing signature with FIXED timestamp:");
println!(" track_id: {}", track_id);
println!(" format_id: {}", format_id);
println!(" intent: {}", intent);
println!(" timestamp: {}", timestamp);
println!(" secret: {}... (first 10 chars)", &app_secret[..10.min(app_secret.len())]);
// Calculer la signature
let signature = signing::sign_track_get_file_url(
format_id,
intent,
track_id,
timestamp,
app_secret.as_bytes(),
);
println!("\n3. Result:");
println!(" Signature: {}", signature);
println!("\n✓ You can now compare this signature with Python using the same timestamp.");
println!("\nPython test command:");
println!(" python3 -c \"");
println!("import hashlib");
println!("secret = '{}'", app_secret);
println!("ts = '{}'", timestamp);
println!("s = 'trackgetFileUrlformat_id27intentstreamtrack_id19557883' + ts");
println!("s = s.encode('ASCII') + secret.encode('utf-8')");
println!("print('Signature:', hashlib.md5(s).hexdigest())");
println!(" \"");
Ok(())
}

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(())
}

View File

@@ -0,0 +1,195 @@
# Guide de comparaison Python vs Rust
## Objectif
Comparer exactement ce que Python envoie vs ce que Rust envoie lors de l'appel à `track/getFileUrl`.
## Étape 1: Tester Python avec le fake server
### Terminal 1 - Démarrer le fake server:
```bash
cd pmoqobuz/test_python_qobuz
python3 fake_qobuz_server.py
```
Laissez ce terminal ouvert. Il affichera tous les détails des requêtes.
### Terminal 2 - Tester Python:
```bash
cd pmoqobuz/test_python_qobuz
# Patcher pour utiliser localhost
python3 patch_for_fake.py
# Lancer le test simplifié
python3 test_getfileurl.py
```
**Entrer vos credentials:**
- Username: `eric@coissac.eu` (ou Entrée)
- Password: votre mot de passe
### Terminal 1 - OBSERVER:
Le serveur affichera 2 requêtes:
1. **POST /api.json/0.2/user/login** - Login (pas de signature)
2. **POST /api.json/0.2/track/getFileUrl** - ⚠️ C'EST CELLE-CI QU'ON VEUT!
Pour la requête `track/getFileUrl`, notez EXACTEMENT:
```
Headers:
X-App-Id: ???
X-User-Auth-Token: ???
Content-Type: ???
Content-Length: ???
Body (Form Data):
track_id: ???
format_id: ???
intent: ???
request_ts: ??? ← FORMAT DU TIMESTAMP
request_sig: ??? ← SIGNATURE MD5
```
**IMPORTANT:** Copiez/collez ou prenez une capture d'écran de cette requête!
### Terminal 2 - Restaurer:
```bash
python3 patch_for_fake.py restore
```
---
## Étape 2: Tester Rust avec le fake server
### Terminal 1 - Fake server toujours actif
### Terminal 2 - Modifier temporairement le code Rust:
Éditer `pmoqobuz/src/api/mod.rs` ligne ~32:
```rust
// AVANT:
const API_BASE_URL: &str = "https://www.qobuz.com/api.json/0.2";
// APRÈS (temporaire!):
const API_BASE_URL: &str = "http://localhost:8080/api.json/0.2";
```
### Terminal 2 - Compiler et lancer:
```bash
cd pmoqobuz
cargo build --example lazy_loading
cargo run --example lazy_loading
```
### Terminal 1 - OBSERVER:
Cherchez la requête **POST /api.json/0.2/track/getFileUrl**
Notez les mêmes détails que pour Python:
```
Headers:
X-App-Id: ???
X-User-Auth-Token: ???
Content-Type: ???
Content-Length: ???
Body (Form Data):
track_id: ???
format_id: ???
intent: ???
request_ts: ??? ← FORMAT DU TIMESTAMP
request_sig: ??? ← SIGNATURE MD5
```
### Terminal 2 - Restaurer le code Rust:
Remettre l'URL originale:
```rust
const API_BASE_URL: &str = "https://www.qobuz.com/api.json/0.2";
```
---
## Étape 3: Comparer
Placez les deux requêtes côte à côte et cherchez les différences:
### Différences possibles:
1. **Ordre des paramètres** dans le form data
2. **Format du timestamp** (nombre de décimales?)
3. **Signature MD5** (devrait être différente car timestamp différent)
4. **Headers manquants** (Content-Type?)
5. **Valeurs des headers** (X-App-Id différent?)
6. **Encoding** du form data
### Ce qu'on DOIT voir identique:
- Méthode: POST (pas GET)
- Headers X-App-Id et X-User-Auth-Token présents
- Paramètres: track_id, format_id, intent, request_ts, request_sig
- Content-Type: application/x-www-form-urlencoded
### Ce qui PEUT être différent:
- Valeur de request_ts (timestamp différent)
- Valeur de request_sig (car timestamp différent)
- Ordre des paramètres (si ça n'affecte pas la signature)
### Ce qui NE DOIT PAS être différent:
- **Format** du timestamp (même nombre de décimales)
- Type de requête (POST)
- Présence de tous les headers requis
---
## Exemple de comparaison
### Python (référence):
```
POST /api.json/0.2/track/getFileUrl
Headers:
X-App-Id: 798273057
X-User-Auth-Token: NAP_hlSUqU...
Content-Type: application/x-www-form-urlencoded
Content-Length: 156
Body:
track_id=19557883&format_id=27&intent=stream&request_ts=1734170123.456789&request_sig=abc123def456...
```
### Rust (à corriger):
```
POST /api.json/0.2/track/getFileUrl
Headers:
X-App-Id: 798273057
X-User-Auth-Token: NAP_hlSUqU...
Content-Type: application/x-www-form-urlencoded
Content-Length: 156
Body:
track_id=19557883&format_id=27&intent=stream&request_ts=1734170123.45678&request_sig=xyz789abc012...
^
Une décimale en moins?
```
---
## Notes
- Le fake server log TOUT, donc vous verrez aussi les requêtes de login
- Concentrez-vous sur la requête `/track/getFileUrl` qui nécessite une signature
- Prenez des captures d'écran ou copiez les logs pour comparaison facile

View File

@@ -0,0 +1,158 @@
# Instructions de test - Comparaison Python vs Rust
## Étape 1: Tester le script Python contre l'API réelle
```bash
cd pmoqobuz/test_python_qobuz
python3 test_qobuz.py
```
**Ce que vous devez entrer:**
- Username: `eric@coissac.eu` (ou appuyez Entrée pour défaut)
- Password: votre mot de passe Qobuz
**Résultat attendu:**
Si le script Python **réussit**, cela confirme que:
- ✅ La méthode de signature Python est correcte
- ✅ Les secrets extraits par le spoofer fonctionnent
- ✅ Notre implémentation Rust a un problème spécifique
Si le script Python **échoue aussi**, cela signifie:
- ❌ Le problème est dans les secrets eux-mêmes
- ❌ Ou Qobuz a changé leur API récemment
---
## Étape 2: Analyser les requêtes avec le fake server
### Terminal 1 - Démarrer le fake server:
```bash
cd pmoqobuz/test_python_qobuz
python3 fake_qobuz_server.py
```
Le serveur affichera tous les détails des requêtes reçues.
### Terminal 2 - Tester avec Python:
```bash
cd pmoqobuz/test_python_qobuz
python3 patch_for_fake.py # Redirige vers localhost
python3 test_qobuz.py # Entrer credentials
```
**Observer dans Terminal 1:**
- Méthode HTTP (GET/POST) pour `/track/getFileUrl`
- Headers exactes (X-App-Id, X-User-Auth-Token)
- Paramètres et leur ordre
- Format du `request_ts` (timestamp)
- Format du `request_sig` (signature MD5)
- Content-Type du body
### Terminal 2 - Restaurer:
```bash
python3 patch_for_fake.py restore
```
---
## Étape 3: Tester Rust contre le fake server
### Modifier temporairement le code Rust:
Éditer `pmoqobuz/src/api/mod.rs` ligne ~32:
```rust
// Avant:
const API_BASE_URL: &str = "https://www.qobuz.com/api.json/0.2";
// Après (temporaire!):
const API_BASE_URL: &str = "http://localhost:8080/api.json/0.2";
```
### Terminal 1 - Fake server toujours actif
### Terminal 2 - Lancer l'exemple Rust:
```bash
cd pmoqobuz
cargo run --example lazy_loading
```
**Observer dans Terminal 1:**
Les mêmes détails que pour Python.
### Restaurer le code Rust:
```rust
const API_BASE_URL: &str = "https://www.qobuz.com/api.json/0.2";
```
---
## Étape 4: Comparer
Comparez côte à côte:
### Python (référence qui marche):
```
POST /api.json/0.2/track/getFileUrl
Headers:
X-App-Id: 798273057
X-User-Auth-Token: NAP_...
Content-Type: application/x-www-form-urlencoded
Body:
track_id=12345678
format_id=27
intent=stream
request_ts=1734169183.123456
request_sig=a1b2c3d4...
```
### Rust (à corriger):
```
POST /api.json/0.2/track/getFileUrl
Headers:
X-App-Id: ???
X-User-Auth-Token: ???
Content-Type: ???
Body:
??? ordre différent ???
??? timestamp différent ???
??? signature différente ???
```
### Différences à chercher:
1. **Ordre des paramètres** - l'ordre affecte-t-il la signature?
2. **Format du timestamp** - nombre de décimales?
3. **Headers manquants** - Content-Type?
4. **Signature MD5** - différente malgré mêmes inputs?
5. **Method** - vraiment POST des deux côtés?
---
## Script automatique
Pour simplifier, vous pouvez aussi utiliser:
```bash
./run_comparison.sh # Test contre API réelle
./run_comparison.sh fake # Test avec fake server (interactif)
```
---
## Nettoyage
```bash
# Arrêter tous les fake servers
pkill -f fake_qobuz_server
# Restaurer raw.py si nécessaire
python3 patch_for_fake.py restore
```

View File

@@ -0,0 +1,65 @@
# Test Python Qobuz - Debugging Suite
Ce répertoire contient des outils pour comparer le comportement Python vs Rust de l'API Qobuz.
## Fichiers
- `raw.py` - Module API Qobuz copié depuis UPMPdcli
- `spoofbuz.py` - Spoofer pour extraire les credentials
- `test_qobuz.py` - Script de test contre l'API réelle
- `fake_qobuz_server.py` - Serveur fake qui log toutes les requêtes
- `patch_for_fake.py` - Utilitaire pour rediriger vers le fake server
## Usage
### 1. Test contre l'API réelle Qobuz
```bash
cd pmoqobuz/test_python_qobuz
python3 test_qobuz.py
```
Entrer username et password quand demandé.
### 2. Test avec le fake server (pour debug)
Terminal 1 - Lancer le fake server:
```bash
cd pmoqobuz/test_python_qobuz
python3 fake_qobuz_server.py
```
Terminal 2 - Patcher et tester:
```bash
cd pmoqobuz/test_python_qobuz
python3 patch_for_fake.py # Redirige vers localhost:8080
python3 test_qobuz.py # Lance le test
python3 patch_for_fake.py restore # Restaure l'URL originale
```
Le fake server affichera TOUS les détails des requêtes:
- Méthode HTTP (GET/POST)
- URL complète
- Headers
- Query parameters (si GET)
- Form data (si POST)
- Timestamp exact de chaque requête
## Comparaison Python vs Rust
Pour comparer:
1. Lancer le fake server
2. Patcher raw.py
3. Lancer test_qobuz.py → Observer les logs du serveur
4. Restaurer raw.py
5. Modifier le code Rust pour pointer vers localhost:8080
6. Lancer l'exemple Rust → Observer les logs du serveur
7. Comparer les deux sorties ligne par ligne
## Ce qu'on cherche
- Ordre des paramètres dans la signature
- Format exact du timestamp
- Différences dans les headers
- Différences POST vs GET
- Format exact de request_sig

View File

@@ -0,0 +1,68 @@
# 🔍 Debugging Environment - START HERE
## Ce que nous savons
**Python fonctionne** - Le script `test_qobuz.py` réussit à appeler `track_getFileUrl()`
**Rust échoue** - Erreur "Invalid Request Signature parameter (request_sig)"
**Conclusion:** Le problème est spécifique à notre implémentation Rust.
## Prochaine étape: Comparer les requêtes
Nous allons comparer exactement ce que Python envoie vs ce que Rust envoie.
### Option 1: Guide détaillé (recommandé)
Lisez [COMPARISON_GUIDE.md](COMPARISON_GUIDE.md) pour un guide étape par étape.
### Option 2: Script rapide
Terminal 1:
```bash
cd pmoqobuz/test_python_qobuz
python3 fake_qobuz_server.py
```
Terminal 2:
```bash
cd pmoqobuz/test_python_qobuz
./quick_compare.sh
```
## Fichiers disponibles
### Scripts de test:
- `test_qobuz.py` - Test complet contre l'API réelle (déjà validé ✅)
- `test_getfileurl.py` - Test simplifié pour comparaison avec fake server
- `fake_qobuz_server.py` - Serveur fake qui log toutes les requêtes
- `patch_for_fake.py` - Utilitaire pour rediriger vers fake server
### Guides:
- `COMPARISON_GUIDE.md` - Guide détaillé de comparaison
- `INSTRUCTIONS.md` - Instructions générales
- `README.md` - Documentation
### Scripts utilitaires:
- `quick_compare.sh` - Script automatique de comparaison
- `run_comparison.sh` - Alternative
## Ce qu'on cherche
En comparant les requêtes Python vs Rust pour `/track/getFileUrl`, on cherche:
1. **Format du timestamp** - Nombre de décimales?
2. **Ordre des paramètres** - Affecte-t-il la signature?
3. **Headers** - Content-Type manquant?
4. **Encoding** - Problème d'encodage du form data?
## Résultat attendu
Après comparaison, vous devriez identifier LA différence exacte qui cause l'échec de validation de signature côté Qobuz.
Exemple de différence possible:
```
Python: request_ts=1734170123.456789 (6 décimales)
Rust: request_ts=1734170123.45678 (5 décimales)
```
Cette petite différence suffirait à invalider la signature MD5!

View File

@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""
Fake Qobuz server for debugging
Logs all incoming requests with full details
"""
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
import urllib.parse
from datetime import datetime
class FakeQobuzHandler(BaseHTTPRequestHandler):
def log_request_details(self, method):
"""Log complete request details"""
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
print(f"\n{'='*80}")
print(f"[{timestamp}] {method} {self.path}")
print(f"{'='*80}")
# Parse URL
parsed = urllib.parse.urlparse(self.path)
print(f"\nURL Components:")
print(f" Path: {parsed.path}")
print(f" Query: {parsed.query}")
if parsed.query:
params = urllib.parse.parse_qs(parsed.query)
print(f"\nQuery Parameters:")
for key, values in sorted(params.items()):
print(f" {key}: {values[0]}")
# Headers
print(f"\nHeaders:")
for header, value in sorted(self.headers.items()):
print(f" {header}: {value}")
# Body for POST
if method == "POST":
content_length = int(self.headers.get('Content-Length', 0))
if content_length > 0:
body = self.rfile.read(content_length)
print(f"\nBody (raw): {body}")
content_type = self.headers.get('Content-Type', '')
if 'application/x-www-form-urlencoded' in content_type:
params = urllib.parse.parse_qs(body.decode('utf-8'))
print(f"\nForm Data:")
for key, values in sorted(params.items()):
print(f" {key}: {values[0]}")
elif 'application/json' in content_type:
try:
data = json.loads(body.decode('utf-8'))
print(f"\nJSON Data:")
print(f" {json.dumps(data, indent=2)}")
except:
pass
print(f"\n{'='*80}\n")
def do_GET(self):
self.log_request_details("GET")
self.send_fake_response()
def do_POST(self):
self.log_request_details("POST")
self.send_fake_response()
def send_fake_response(self):
"""Send a fake successful response"""
path = urllib.parse.urlparse(self.path).path
# Login response
if '/user/login' in path:
response = {
"user": {
"id": "1217710",
"credential": {
"parameters": {
"short_label": "Studio"
}
}
},
"user_auth_token": "FAKE_TOKEN_12345"
}
# Favorite albums
elif '/favorite/getUserFavorites' in path:
response = {
"albums": {
"total": 1,
"items": [{
"id": "0825646206179",
"title": "Under the Shade of Violets",
"artist": {"name": "Orange Blossom"}
}]
}
}
# Album get
elif '/album/get' in path:
response = {
"tracks": {
"items": [{
"id": "12345678",
"title": "Test Track"
}]
}
}
# Track getFileUrl
elif '/track/getFileUrl' in path:
response = {
"url": "https://fake.qobuz.com/track.flac",
"mime_type": "audio/flac",
"sampling_rate": 44.1,
"bit_depth": 16,
"format_id": 27
}
else:
response = {"status": "ok"}
# Send response
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(response).encode('utf-8'))
def log_message(self, format, *args):
"""Suppress default logging"""
pass
def run_server(port=8080):
server = HTTPServer(('localhost', port), FakeQobuzHandler)
print(f"🎭 Fake Qobuz Server running on http://localhost:{port}")
print(f" Logging all requests to console...")
print(f" Press Ctrl+C to stop\n")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n\n✓ Server stopped")
if __name__ == "__main__":
run_server()

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""
Patch raw.py to use fake server
"""
import sys
def patch_raw_py():
"""Replace base URL in raw.py"""
with open('raw.py', 'r') as f:
content = f.read()
# Replace base URL (without version, as it's constructed dynamically)
original_line = 'self.baseUrl = "https://www.qobuz.com/api.json/"'
fake_line = 'self.baseUrl = "http://localhost:8080/api.json/"'
if original_line in content:
content = content.replace(original_line, fake_line)
with open('raw.py', 'w') as f:
f.write(content)
print(f"✓ Patched raw.py:")
print(f" {original_line}")
print(f"{fake_line}")
return True
else:
print("✗ Original URL not found in raw.py")
print(" Looking for:", original_line)
return False
def unpatch_raw_py():
"""Restore original URL in raw.py"""
with open('raw.py', 'r') as f:
content = f.read()
original_line = 'self.baseUrl = "https://www.qobuz.com/api.json/"'
fake_line = 'self.baseUrl = "http://localhost:8080/api.json/"'
if fake_line in content:
content = content.replace(fake_line, original_line)
with open('raw.py', 'w') as f:
f.write(content)
print(f"✓ Restored raw.py to original URL")
return True
else:
print("✗ Fake URL not found in raw.py (already restored?)")
return False
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "restore":
unpatch_raw_py()
else:
patch_raw_py()
print("\nTo restore: python3 patch_for_fake.py restore")

View File

@@ -0,0 +1,79 @@
#!/bin/bash
# Quick comparison script
echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ Python vs Rust - track_getFileUrl Comparison ║"
echo "╚═══════════════════════════════════════════════════════════════╝"
echo ""
echo "This script will help you compare the exact requests sent by"
echo "Python (working) vs Rust (failing) to find the difference."
echo ""
echo "Prerequisites:"
echo " - Terminal 1: Run fake_qobuz_server.py"
echo " - Terminal 2: Run this script"
echo ""
read -p "Press Enter when fake server is running in Terminal 1..."
echo ""
echo "══════════════════════════════════════════════════════════════"
echo " STEP 1: Testing Python"
echo "══════════════════════════════════════════════════════════════"
echo ""
# Patch for fake server
echo "→ Patching raw.py to use localhost:8080..."
python3 patch_for_fake.py
echo ""
echo "→ Running Python test..."
echo " (Watch Terminal 1 for the /track/getFileUrl request!)"
echo ""
python3 test_getfileurl.py
echo ""
echo "→ Restoring raw.py..."
python3 patch_for_fake.py restore
echo ""
echo "══════════════════════════════════════════════════════════════"
echo " Python test complete!"
echo "══════════════════════════════════════════════════════════════"
echo ""
echo "Did you see the /track/getFileUrl request in Terminal 1?"
echo "Make sure to COPY or SCREENSHOT it!"
echo ""
read -p "Press Enter when ready to test Rust..."
echo ""
echo "══════════════════════════════════════════════════════════════"
echo " STEP 2: Testing Rust"
echo "══════════════════════════════════════════════════════════════"
echo ""
echo "⚠️ MANUAL STEP REQUIRED:"
echo ""
echo "1. Edit pmoqobuz/src/api/mod.rs line ~32:"
echo " Change: const API_BASE_URL: &str = \"https://www.qobuz.com/api.json/0.2\";"
echo " To: const API_BASE_URL: &str = \"http://localhost:8080/api.json/0.2\";"
echo ""
echo "2. Compile and run:"
echo " cd .."
echo " cargo run --example lazy_loading"
echo ""
echo "3. Watch Terminal 1 for the /track/getFileUrl request!"
echo ""
echo "4. COMPARE with Python request!"
echo ""
echo "5. Don't forget to restore the URL in mod.rs when done!"
echo ""
echo "══════════════════════════════════════════════════════════════"
echo " Comparison tips:"
echo "══════════════════════════════════════════════════════════════"
echo ""
echo "Look for differences in:"
echo " • Order of form parameters"
echo " • Format of request_ts (timestamp)"
echo " • Presence of headers (Content-Type, etc.)"
echo " • Encoding of form data"
echo ""
echo "See COMPARISON_GUIDE.md for detailed instructions."
echo ""

342
pmoqobuz/test_python_qobuz/raw.py Executable file
View File

@@ -0,0 +1,342 @@
"""
qobuz.api.raw
~~~~~~~~~~~~~
Our base api, all method are mapped like in <endpoint>_<method>
see Qobuz API on GitHub (https://github.com/Qobuz/api-documentation)
:part_of: xbmc-qobuz
:copyright: (c) 2012 by Joachim Basmaison, Cyril Leclerc
:license: GPLv3, see LICENSE for more details.
"""
import sys
import time
import math
import hashlib
import socket
import binascii
from itertools import cycle
import requests
import spoofbuz
socket.timeout = 5
_loglevel = 3
def debug(s):
if _loglevel >= 4:
print("%s" % s, file=sys.stderr)
def warn(s):
if _loglevel >= 3:
print("%s" % s, file=sys.stderr)
class RawApi(object):
def __init__(self, appid, configvalue):
if appid and configvalue:
self.configvalue = configvalue
self.appid = appid
self.__set_s4()
else:
self.spoofer = spoofbuz.Spoofer()
self.appid = self.spoofer.getAppId()
self.version = "0.2"
self.baseUrl = "http://localhost:8080/api.json/"
self.user_auth_token = None
self.user_id = None
self.error = None
self.status_code = None
self._baseUrl = self.baseUrl + self.version
self.session = requests.Session()
self.error = None
def _api_error_string(self, request, url="", params={}, json=""):
return (
"{reason} (code={status_code})\n"
"url={url}\nparams={params}"
"\njson={json}".format(
reason=request.reason,
status_code=self.status_code,
url=url,
params=str(["%s: %s" % (k, v) for k, v in params.items()]),
json=str(json),
)
)
def _check_ka(self, ka, mandatory, allowed=[]):
"""Checking parameters before sending our request
- if mandatory parameter is missing raise error
- if a given parameter is neither in mandatory or allowed
raise error
"""
for label in mandatory:
if not label in ka:
raise Exception("Qobuz: missing parameter [%s]" % label)
for label in ka:
if label not in mandatory and label not in allowed:
raise Exception("Qobuz: invalid parameter [%s]" % label)
# Having no parameters set triggers a problem in the pyrequests/Qobuz dialog, don't know
# where the bug is, but it results in a 411 (length required). So set a very high limit if
# nothing is set
noparams = True
for label in ka:
if ka[label]:
noparams = False
break
if noparams:
ka["limit"] = "10000"
def __set_s4(self):
"""appid and associated secret is for this app usage only
Any use of the API implies your full acceptance of the
General Terms and Conditions
(http://www.qobuz.com/apps/api/QobuzAPI-TermsofUse.pdf)
"""
s3b = self.configvalue.encode("ASCII")
s3s = binascii.a2b_base64(s3b)
bappid = self.appid.encode("ASCII")
a = cycle(bappid)
b = zip(s3s, a)
self.s4 = b"".join((x ^ y).to_bytes(1, byteorder="big") for (x, y) in b)
# print("S4: %s"% self.s4.decode('ASCII'), file=sys.stderr)
def __unset_s4(self, id, sec):
a = cycle(id)
b = zip(sec, a)
bs4 = b"".join((x ^ y).to_bytes(1, byteorder="big") for (x, y) in b)
value = binascii.b2a_base64(bs4)
return value
def _api_request(self, params, uri, **opt):
"""Qobuz API HTTP get request
Arguments:
params: parameters dictionary
uri : service/method
opt : Optional named parameters
- noToken=True/False
- useGet=True/False
Return None if something went wrong
Return raw data from qobuz on success as dictionary
* on error you can check error and status_code
Example:
ret = api._api_request({'username':'foo',
'password':'bar'},
'user/login', noToken=True)
print('Error: %s [%s]' % (api.error, api.status_code))
This should produce something like:
Error: [200]
Error: Bad Request [400]
"""
self.error = ""
self.status_code = None
url = self._baseUrl + uri
useToken = False if (opt and "noToken" in opt) else True
useGet = True if (opt and "useGet" in opt) else False
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:67.0) Gecko/20100101 Firefox/67.0"
}
if useToken and self.user_auth_token:
headers["X-User-Auth-Token"] = self.user_auth_token
headers["X-App-Id"] = self.appid
r = None
op = "GET" if useGet else "POST"
# warn(f"{op} {url} params {params} headers {headers}")
debug(f"{op} {url} params {params}")
try:
if useGet:
r = self.session.get(url, params=params, headers=headers)
else:
r = self.session.post(url, data=params, headers=headers)
except:
self.error = "Post request fail"
warn(self.error)
return None
debug(f"status_code: {r.status_code}\nheaders: {r.headers}\ncontent: {r.content}")
self.status_code = int(r.status_code)
if self.status_code != 200:
self.error = self._api_error_string(r, url, params)
warn(self.error)
return None
if not r.content:
self.error = "Request return no content"
warn(self.error)
return None
"""Retry get if connexion fail"""
try:
response_json = r.json()
except Exception as e:
warn("Json loads failed to load... retrying!\n{}", repr(e))
try:
response_json = r.json()
except:
self.error = "Failed to load json two times...abort"
warn(self.error)
return None
status = None
try:
status = response_json["status"]
except:
pass
if status == "error":
self.error = self._api_error_string(r, url, params, response_json)
warn(self.error)
return None
return response_json
def logout(self):
self.user_auth_token = None
self.user_id = None
self.logged_on = None
def user_login(self, **ka):
self._check_ka(ka, ["username", "password"], ["email"])
data = self._api_request(ka, "/user/login", noToken=True)
if (
not data
or not "user" in data
or not "credential" in data["user"]
or not "id" in data["user"]
or not "parameters" in data["user"]["credential"]
):
warn("/user/login returns %s" % data)
self.logout()
return None
if not data["user"]["credential"]["parameters"]:
warn("Free accounts are not eligible to download tracks.")
return None
self.user_id = data["user"]["id"]
self.user_auth_token = data["user_auth_token"]
self.label = data["user"]["credential"]["parameters"]["short_label"]
debug("Membership: {}".format(self.label))
data["user"]["email"] = ""
data["user"]["firstname"] = ""
data["user"]["lastname"] = ""
self.setSec()
return data
def setSec(self):
global _loglevel
savedloglevel = _loglevel
_loglevel = 1
for value in self.spoofer.getSecrets().values():
self.s4 = value.encode("utf-8")
if self.userlib_getAlbums(sec=self.s4) is not None:
# debug("SECRET [%s]"%self.s4)
_loglevel = savedloglevel
return
_loglevel = savedloglevel
def track_get(self, **ka):
self._check_ka(ka, ["track_id"])
return self._api_request(ka, "/track/get")
def track_getFileUrl(self, intent="stream", **ka):
self._check_ka(ka, ["format_id", "track_id"])
ts = str(time.time())
track_id = str(ka["track_id"])
fmt_id = str(ka["format_id"])
stringvalue = (
"trackgetFileUrlformat_id" + fmt_id + "intent" + intent + "track_id" + track_id + ts
)
stringvalue = stringvalue.encode("ASCII")
stringvalue += self.s4
rq_sig = str(hashlib.md5(stringvalue).hexdigest())
params = {
"format_id": fmt_id,
"intent": intent,
"request_ts": ts,
"request_sig": rq_sig,
"track_id": track_id,
}
return self._api_request(params, "/track/getFileUrl")
def userlib_getAlbums(self, **ka):
ts = str(time.time())
r_sig = "userLibrarygetAlbumsList" + str(ts) + str(ka["sec"])
r_sig_hashed = hashlib.md5(r_sig.encode("utf-8")).hexdigest()
params = {
"app_id": self.appid,
"user_auth_token": self.user_auth_token,
"request_ts": ts,
"request_sig": r_sig_hashed,
}
return self._api_request(params, "/userLibrary/getAlbumsList")
# Currently unused. Check that it works ?
def track_search(self, **ka):
self._check_ka(ka, ["query"], ["limit"])
return self._api_request(ka, "/track/search")
def album_get(self, **ka):
self._check_ka(ka, ["album_id"], ["extra", "limit", "offset"])
# As of around sept 2024, using a POST for this does not work any more (always return the
# same album, not the one requested. Probably an inadvertant change on the Qobuz side. The
# WEB player uses a GET
return self._api_request(ka, "/album/get", useGet=True)
def album_getFeatured(self, **ka):
self._check_ka(ka, [], ["type", "genre_ids", "limit", "offset"])
return self._api_request(ka, "/album/getFeatured", useGet=True)
def favorite_getUserFavorites(self, **ka):
self._check_ka(ka, [], ["user_id", "type", "limit", "offset"])
return self._api_request(ka, "/favorite/getUserFavorites")
def playlist_get(self, **ka):
self._check_ka(ka, ["playlist_id"], ["extra", "limit", "offset"])
return self._api_request(ka, "/playlist/get", useGet=True)
def playlist_getFeatured(self, **ka):
# type is 'last-created' or 'editor-picks'
self._check_ka(ka, ["type"], ["genre_ids", "tags", "limit", "offset"])
for k in ("tags", "genre_ids"):
if k in ka and ka[k] == "None":
del ka[k]
return self._api_request(ka, "/playlist/getFeatured", useGet=True)
def playlist_getUserPlaylists(self, **ka):
self._check_ka(ka, [], ["user_id", "username", "order", "offset", "limit"])
if not "user_id" in ka and not "username" in ka:
ka["user_id"] = self.user_id
return self._api_request(ka, "/playlist/getUserPlaylists")
def artist_getSimilarArtists(self, **ka):
self._check_ka(ka, ["artist_id"], ["limit", "offset"])
return self._api_request(ka, "/artist/getSimilarArtists", useGet=True)
def artist_get(self, **ka):
self._check_ka(ka, ["artist_id"], ["extra", "limit", "offset"])
return self._api_request(ka, "/artist/get", useGet=True)
def genre_list(self, **ka):
self._check_ka(ka, [], ["parent_id", "limit", "offset"])
return self._api_request(ka, "/genre/list")
def label_list(self, **ka):
self._check_ka(ka, [], ["limit", "offset"])
return self._api_request(ka, "/label/list")
def catalog_search(self, **ka):
# type may be 'tracks', 'albums', 'artists' or 'playlists'
self._check_ka(ka, ["query"], ["type", "offset", "limit"])
return self._api_request(ka, "/catalog/search", useGet=True)
#### 2024-10 Except for search the /catalog/ methods still work but they're not used by the site
#### afaics, replaced by /albums/getFeatured, /playlists/getFeatured
def catalog_getFeatured(self, **ka):
return self._api_request(ka, "/catalog/getFeatured")
def catalog_getFeaturedTypes(self, **ka):
return self._api_request(ka, "/catalog/getFeaturedTypes")

View File

@@ -0,0 +1,45 @@
#!/bin/bash
# Script pour comparer Python vs Rust
echo "=== Qobuz API Comparison Test ==="
echo ""
# Check if fake server should be used
USE_FAKE=${1:-no}
if [ "$USE_FAKE" == "fake" ]; then
echo "Mode: FAKE SERVER (debugging)"
echo "1. Starting fake Qobuz server on port 8080..."
# Start fake server
python3 fake_qobuz_server.py &
SERVER_PID=$!
echo " Server PID: $SERVER_PID"
sleep 2
# Patch raw.py
echo "2. Patching raw.py to use localhost..."
python3 patch_for_fake.py
echo "3. Ready to test!"
echo ""
echo "Now run: python3 test_qobuz.py"
echo ""
echo "When done, press Enter to stop server and restore..."
read
# Cleanup
echo "Stopping server..."
kill $SERVER_PID 2>/dev/null
echo "Restoring raw.py..."
python3 patch_for_fake.py restore
echo "Done!"
else
echo "Mode: REAL API"
echo ""
echo "This will test against the real Qobuz API."
echo "You'll need to enter your password."
echo ""
python3 test_qobuz.py
fi

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""
Affiche tous les secrets du Spoofer Python
"""
import sys
sys.path.insert(0, '.')
from spoofbuz import Spoofer
def main():
print("=== Python Spoofer Secrets ===\n")
spoofer = Spoofer()
# App ID
app_id = spoofer.getAppId()
print(f"App ID: {app_id}\n")
# Tous les secrets
secrets = spoofer.getSecrets()
print(f"Number of secrets: {len(secrets)}\n")
for i, (tz, secret) in enumerate(secrets.items(), 1):
print(f"Secret {i} (timezone: {tz}):")
print(f" Full value: {secret}")
print(f" Length: {len(secret)}")
print()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,59 @@
import base64
import re
from collections import OrderedDict
import requests
class Spoofer:
def __init__(self):
self.seed_timezone_regex = (
r'[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[a-z]+)\)'
)
# note: {timezones} should be replaced with every capitalized timezone joined by a |
self.info_extras_regex = r'name:"\w+/(?P<timezone>{timezones})",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)"'
self.appId_regex = r'production:{api:{appId:"(?P<app_id>\d{9})",appSecret:"(?P<secret>\w{32})"},braze:.\(.\({},.\),{},{apiKey:"([-0-9a-fA-F]{36})"}\),extra:.}'
login_page_request = requests.get("https://play.qobuz.com/login")
login_page = login_page_request.text
bundle_url_match = re.search(
r'<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>',
login_page,
)
bundle_url = bundle_url_match.group(1)
bundle_req = requests.get("https://play.qobuz.com" + bundle_url)
self.bundle = bundle_req.text
def getAppId(self):
return re.search(self.appId_regex, self.bundle).group("app_id")
def getSecrets(self):
seed_matches = re.finditer(self.seed_timezone_regex, self.bundle)
secrets = OrderedDict()
for match in seed_matches:
seed, timezone = match.group("seed", "timezone")
secrets[timezone] = [seed]
"""The code that follows switches around the first and second timezone. Why? Read on:
Qobuz uses two ternary (a shortened if statement) conditions that should always return false.
The way Javascript's ternary syntax works, the second option listed is what runs if the condition returns false.
Because of this, we must prioritize the *second* seed/timezone pair captured, not the first.
"""
keypairs = list(secrets.items())
secrets.move_to_end(keypairs[1][0], last=False)
info_extras_regex = self.info_extras_regex.format(
timezones="|".join([timezone.capitalize() for timezone in secrets])
)
info_extras_matches = re.finditer(info_extras_regex, self.bundle)
for match in info_extras_matches:
timezone, info, extras = match.group("timezone", "info", "extras")
secrets[timezone.lower()] += [info, extras]
for secret_pair in secrets:
secrets[secret_pair] = base64.standard_b64decode(
"".join(secrets[secret_pair])[:-44]
).decode("utf-8")
return secrets
if __name__ == "__main__":
spoofer = Spoofer()
print("%s" % spoofer.getSecrets())
print("%s" % spoofer.getAppId())

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""
Test simplifié - va directement à track_getFileUrl
Pour utiliser avec le fake server
"""
import sys
import getpass
from raw import RawApi
def main():
print("=== Test track_getFileUrl (Python) ===\n")
# Credentials
username = input("Qobuz username (default: eric@coissac.eu): ").strip() or "eric@coissac.eu"
password = getpass.getpass("Qobuz password: ")
if not password:
print("Error: password required")
return 1
# Track ID connu (récupéré du test précédent)
track_id = "19557883"
format_id = 27
print("1. Creating RawApi (auto-initializes Spoofer)...")
api = RawApi(appid=None, configvalue=None)
print(f" App ID: {api.appid}")
print("\n2. Logging in...")
login_result = api.user_login(username=username, password=password)
if not login_result:
print(f" ✗ Login failed: {api.error}")
return 1
print(f" ✓ Login successful - User ID: {api.user_id}")
print(f" Token: {api.user_auth_token[:20]}...")
# Appel direct à track_getFileUrl
print(f"\n3. Calling track_getFileUrl...")
print(f" Track ID: {track_id}")
print(f" Format ID: {format_id}")
print(f" ⚠️ THIS CALL IS SIGNED - Watch fake server logs!\n")
file_url_data = api.track_getFileUrl(track_id=track_id, format_id=format_id)
if not file_url_data:
print(f" ✗ Failed: {api.error}")
print(f" Status code: {api.status_code}")
return 1
url = file_url_data.get('url', '')
mime_type = file_url_data.get('mime_type', '')
print(f" ✓ Success!")
print(f" URL: {url[:80]}...")
print(f" MIME type: {mime_type}")
print("\n=== Test completed successfully! ===")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""
Script de test pour Qobuz API
Compare le comportement Python vs Rust
"""
import sys
import getpass
from spoofbuz import Spoofer
from raw import RawApi
def main():
print("=== Test Qobuz API (Python) ===\n")
# Obtenir les credentials
username = input("Qobuz username (default: eric@coissac.eu): ").strip() or "eric@coissac.eu"
password = getpass.getpass("Qobuz password: ")
if not password:
print("Error: password required")
return 1
print("\n1. Creating RawApi (will auto-initialize Spoofer)...")
# Si on ne passe pas appid/configvalue, il crée automatiquement le spoofer
api = RawApi(appid=None, configvalue=None)
print(f" App ID: {api.appid}")
print("\n2. Logging in...")
login_result = api.user_login(username=username, password=password)
if not login_result:
print(f" ✗ Login failed: {api.error}")
return 1
print(f" ✓ Login successful - User ID: {api.user_id}")
print("\n3. Getting favorite albums...")
albums = api.favorite_getUserFavorites(type="albums", limit="10")
if not albums:
print(f" ✗ Failed: {api.error}")
return 1
album_count = albums.get('albums', {}).get('total', 0)
print(f" ✓ Found {album_count} favorite albums")
# Get first album
items = albums.get('albums', {}).get('items', [])
if not items:
print(" No albums in favorites")
return 1
first_album = items[0]
album_id = first_album.get('id')
album_title = first_album.get('title', 'Unknown')
album_artist = first_album.get('artist', {}).get('name', 'Unknown')
print(f"\n4. First album: {album_artist} - {album_title}")
print(f" Album ID: {album_id}")
# Get album tracks
print("\n5. Getting album tracks...")
album_data = api.album_get(album_id=album_id)
if not album_data:
print(f" ✗ Failed: {api.error}")
return 1
tracks = album_data.get('tracks', {}).get('items', [])
if not tracks:
print(" No tracks in album")
return 1
first_track = tracks[0]
track_id = first_track.get('id')
track_title = first_track.get('title', 'Unknown')
print(f" ✓ Found {len(tracks)} tracks")
print(f" First track: {track_title} (ID: {track_id})")
# Get file URL (this is where signature is needed!)
print("\n6. Getting file URL for first track...")
print(" ⚠️ This call requires signature validation")
file_url_data = api.track_getFileUrl(track_id=track_id, format_id=27)
if not file_url_data:
print(f" ✗ Failed: {api.error}")
print(f" Status code: {api.status_code}")
return 1
url = file_url_data.get('url', '')
mime_type = file_url_data.get('mime_type', '')
print(f" ✓ Success!")
print(f" URL: {url[:80]}...")
print(f" MIME type: {mime_type}")
print("\n=== Test completed successfully! ===")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""
Test de signature MD5 avec timestamp fixe
Pour comparer avec Rust
"""
import hashlib
import sys
sys.path.insert(0, '.')
from spoofbuz import Spoofer
def main():
print("=== Test de signature MD5 (Python) ===\n")
# Récupérer le secret
print("1. Getting secret from Spoofer...")
spoofer = Spoofer()
secrets = spoofer.getSecrets()
# Utiliser le premier secret disponible
app_secret = list(secrets.values())[0]
print(f" ✓ Secret retrieved (length: {len(app_secret)} chars)")
# Paramètres du test
track_id = "19557883"
format_id = "27"
intent = "stream"
# TIMESTAMP FIXE pour comparaison
timestamp = "1234567890.123456"
print("\n2. Computing signature with FIXED timestamp:")
print(f" track_id: {track_id}")
print(f" format_id: {format_id}")
print(f" intent: {intent}")
print(f" timestamp: {timestamp}")
print(f" secret: {app_secret[:10]}... (first 10 chars)")
# Calculer la signature (même logique que raw.py)
stringvalue = (
"trackgetFileUrlformat_id" + format_id +
"intent" + intent +
"track_id" + track_id +
timestamp
)
stringvalue = stringvalue.encode("ASCII")
stringvalue += app_secret.encode("utf-8")
signature = hashlib.md5(stringvalue).hexdigest()
print("\n3. Result:")
print(f" Signature: {signature}")
print("\n✓ Compare this signature with Rust output")
if __name__ == "__main__":
main()