Première version testable avec la construction d'une image docker #22
12
pmoqobuz/.pmomusic/config.yaml
Normal file
12
pmoqobuz/.pmomusic/config.yaml
Normal 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
|
||||
@@ -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
|
||||
|
||||
47
pmoqobuz/examples/show_secrets.rs
Normal file
47
pmoqobuz/examples/show_secrets.rs
Normal 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(())
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
64
pmoqobuz/examples/test_getfileurl.rs
Normal file
64
pmoqobuz/examples/test_getfileurl.rs
Normal 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(())
|
||||
}
|
||||
57
pmoqobuz/examples/test_signature.rs
Normal file
57
pmoqobuz/examples/test_signature.rs
Normal 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(())
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
195
pmoqobuz/test_python_qobuz/COMPARISON_GUIDE.md
Normal file
195
pmoqobuz/test_python_qobuz/COMPARISON_GUIDE.md
Normal 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
|
||||
158
pmoqobuz/test_python_qobuz/INSTRUCTIONS.md
Normal file
158
pmoqobuz/test_python_qobuz/INSTRUCTIONS.md
Normal 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
|
||||
```
|
||||
65
pmoqobuz/test_python_qobuz/README.md
Normal file
65
pmoqobuz/test_python_qobuz/README.md
Normal 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
|
||||
68
pmoqobuz/test_python_qobuz/START_HERE.md
Normal file
68
pmoqobuz/test_python_qobuz/START_HERE.md
Normal 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!
|
||||
BIN
pmoqobuz/test_python_qobuz/__pycache__/raw.cpython-312.pyc
Normal file
BIN
pmoqobuz/test_python_qobuz/__pycache__/raw.cpython-312.pyc
Normal file
Binary file not shown.
BIN
pmoqobuz/test_python_qobuz/__pycache__/spoofbuz.cpython-312.pyc
Normal file
BIN
pmoqobuz/test_python_qobuz/__pycache__/spoofbuz.cpython-312.pyc
Normal file
Binary file not shown.
146
pmoqobuz/test_python_qobuz/fake_qobuz_server.py
Executable file
146
pmoqobuz/test_python_qobuz/fake_qobuz_server.py
Executable 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()
|
||||
53
pmoqobuz/test_python_qobuz/patch_for_fake.py
Executable file
53
pmoqobuz/test_python_qobuz/patch_for_fake.py
Executable 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")
|
||||
79
pmoqobuz/test_python_qobuz/quick_compare.sh
Executable file
79
pmoqobuz/test_python_qobuz/quick_compare.sh
Executable 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
342
pmoqobuz/test_python_qobuz/raw.py
Executable 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")
|
||||
45
pmoqobuz/test_python_qobuz/run_comparison.sh
Executable file
45
pmoqobuz/test_python_qobuz/run_comparison.sh
Executable 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
|
||||
30
pmoqobuz/test_python_qobuz/show_secrets.py
Normal file
30
pmoqobuz/test_python_qobuz/show_secrets.py
Normal 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()
|
||||
59
pmoqobuz/test_python_qobuz/spoofbuz.py
Executable file
59
pmoqobuz/test_python_qobuz/spoofbuz.py
Executable 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())
|
||||
62
pmoqobuz/test_python_qobuz/test_getfileurl.py
Executable file
62
pmoqobuz/test_python_qobuz/test_getfileurl.py
Executable 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())
|
||||
97
pmoqobuz/test_python_qobuz/test_qobuz.py
Executable file
97
pmoqobuz/test_python_qobuz/test_qobuz.py
Executable 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())
|
||||
55
pmoqobuz/test_python_qobuz/test_signature_fixed.py
Normal file
55
pmoqobuz/test_python_qobuz/test_signature_fixed.py
Normal 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()
|
||||
Reference in New Issue
Block a user