From d2f6abf4bb63bdfc88dcd26aef4348c86bdc19af Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 13 Dec 2025 13:57:20 +0100 Subject: [PATCH] Big Debug of PMOQobuz step 3 --- pmoqobuz/.pmomusic/config.yaml | 12 + pmoqobuz/examples/lazy_loading.rs | 4 + pmoqobuz/examples/show_secrets.rs | 47 +++ pmoqobuz/examples/spoofer.rs | 25 +- pmoqobuz/examples/test_getfileurl.rs | 64 ++++ pmoqobuz/examples/test_signature.rs | 57 +++ pmoqobuz/src/api/auth.rs | 12 +- pmoqobuz/src/api/catalog.rs | 17 +- pmoqobuz/src/api/mod.rs | 38 +- pmoqobuz/src/api/spoofer.rs | 17 + pmoqobuz/src/api/user.rs | 2 +- pmoqobuz/src/client.rs | 75 ++-- .../test_python_qobuz/COMPARISON_GUIDE.md | 195 ++++++++++ pmoqobuz/test_python_qobuz/INSTRUCTIONS.md | 158 ++++++++ pmoqobuz/test_python_qobuz/README.md | 65 ++++ pmoqobuz/test_python_qobuz/START_HERE.md | 68 ++++ .../__pycache__/raw.cpython-312.pyc | Bin 0 -> 16676 bytes .../__pycache__/spoofbuz.cpython-312.pyc | Bin 0 -> 3687 bytes .../test_python_qobuz/fake_qobuz_server.py | 146 ++++++++ pmoqobuz/test_python_qobuz/patch_for_fake.py | 53 +++ pmoqobuz/test_python_qobuz/quick_compare.sh | 79 ++++ pmoqobuz/test_python_qobuz/raw.py | 342 ++++++++++++++++++ pmoqobuz/test_python_qobuz/run_comparison.sh | 45 +++ pmoqobuz/test_python_qobuz/show_secrets.py | 30 ++ pmoqobuz/test_python_qobuz/spoofbuz.py | 59 +++ pmoqobuz/test_python_qobuz/test_getfileurl.py | 62 ++++ pmoqobuz/test_python_qobuz/test_qobuz.py | 97 +++++ .../test_python_qobuz/test_signature_fixed.py | 55 +++ 28 files changed, 1765 insertions(+), 59 deletions(-) create mode 100644 pmoqobuz/.pmomusic/config.yaml create mode 100644 pmoqobuz/examples/show_secrets.rs create mode 100644 pmoqobuz/examples/test_getfileurl.rs create mode 100644 pmoqobuz/examples/test_signature.rs create mode 100644 pmoqobuz/test_python_qobuz/COMPARISON_GUIDE.md create mode 100644 pmoqobuz/test_python_qobuz/INSTRUCTIONS.md create mode 100644 pmoqobuz/test_python_qobuz/README.md create mode 100644 pmoqobuz/test_python_qobuz/START_HERE.md create mode 100644 pmoqobuz/test_python_qobuz/__pycache__/raw.cpython-312.pyc create mode 100644 pmoqobuz/test_python_qobuz/__pycache__/spoofbuz.cpython-312.pyc create mode 100755 pmoqobuz/test_python_qobuz/fake_qobuz_server.py create mode 100755 pmoqobuz/test_python_qobuz/patch_for_fake.py create mode 100755 pmoqobuz/test_python_qobuz/quick_compare.sh create mode 100755 pmoqobuz/test_python_qobuz/raw.py create mode 100755 pmoqobuz/test_python_qobuz/run_comparison.sh create mode 100644 pmoqobuz/test_python_qobuz/show_secrets.py create mode 100755 pmoqobuz/test_python_qobuz/spoofbuz.py create mode 100755 pmoqobuz/test_python_qobuz/test_getfileurl.py create mode 100755 pmoqobuz/test_python_qobuz/test_qobuz.py create mode 100644 pmoqobuz/test_python_qobuz/test_signature_fixed.py diff --git a/pmoqobuz/.pmomusic/config.yaml b/pmoqobuz/.pmomusic/config.yaml new file mode 100644 index 00000000..ae069315 --- /dev/null +++ b/pmoqobuz/.pmomusic/config.yaml @@ -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 diff --git a/pmoqobuz/examples/lazy_loading.rs b/pmoqobuz/examples/lazy_loading.rs index 32aeb515..df1c2e52 100644 --- a/pmoqobuz/examples/lazy_loading.rs +++ b/pmoqobuz/examples/lazy_loading.rs @@ -45,6 +45,10 @@ async fn main() -> Result<(), Box> { 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 diff --git a/pmoqobuz/examples/show_secrets.rs b/pmoqobuz/examples/show_secrets.rs new file mode 100644 index 00000000..6773bdea --- /dev/null +++ b/pmoqobuz/examples/show_secrets.rs @@ -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(()) +} diff --git a/pmoqobuz/examples/spoofer.rs b/pmoqobuz/examples/spoofer.rs index cccb9ed9..fc8f4df1 100644 --- a/pmoqobuz/examples/spoofer.rs +++ b/pmoqobuz/examples/spoofer.rs @@ -92,6 +92,20 @@ impl Spoofer { .to_string()) } + /// Extrait l'appSecret depuis le bundle (secret à 32 caractères) + fn get_app_secret(&self) -> Result { + 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> { // É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 { diff --git a/pmoqobuz/examples/test_getfileurl.rs b/pmoqobuz/examples/test_getfileurl.rs new file mode 100644 index 00000000..2a30860e --- /dev/null +++ b/pmoqobuz/examples/test_getfileurl.rs @@ -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(()) +} diff --git a/pmoqobuz/examples/test_signature.rs b/pmoqobuz/examples/test_signature.rs new file mode 100644 index 00000000..d043c011 --- /dev/null +++ b/pmoqobuz/examples/test_signature.rs @@ -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(()) +} diff --git a/pmoqobuz/src/api/auth.rs b/pmoqobuz/src/api/auth.rs index 59520cde..6893fabf 100644 --- a/pmoqobuz/src/api/auth.rs +++ b/pmoqobuz/src/api/auth.rs @@ -72,7 +72,13 @@ impl QobuzApi { let params = [("username", username), ("password", password)]; - let response: LoginResponse = self.post("/user/login", ¶ms).await?; + let response: LoginResponse = match self.post("/user/login", ¶ms).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 ); diff --git a/pmoqobuz/src/api/catalog.rs b/pmoqobuz/src/api/catalog.rs index d0c730f4..f83f4736 100644 --- a/pmoqobuz/src/api/catalog.rs +++ b/pmoqobuz/src/api/catalog.rs @@ -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", ¶ms).await?; + // Utiliser POST (comme Python) + let response: FileUrlResponse = self.post("/track/getFileUrl", ¶ms).await?; Ok(StreamInfo { url: response.url, diff --git a/pmoqobuz/src/api/mod.rs b/pmoqobuz/src/api/mod.rs index 863f50ba..4284f4ec 100644 --- a/pmoqobuz/src/api/mod.rs +++ b/pmoqobuz/src/api/mod.rs @@ -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, raw_secret: &str) -> Result { + 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, 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, 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 { 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(&self, response: Response) -> Result { + async fn handle_response(&self, response: Response, endpoint: &str) -> Result { 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(), diff --git a/pmoqobuz/src/api/spoofer.rs b/pmoqobuz/src/api/spoofer.rs index 942314e1..5ef3ffa1 100644 --- a/pmoqobuz/src/api/spoofer.rs +++ b/pmoqobuz/src/api/spoofer.rs @@ -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 { + 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> { // Étape 1: Extraire tous les seed/timezone pairs diff --git a/pmoqobuz/src/api/user.rs b/pmoqobuz/src/api/user.rs index 6d0c80cf..cfa8cbfe 100644 --- a/pmoqobuz/src/api/user.rs +++ b/pmoqobuz/src/api/user.rs @@ -15,7 +15,7 @@ struct PaginatedResponse { /// Réponse de l'endpoint /favorite/getUserFavorites #[derive(Debug, Deserialize)] -struct FavoritesResponse { +pub(crate) struct FavoritesResponse { #[serde(default)] albums: Option>, #[serde(default)] diff --git a/pmoqobuz/src/client.rs b/pmoqobuz/src/client.rs index 50446284..64b37499 100644 --- a/pmoqobuz/src/client.rs +++ b/pmoqobuz/src/client.rs @@ -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 { 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> { 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(()) } diff --git a/pmoqobuz/test_python_qobuz/COMPARISON_GUIDE.md b/pmoqobuz/test_python_qobuz/COMPARISON_GUIDE.md new file mode 100644 index 00000000..369d3846 --- /dev/null +++ b/pmoqobuz/test_python_qobuz/COMPARISON_GUIDE.md @@ -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 diff --git a/pmoqobuz/test_python_qobuz/INSTRUCTIONS.md b/pmoqobuz/test_python_qobuz/INSTRUCTIONS.md new file mode 100644 index 00000000..71913027 --- /dev/null +++ b/pmoqobuz/test_python_qobuz/INSTRUCTIONS.md @@ -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 +``` diff --git a/pmoqobuz/test_python_qobuz/README.md b/pmoqobuz/test_python_qobuz/README.md new file mode 100644 index 00000000..705f863e --- /dev/null +++ b/pmoqobuz/test_python_qobuz/README.md @@ -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 diff --git a/pmoqobuz/test_python_qobuz/START_HERE.md b/pmoqobuz/test_python_qobuz/START_HERE.md new file mode 100644 index 00000000..c09ff875 --- /dev/null +++ b/pmoqobuz/test_python_qobuz/START_HERE.md @@ -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! diff --git a/pmoqobuz/test_python_qobuz/__pycache__/raw.cpython-312.pyc b/pmoqobuz/test_python_qobuz/__pycache__/raw.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a54bd7d8bcea387182219fd051f703dc80f8384 GIT binary patch literal 16676 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 literal 0 HcmV?d00001 diff --git a/pmoqobuz/test_python_qobuz/__pycache__/spoofbuz.cpython-312.pyc b/pmoqobuz/test_python_qobuz/__pycache__/spoofbuz.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d4190899f90ef3c4dd8ce33cefc9c0bea9898fa5 GIT binary patch literal 3687 zcmcInO>7&-6`o!0lFJ|cTGT&nijo|yC6TS=+LkPUbt4A3srHi1NSOLJG!(j=Fj zT~;PV0y!8cjiiW#9%P{hJEtmefcoh6(xBH~L{ll5jSCdWp$O0eYXvCki~DAmOFDv( zoH`KSzIpTBn>TM}-Z%PdON$FZ`|+>m+JCsSt?8Mj<1MLn5T62O)N@S60gcJE%v65`V{E!BuICVgvj!i zXpy>)mTV#olp)JnVzUg->W;;$+}&E%2RP;x0<7bE6`etC5g9MT=OfkdD_~If-P^0cQnT=PP8{> zVZI|CN1n-nSwo-Av&TgRD|A}kvxw)yxm(N8$=pgLD5qpijwTi*NsNpI#(sEfRt0u$ zIkItiWtpL0+EgJ&^I(TEnT$$ z{^ZoEGGoj`xaK_4Jq<@ybHwBuW4Q-utcaPICM&6#&1hOCBz;{pm4OvxaV{cmP80vo zU6f*2(#%oS;O8P6n^R|DxG-@WM|09lFfs;zn}vyBgr9&9@p?nbg3a>e_a>*KldD%I zudQrOohLS+Vnj3(EaV2)H7%{qghJ^=G#k9D+|J~JF(nyFC~I;mw=k>5u$Rx)Yb zZpOshbw-sEt9qxgI3XQflfaYVLU%P~db_DFWN<=AMm2EcYDyYBSgKGzkl$#pRzg2h zC9H;Gima;9SZFbuiiMIYN~ulPw6j_Ih~;S`u;*)cZi*L;vsyH5+oa9B}>`hXMtv$Q*DdYcv{M zrwQBp!Z-RTvK!eyvrzV3-{<iPywn5iE*9%^{ zPglI-CGYs&m9jSoYF^(?WIM7Sn0dBT3e1@cZT zxE%;j;ZFFdBs@Rv1*&s!s;-cW+03~m=_jgyI*{`hZ5f+AHR3*50^f^ zRBpRaX}er%yZmgu+%~_@&0}Kmjl7Vkjf(y+$ix35F9k|#WX8g;0-dL>q7@(1byBO{ z5W|rsQevZ?M>@4VhaEd`r)Wn259Vgj5(yaNcT`1T=?-*pr+eKR+ z*ourn*-z}&u2Kkgo$Sbw7C#1^MMu2nh#GYB^i}jB8>81yj9vjxunsm(ra98AShwj9 zx){(cuvXNca28i^e8;(UeES(KaCr{$e*RnXPK!>_^@N-VXc71p*n+d*B6*Jwn9?_? z74<#(9!(aNcjjGdbPljOW{c6rPF-Y-9IVWNFSiDJZnijig&b+3`}hpz-Nd(*1$dKr z`WwCY&=Dul65^7vDj==g)~!Oc4Qb%rEaTiiqwp;LN}dN!1-DXsbGFNbo5VlAp}U-I22CsEk!J zgcEN9Ufmjx5baT-?Kb@YaDjyfBqsEj)GS#pC{X5eLCtkqK+%+uGoWH9aZt8;`gS(A zHy@v?3|}e@UwZb_KQI4rx!iNT=y}oU`|V`WQS}bJM6~m*;@eeU;L-B#@?Q7T{<3fO zLAZEzYpgmn`q*Ba-&%O?@zq#RtJxW+=W7@0>V4>V)Uw<1ySDO)u_9OH+A7>Yi5qyh z@c1XYH})X^HujjS_@_$# zsn7gBcz!Be?!F1Q=9@W0v=3mZ?`u0cHT3At?w$QJR^%rE&IYR9F%t7dhJUH9Hy{ zeu-?(lf@e^hWX0yY-xD5+TOY2*mgX0Ja(6QC(7-Ud*YtD-#&F{qeoi*+1mABb?cq? zl^Tm$y0&gS_xNizum|jH?UU9SLGUo6V7$7jJ{sKtNMp4uksO1af{5u-{61DxuK1ucb8v&{Pl0uB9n0s#P z`@GfrzWm<&=Zv#Bvei@R7%p`TmpevFuF(VLbVE8+>KH1+m}~emW~AmoOusSz{{Y|J BQ%V2; literal 0 HcmV?d00001 diff --git a/pmoqobuz/test_python_qobuz/fake_qobuz_server.py b/pmoqobuz/test_python_qobuz/fake_qobuz_server.py new file mode 100755 index 00000000..9660b3c6 --- /dev/null +++ b/pmoqobuz/test_python_qobuz/fake_qobuz_server.py @@ -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() diff --git a/pmoqobuz/test_python_qobuz/patch_for_fake.py b/pmoqobuz/test_python_qobuz/patch_for_fake.py new file mode 100755 index 00000000..27802a78 --- /dev/null +++ b/pmoqobuz/test_python_qobuz/patch_for_fake.py @@ -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") diff --git a/pmoqobuz/test_python_qobuz/quick_compare.sh b/pmoqobuz/test_python_qobuz/quick_compare.sh new file mode 100755 index 00000000..567002a3 --- /dev/null +++ b/pmoqobuz/test_python_qobuz/quick_compare.sh @@ -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 "" diff --git a/pmoqobuz/test_python_qobuz/raw.py b/pmoqobuz/test_python_qobuz/raw.py new file mode 100755 index 00000000..c5eb198f --- /dev/null +++ b/pmoqobuz/test_python_qobuz/raw.py @@ -0,0 +1,342 @@ +""" + qobuz.api.raw + ~~~~~~~~~~~~~ + + Our base api, all method are mapped like in _ + 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") diff --git a/pmoqobuz/test_python_qobuz/run_comparison.sh b/pmoqobuz/test_python_qobuz/run_comparison.sh new file mode 100755 index 00000000..f7fa33f0 --- /dev/null +++ b/pmoqobuz/test_python_qobuz/run_comparison.sh @@ -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 diff --git a/pmoqobuz/test_python_qobuz/show_secrets.py b/pmoqobuz/test_python_qobuz/show_secrets.py new file mode 100644 index 00000000..5b83b05a --- /dev/null +++ b/pmoqobuz/test_python_qobuz/show_secrets.py @@ -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() diff --git a/pmoqobuz/test_python_qobuz/spoofbuz.py b/pmoqobuz/test_python_qobuz/spoofbuz.py new file mode 100755 index 00000000..9c85c083 --- /dev/null +++ b/pmoqobuz/test_python_qobuz/spoofbuz.py @@ -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[\w=]+)",window\.utimezone\.(?P[a-z]+)\)' + ) + # note: {timezones} should be replaced with every capitalized timezone joined by a | + self.info_extras_regex = r'name:"\w+/(?P{timezones})",info:"(?P[\w=]+)",extras:"(?P[\w=]+)"' + self.appId_regex = r'production:{api:{appId:"(?P\d{9})",appSecret:"(?P\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'', + 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()) diff --git a/pmoqobuz/test_python_qobuz/test_getfileurl.py b/pmoqobuz/test_python_qobuz/test_getfileurl.py new file mode 100755 index 00000000..fdf33a8e --- /dev/null +++ b/pmoqobuz/test_python_qobuz/test_getfileurl.py @@ -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()) diff --git a/pmoqobuz/test_python_qobuz/test_qobuz.py b/pmoqobuz/test_python_qobuz/test_qobuz.py new file mode 100755 index 00000000..e747298e --- /dev/null +++ b/pmoqobuz/test_python_qobuz/test_qobuz.py @@ -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()) diff --git a/pmoqobuz/test_python_qobuz/test_signature_fixed.py b/pmoqobuz/test_python_qobuz/test_signature_fixed.py new file mode 100644 index 00000000..c16ea4a7 --- /dev/null +++ b/pmoqobuz/test_python_qobuz/test_signature_fixed.py @@ -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()