push-ltynmtvvsnkt #100

Merged
eric merged 4 commits from push-ltynmtvvsnkt into main 2026-06-11 11:15:32 +02:00
4 changed files with 153 additions and 104 deletions
Showing only changes of commit e670e8e0d8 - Show all commits

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
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 |

View File

@@ -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<Self> {
// Expressions régulières (équivalent Python)
let seed_timezone_regex = Regex::new(
r#"[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[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<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:.\}"#,
)?;
// 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<String> {
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#"<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>"#,
let re = Regex::new(
r#"<script src="/resources/(\d+\.\d+\.\d+-[a-z]\d{3})/bundle\.js"></script>"#,
)?;
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<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> {
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<String> {
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<IndexMap<String, String>> {
// Étape 1: Extraire tous les seed/timezone pairs
let mut secrets: IndexMap<String, Vec<String>> = 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::<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 {
let keys: Vec<String> = 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<String> = 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),
}
}
}

View File

@@ -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<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? {
// 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((

View File

@@ -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<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 {
@@ -483,4 +490,18 @@ impl QobuzConfigExt for Config {
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()),
)
}
}