diff --git a/Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md b/Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md index 5bf6a911..4b6f688d 100644 --- a/Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md +++ b/Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md @@ -22,7 +22,7 @@ CMAF (Common Media Application Format) : segments AES-CTR chiffrés sur CDN Akam --- -## 2. Extraction du bundle Qobuz — **À FAIRE** (priorité haute) +## 2. Extraction du bundle Qobuz — **Fait** **Problème** : `pmoqobuz` utilise un `app_id` et un `configvalue` statiques, hardcodés ou configurés manuellement. Qobuz peut les invalider à tout moment en changeant son bundle JS. @@ -112,7 +112,7 @@ sortis récemment. Utile pour le catalogue de la webapp. | # | Amélioration | Effort | Impact | État | |---|---|---|---|---| | 1 | Streaming CMAF | Élevé | Critique (pipeline futur) | **Fait** | -| 2 | Bundle extraction avec cache disque | Moyen | Élevé (résilience) | À faire | +| 2 | Bundle extraction avec cache disque | Moyen | Élevé (résilience) | **Fait** | | 3 | Batch `track/getList` | Faible | Élevé (performances) | À faire | | 4 | Pagination concurrente playlists | Faible | Moyen | À faire | | 5 | Release watch endpoint | Faible | Faible (catalogue) | À faire | diff --git a/pmoqobuz/src/api/spoofer.rs b/pmoqobuz/src/api/spoofer.rs index 5ef3ffa1..81cc038a 100644 --- a/pmoqobuz/src/api/spoofer.rs +++ b/pmoqobuz/src/api/spoofer.rs @@ -3,36 +3,40 @@ use base64::{engine::general_purpose::STANDARD, Engine}; use indexmap::IndexMap; use regex::Regex; use reqwest::Client; +use std::time::Duration; +use tracing::{debug, info, warn}; + +/// Timeout par requête HTTP — le bundle fait ~7 MB et le CDN Qobuz peut être lent. +const BUNDLE_FETCH_TIMEOUT: Duration = Duration::from_secs(45); + +/// Tentatives supplémentaires après un échec d'extraction. +const BUNDLE_EXTRACTION_RETRIES: usize = 2; pub struct Spoofer { bundle: String, + /// Version du bundle extrait, ex. `"8.1.0-b019"`. + bundle_version: String, seed_timezone_regex: Regex, info_extras_regex_template: String, app_id_regex: Regex, } impl Spoofer { - /// Crée un nouveau Spoofer et télécharge le bundle.js - pub async fn new() -> Result { - // Expressions régulières (équivalent Python) - let seed_timezone_regex = Regex::new( - r#"[a-z]\.initialSeed\("(?P[\w=]+)",window\.utimezone\.(?P[a-z]+)\)"#, - )?; + /// Version du bundle Qobuz actuellement chargé. + pub fn bundle_version(&self) -> &str { + &self.bundle_version + } - let info_extras_regex_template = - r#"name:"\w+/(?P{timezones})",info:"(?P[\w=]+)",extras:"(?P[\w=]+)""# - .to_string(); - - let app_id_regex = Regex::new( - r#"production:\{api:\{appId:"(?P\d{9})",appSecret:"(?P\w{32})"\},braze:.\(.\(\{\},.\),\{\},\{apiKey:"([-0-9a-fA-F]{36})"\}\),extra:.\}"#, - )?; - - // Créer un client HTTP + /// Récupère uniquement la version du bundle courant sans télécharger les 7 MB. + /// + /// Utile pour savoir si le bundle a changé avant de déclencher une extraction + /// complète. Ne télécharge que la page de login (~5 KB). + pub async fn fetch_current_bundle_version() -> Result { let client = Client::builder() .user_agent("Mozilla/5.0 (compatible; PMOMusic/1.0)") + .timeout(BUNDLE_FETCH_TIMEOUT) .build()?; - println!("Récupération de la page de login..."); let login_page = client .get("https://play.qobuz.com/login") .send() @@ -40,31 +44,80 @@ impl Spoofer { .text() .await?; - // Extraire l'URL du bundle - let bundle_url_regex = Regex::new( - r#""#, + let re = Regex::new( + r#""#, )?; - let bundle_url = bundle_url_regex - .captures(&login_page) + re.captures(&login_page) .and_then(|cap| cap.get(1)) - .ok_or_else(|| anyhow::anyhow!("Impossible de trouver l'URL du bundle"))? - .as_str(); - - println!("Téléchargement du bundle depuis: {}", bundle_url); - let bundle_full_url = format!("https://play.qobuz.com{}", bundle_url); - let bundle = client.get(&bundle_full_url).send().await?.text().await?; - - println!("Bundle téléchargé ({} bytes)", bundle.len()); - - Ok(Self { - bundle, - seed_timezone_regex, - info_extras_regex_template, - app_id_regex, - }) + .map(|m| m.as_str().to_string()) + .ok_or_else(|| anyhow::anyhow!("Version bundle introuvable dans la page de login")) } - /// Extrait l'App ID depuis le bundle + /// Télécharge et parse le bundle Qobuz. Retry jusqu'à `BUNDLE_EXTRACTION_RETRIES` + /// fois en cas d'échec réseau ou d'extraction. + pub async fn new() -> Result { + let seed_timezone_regex = Regex::new( + r#"[a-z]\.initialSeed\("(?P[\w=]+)",window\.utimezone\.(?P[a-z]+)\)"#, + )?; + let info_extras_regex_template = + r#"name:"\w+/(?P{timezones})",info:"(?P[\w=]+)",extras:"(?P[\w=]+)""# + .to_string(); + let app_id_regex = Regex::new( + r#"production:\{api:\{appId:"(?P\d{9})",appSecret:"(?P\w{32})"\},braze:.\(.\(\{\},.\),\{\},\{apiKey:"([-0-9a-fA-F]{36})"\}\),extra:.\}"#, + )?; + + let client = Client::builder() + .user_agent("Mozilla/5.0 (compatible; PMOMusic/1.0)") + .timeout(BUNDLE_FETCH_TIMEOUT) + .build()?; + + let mut last_err: Option = None; + for attempt in 1..=(BUNDLE_EXTRACTION_RETRIES + 1) { + match Self::fetch_bundle(&client).await { + Ok((bundle, bundle_version)) => { + info!("[Spoofer] Bundle {} téléchargé ({} bytes)", bundle_version, bundle.len()); + return Ok(Self { + bundle, + bundle_version, + seed_timezone_regex, + info_extras_regex_template, + app_id_regex, + }); + } + Err(e) => { + warn!("[Spoofer] Tentative {}/{} échouée : {}", attempt, BUNDLE_EXTRACTION_RETRIES + 1, e); + last_err = Some(e); + } + } + } + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("Échec téléchargement bundle"))) + } + + async fn fetch_bundle(client: &Client) -> Result<(String, String)> { + let login_page = client + .get("https://play.qobuz.com/login") + .send() + .await? + .text() + .await?; + + let bundle_url_regex = Regex::new( + r#""#, + )?; + let caps = bundle_url_regex + .captures(&login_page) + .ok_or_else(|| anyhow::anyhow!("URL bundle introuvable dans la page de login"))?; + let bundle_path = caps.get(1).unwrap().as_str(); + let bundle_version = caps.get(2).unwrap().as_str().to_string(); + + let bundle_url = format!("https://play.qobuz.com{}", bundle_path); + debug!("[Spoofer] Téléchargement bundle depuis {}", bundle_url); + + let bundle = client.get(&bundle_url).send().await?.text().await?; + Ok((bundle, bundle_version)) + } + + /// Extrait l'App ID depuis le bundle. pub fn get_app_id(&self) -> Result { let captures = self .app_id_regex @@ -78,10 +131,7 @@ 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 + /// Extrait l'appSecret depuis le bundle (secret MD5 à 32 caractères). pub fn get_app_secret(&self) -> Result { let captures = self .app_id_regex @@ -95,37 +145,25 @@ impl Spoofer { .to_string()) } - /// Extrait les secrets depuis le bundle + /// Extrait les secrets timezone depuis le bundle. pub fn get_secrets(&self) -> Result> { - // Étape 1: Extraire tous les seed/timezone pairs let mut secrets: IndexMap> = IndexMap::new(); for captures in self.seed_timezone_regex.captures_iter(&self.bundle) { - let seed = captures - .name("seed") - .ok_or_else(|| anyhow::anyhow!("Groupe seed non trouvé"))? - .as_str(); - let timezone = captures - .name("timezone") - .ok_or_else(|| anyhow::anyhow!("Groupe timezone non trouvé"))? - .as_str(); - + let seed = captures.name("seed").unwrap().as_str(); + let timezone = captures.name("timezone").unwrap().as_str(); secrets .entry(timezone.to_string()) - .or_insert_with(Vec::new) + .or_default() .push(seed.to_string()); } - println!("Timezones trouvées: {:?}", secrets.keys()); + debug!("[Spoofer] Timezones trouvées : {:?}", secrets.keys().collect::>()); - // Étape 2: Réordonner - on met la deuxième timezone en premier - // (comme le fait le code Python avec move_to_end) if secrets.len() >= 2 { let keys: Vec = secrets.keys().cloned().collect(); let second_key = keys[1].clone(); let second_value = secrets.get(&second_key).unwrap().clone(); - - // Retirer et réinsérer pour le mettre en premier secrets.shift_remove(&second_key); let mut new_secrets = IndexMap::new(); new_secrets.insert(second_key, second_value); @@ -135,7 +173,6 @@ impl Spoofer { secrets = new_secrets; } - // Étape 3: Construire la regex pour info/extras let timezones_capitalized: Vec = secrets .keys() .map(|tz| { @@ -150,24 +187,12 @@ impl Spoofer { let info_extras_regex_str = self .info_extras_regex_template .replace("{timezones}", &timezones_capitalized.join("|")); - let info_extras_regex = Regex::new(&info_extras_regex_str)?; - // Étape 4: Extraire info et extras pour chaque timezone for captures in info_extras_regex.captures_iter(&self.bundle) { - let timezone_cap = captures - .name("timezone") - .ok_or_else(|| anyhow::anyhow!("Groupe timezone non trouvé"))? - .as_str(); - let info = captures - .name("info") - .ok_or_else(|| anyhow::anyhow!("Groupe info non trouvé"))? - .as_str(); - let extras = captures - .name("extras") - .ok_or_else(|| anyhow::anyhow!("Groupe extras non trouvé"))? - .as_str(); - + let timezone_cap = captures.name("timezone").unwrap().as_str(); + let info = captures.name("info").unwrap().as_str(); + let extras = captures.name("extras").unwrap().as_str(); let timezone_lower = timezone_cap.to_lowercase(); if let Some(vec) = secrets.get_mut(&timezone_lower) { vec.push(info.to_string()); @@ -175,31 +200,19 @@ impl Spoofer { } } - // Étape 5: Décoder les secrets en base64 let mut decoded_secrets = IndexMap::new(); for (timezone, parts) in secrets { let concatenated = parts.join(""); - - // Retirer les 44 derniers caractères (comme Python [:-44]) if concatenated.len() > 44 { let trimmed = &concatenated[..concatenated.len() - 44]; - - // Décoder en base64 match STANDARD.decode(trimmed) { Ok(decoded_bytes) => match String::from_utf8(decoded_bytes) { Ok(decoded_str) => { decoded_secrets.insert(timezone, decoded_str); } - Err(e) => { - eprintln!("Erreur UTF-8 pour timezone {}: {}", timezone, e); - } + Err(e) => warn!("[Spoofer] UTF-8 invalide pour timezone {}: {}", timezone, e), }, - Err(e) => { - eprintln!( - "Erreur de décodage base64 pour timezone {}: {}", - timezone, e - ); - } + Err(e) => warn!("[Spoofer] Base64 invalide pour timezone {}: {}", timezone, e), } } } diff --git a/pmoqobuz/src/client.rs b/pmoqobuz/src/client.rs index 3666516e..9fbf0caf 100644 --- a/pmoqobuz/src/client.rs +++ b/pmoqobuz/src/client.rs @@ -236,14 +236,32 @@ impl QobuzClient { /// - Quand aucun appid/secret n'est configuré /// - Quand les credentials configurés sont invalides/expirés async fn try_spoofer_fallback(config: &Config) -> Result { + // Vérification bon marché : si la version du bundle n'a pas changé, + // re-télécharger les 7 MB ne donnera pas de meilleurs secrets. + // On court-circuite l'extraction et on passe directement au DEFAULT_APP_ID. + if let Ok(Some(cached_version)) = config.get_qobuz_bundle_version() { + match crate::api::Spoofer::fetch_current_bundle_version().await { + Ok(current) if current == cached_version => { + info!( + "[Spoofer] Bundle inchangé ({}) — secret invalide pour une autre raison, skip extraction", + current + ); + return QobuzApi::new(DEFAULT_APP_ID); + } + Ok(new_version) => { + info!("[Spoofer] Bundle rotaté : {} → {}, re-extraction", cached_version, new_version); + } + Err(e) => { + debug!("[Spoofer] Impossible de vérifier la version du bundle : {}", e); + } + } + } + if let Some((app_id, secret)) = Self::fetch_spoofer_credentials(config).await? { - // Use raw secret from Spoofer (no XOR) return QobuzApi::with_raw_secret(app_id, &secret); } - info!( - "✗ No valid secret found from Spoofer, falling back to DEFAULT_APP_ID without secret" - ); + info!("✗ No valid secret found from Spoofer, falling back to DEFAULT_APP_ID without secret"); QobuzApi::new(DEFAULT_APP_ID) } @@ -288,18 +306,15 @@ impl QobuzClient { timezone ); - // Save both appid and the working secret - if let Err(e) = config.set_qobuz_appid(&app_id) - { + // Save appid, secret, and bundle version + if let Err(e) = config.set_qobuz_appid(&app_id) { debug!("Could not save appid: {}", e); } - if let Err(e) = - config.set_qobuz_spoofer_secret(secret) - { - debug!( - "Could not save spoofer secret: {}", - e - ); + if let Err(e) = config.set_qobuz_spoofer_secret(secret) { + debug!("Could not save spoofer secret: {}", e); + } + if let Err(e) = config.set_qobuz_bundle_version(spoofer.bundle_version()) { + debug!("Could not save bundle version: {}", e); } return Ok(Some(( diff --git a/pmoqobuz/src/config_ext.rs b/pmoqobuz/src/config_ext.rs index 085a63a8..f1535883 100644 --- a/pmoqobuz/src/config_ext.rs +++ b/pmoqobuz/src/config_ext.rs @@ -238,6 +238,13 @@ pub trait QobuzConfigExt { /// Active ou désactive le rate limiting fn set_qobuz_rate_limiting_enabled(&self, enabled: bool) -> Result<()>; + + /// Version du bundle Qobuz extrait en dernier (ex : `"8.1.0-b019"`). + /// Permet de détecter une rotation de bundle sans télécharger les 7 MB. + fn get_qobuz_bundle_version(&self) -> Result>; + + /// Persiste la version du bundle après une extraction réussie. + fn set_qobuz_bundle_version(&self, version: &str) -> Result<()>; } impl QobuzConfigExt for Config { @@ -483,4 +490,18 @@ impl QobuzConfigExt for Config { Value::Bool(enabled), ) } + + fn get_qobuz_bundle_version(&self) -> Result> { + match self.get_value(&["accounts", "qobuz", "bundle_version"]) { + Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)), + _ => Ok(None), + } + } + + fn set_qobuz_bundle_version(&self, version: &str) -> Result<()> { + self.set_value( + &["accounts", "qobuz", "bundle_version"], + Value::String(version.to_string()), + ) + } }