feat: enhance Qobuz bundle extraction with caching and retry logic

Refactor Qobuz bundle fetching to improve resilience and performance. Add HTTP timeout and retry configuration, and implement a retry loop that returns version metadata. Introduce bundle version caching and persistence to skip redundant extractions when the bundle remains unchanged. Replace console logging with structured tracing macros and update the architectural tracking document to mark these improvements as complete.
This commit is contained in:
2026-06-11 09:39:45 +02:00
parent c7f67e7c7c
commit e670e8e0d8
4 changed files with 153 additions and 104 deletions

View File

@@ -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 **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. 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 | | # | Amélioration | Effort | Impact | État |
|---|---|---|---|---| |---|---|---|---|---|
| 1 | Streaming CMAF | Élevé | Critique (pipeline futur) | **Fait** | | 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 | | 3 | Batch `track/getList` | Faible | Élevé (performances) | À faire |
| 4 | Pagination concurrente playlists | Faible | Moyen | À faire | | 4 | Pagination concurrente playlists | Faible | Moyen | À faire |
| 5 | Release watch endpoint | Faible | Faible (catalogue) | À faire | | 5 | Release watch endpoint | Faible | Faible (catalogue) | À faire |

View File

@@ -3,36 +3,40 @@ use base64::{engine::general_purpose::STANDARD, Engine};
use indexmap::IndexMap; use indexmap::IndexMap;
use regex::Regex; use regex::Regex;
use reqwest::Client; 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 { pub struct Spoofer {
bundle: String, bundle: String,
/// Version du bundle extrait, ex. `"8.1.0-b019"`.
bundle_version: String,
seed_timezone_regex: Regex, seed_timezone_regex: Regex,
info_extras_regex_template: String, info_extras_regex_template: String,
app_id_regex: Regex, app_id_regex: Regex,
} }
impl Spoofer { impl Spoofer {
/// Crée un nouveau Spoofer et télécharge le bundle.js /// Version du bundle Qobuz actuellement chargé.
pub async fn new() -> Result<Self> { pub fn bundle_version(&self) -> &str {
// Expressions régulières (équivalent Python) &self.bundle_version
let seed_timezone_regex = Regex::new( }
r#"[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[a-z]+)\)"#,
)?;
let info_extras_regex_template = /// Récupère uniquement la version du bundle courant sans télécharger les 7 MB.
r#"name:"\w+/(?P<timezone>{timezones})",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)""# ///
.to_string(); /// 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).
let app_id_regex = Regex::new( pub async fn fetch_current_bundle_version() -> Result<String> {
r#"production:\{api:\{appId:"(?P<app_id>\d{9})",appSecret:"(?P<secret>\w{32})"\},braze:.\(.\(\{\},.\),\{\},\{apiKey:"([-0-9a-fA-F]{36})"\}\),extra:.\}"#,
)?;
// Créer un client HTTP
let client = Client::builder() let client = Client::builder()
.user_agent("Mozilla/5.0 (compatible; PMOMusic/1.0)") .user_agent("Mozilla/5.0 (compatible; PMOMusic/1.0)")
.timeout(BUNDLE_FETCH_TIMEOUT)
.build()?; .build()?;
println!("Récupération de la page de login...");
let login_page = client let login_page = client
.get("https://play.qobuz.com/login") .get("https://play.qobuz.com/login")
.send() .send()
@@ -40,31 +44,80 @@ impl Spoofer {
.text() .text()
.await?; .await?;
// Extraire l'URL du bundle let re = Regex::new(
let bundle_url_regex = Regex::new( r#"<script src="/resources/(\d+\.\d+\.\d+-[a-z]\d{3})/bundle\.js"></script>"#,
r#"<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>"#,
)?; )?;
let bundle_url = bundle_url_regex re.captures(&login_page)
.captures(&login_page)
.and_then(|cap| cap.get(1)) .and_then(|cap| cap.get(1))
.ok_or_else(|| anyhow::anyhow!("Impossible de trouver l'URL du bundle"))? .map(|m| m.as_str().to_string())
.as_str(); .ok_or_else(|| anyhow::anyhow!("Version bundle introuvable dans la page de login"))
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,
})
} }
/// 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<Self> {
let seed_timezone_regex = Regex::new(
r#"[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[a-z]+)\)"#,
)?;
let info_extras_regex_template =
r#"name:"\w+/(?P<timezone>{timezones})",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)""#
.to_string();
let app_id_regex = Regex::new(
r#"production:\{api:\{appId:"(?P<app_id>\d{9})",appSecret:"(?P<secret>\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<anyhow::Error> = 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#"<script src="(/resources/(\d+\.\d+\.\d+-[a-z]\d{3})/bundle\.js)"></script>"#,
)?;
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<String> { pub fn get_app_id(&self) -> Result<String> {
let captures = self let captures = self
.app_id_regex .app_id_regex
@@ -78,10 +131,7 @@ impl Spoofer {
.to_string()) .to_string())
} }
/// Extrait l'appSecret depuis le bundle (secret MD5 à 32 caractères) /// 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> { pub fn get_app_secret(&self) -> Result<String> {
let captures = self let captures = self
.app_id_regex .app_id_regex
@@ -95,37 +145,25 @@ impl Spoofer {
.to_string()) .to_string())
} }
/// Extrait les secrets depuis le bundle /// Extrait les secrets timezone depuis le bundle.
pub fn get_secrets(&self) -> Result<IndexMap<String, String>> { pub fn get_secrets(&self) -> Result<IndexMap<String, String>> {
// Étape 1: Extraire tous les seed/timezone pairs
let mut secrets: IndexMap<String, Vec<String>> = IndexMap::new(); let mut secrets: IndexMap<String, Vec<String>> = IndexMap::new();
for captures in self.seed_timezone_regex.captures_iter(&self.bundle) { for captures in self.seed_timezone_regex.captures_iter(&self.bundle) {
let seed = captures let seed = captures.name("seed").unwrap().as_str();
.name("seed") let timezone = captures.name("timezone").unwrap().as_str();
.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();
secrets secrets
.entry(timezone.to_string()) .entry(timezone.to_string())
.or_insert_with(Vec::new) .or_default()
.push(seed.to_string()); .push(seed.to_string());
} }
println!("Timezones trouvées: {:?}", secrets.keys()); debug!("[Spoofer] Timezones trouvées : {:?}", secrets.keys().collect::<Vec<_>>());
// É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 { if secrets.len() >= 2 {
let keys: Vec<String> = secrets.keys().cloned().collect(); let keys: Vec<String> = secrets.keys().cloned().collect();
let second_key = keys[1].clone(); let second_key = keys[1].clone();
let second_value = secrets.get(&second_key).unwrap().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); secrets.shift_remove(&second_key);
let mut new_secrets = IndexMap::new(); let mut new_secrets = IndexMap::new();
new_secrets.insert(second_key, second_value); new_secrets.insert(second_key, second_value);
@@ -135,7 +173,6 @@ impl Spoofer {
secrets = new_secrets; secrets = new_secrets;
} }
// Étape 3: Construire la regex pour info/extras
let timezones_capitalized: Vec<String> = secrets let timezones_capitalized: Vec<String> = secrets
.keys() .keys()
.map(|tz| { .map(|tz| {
@@ -150,24 +187,12 @@ impl Spoofer {
let info_extras_regex_str = self let info_extras_regex_str = self
.info_extras_regex_template .info_extras_regex_template
.replace("{timezones}", &timezones_capitalized.join("|")); .replace("{timezones}", &timezones_capitalized.join("|"));
let info_extras_regex = Regex::new(&info_extras_regex_str)?; 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) { for captures in info_extras_regex.captures_iter(&self.bundle) {
let timezone_cap = captures let timezone_cap = captures.name("timezone").unwrap().as_str();
.name("timezone") let info = captures.name("info").unwrap().as_str();
.ok_or_else(|| anyhow::anyhow!("Groupe timezone non trouvé"))? let extras = captures.name("extras").unwrap().as_str();
.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_lower = timezone_cap.to_lowercase(); let timezone_lower = timezone_cap.to_lowercase();
if let Some(vec) = secrets.get_mut(&timezone_lower) { if let Some(vec) = secrets.get_mut(&timezone_lower) {
vec.push(info.to_string()); 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(); let mut decoded_secrets = IndexMap::new();
for (timezone, parts) in secrets { for (timezone, parts) in secrets {
let concatenated = parts.join(""); let concatenated = parts.join("");
// Retirer les 44 derniers caractères (comme Python [:-44])
if concatenated.len() > 44 { if concatenated.len() > 44 {
let trimmed = &concatenated[..concatenated.len() - 44]; let trimmed = &concatenated[..concatenated.len() - 44];
// Décoder en base64
match STANDARD.decode(trimmed) { match STANDARD.decode(trimmed) {
Ok(decoded_bytes) => match String::from_utf8(decoded_bytes) { Ok(decoded_bytes) => match String::from_utf8(decoded_bytes) {
Ok(decoded_str) => { Ok(decoded_str) => {
decoded_secrets.insert(timezone, decoded_str); decoded_secrets.insert(timezone, decoded_str);
} }
Err(e) => { Err(e) => warn!("[Spoofer] UTF-8 invalide pour timezone {}: {}", timezone, e),
eprintln!("Erreur UTF-8 pour timezone {}: {}", timezone, e);
}
}, },
Err(e) => { Err(e) => warn!("[Spoofer] Base64 invalide pour timezone {}: {}", timezone, e),
eprintln!(
"Erreur de décodage base64 pour timezone {}: {}",
timezone, e
);
}
} }
} }
} }

View File

@@ -236,14 +236,32 @@ impl QobuzClient {
/// - Quand aucun appid/secret n'est configuré /// - Quand aucun appid/secret n'est configuré
/// - Quand les credentials configurés sont invalides/expirés /// - Quand les credentials configurés sont invalides/expirés
async fn try_spoofer_fallback(config: &Config) -> Result<QobuzApi> { async fn try_spoofer_fallback(config: &Config) -> Result<QobuzApi> {
// 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? { 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); return QobuzApi::with_raw_secret(app_id, &secret);
} }
info!( info!("✗ No valid secret found from Spoofer, falling back to DEFAULT_APP_ID without secret");
"✗ No valid secret found from Spoofer, falling back to DEFAULT_APP_ID without secret"
);
QobuzApi::new(DEFAULT_APP_ID) QobuzApi::new(DEFAULT_APP_ID)
} }
@@ -288,18 +306,15 @@ impl QobuzClient {
timezone timezone
); );
// Save both appid and the working secret // Save appid, secret, and bundle version
if let Err(e) = config.set_qobuz_appid(&app_id) if let Err(e) = config.set_qobuz_appid(&app_id) {
{
debug!("Could not save appid: {}", e); debug!("Could not save appid: {}", e);
} }
if let Err(e) = if let Err(e) = config.set_qobuz_spoofer_secret(secret) {
config.set_qobuz_spoofer_secret(secret) debug!("Could not save spoofer secret: {}", e);
{ }
debug!( if let Err(e) = config.set_qobuz_bundle_version(spoofer.bundle_version()) {
"Could not save spoofer secret: {}", debug!("Could not save bundle version: {}", e);
e
);
} }
return Ok(Some(( return Ok(Some((

View File

@@ -238,6 +238,13 @@ pub trait QobuzConfigExt {
/// Active ou désactive le rate limiting /// Active ou désactive le rate limiting
fn set_qobuz_rate_limiting_enabled(&self, enabled: bool) -> Result<()>; 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<Option<String>>;
/// Persiste la version du bundle après une extraction réussie.
fn set_qobuz_bundle_version(&self, version: &str) -> Result<()>;
} }
impl QobuzConfigExt for Config { impl QobuzConfigExt for Config {
@@ -483,4 +490,18 @@ impl QobuzConfigExt for Config {
Value::Bool(enabled), Value::Bool(enabled),
) )
} }
fn get_qobuz_bundle_version(&self) -> Result<Option<String>> {
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()),
)
}
} }