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:
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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((
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user