push-ltynmtvvsnkt #100
@@ -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 |
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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((
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user