Files
pmomusic/pmoqobuz/src/config_ext.rs
Eric Coissac 1d3a9379a1 feat(qobuz): implement concurrent playlist pagination
Parallelize Qobuz playlist track fetching by increasing the page size to 500 and processing remaining pages concurrently via `futures::try_join_all` with a configurable semaphore (default 3). Results are offset-sorted to preserve original order. Adds a `page_concurrency` configuration option, updates the API client initialization, and introduces the `futures` dependency. This reduces large playlist latency from ~1.6s to ~0.7s.
2026-06-11 15:11:12 +02:00

544 lines
18 KiB
Rust

//! Extension pour intégrer la configuration Qobuz dans pmoconfig
//!
//! Ce module fournit le trait `QobuzConfigExt` qui permet d'ajouter facilement
//! des méthodes de gestion des credentials Qobuz à pmoconfig::Config.
use anyhow::{anyhow, Result};
use pmoconfig::Config;
use serde_yaml::Value;
/// Trait d'extension pour gérer la configuration Qobuz dans pmoconfig
///
/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques
/// aux credentials et paramètres Qobuz.
///
/// # Exemple
///
/// ```rust,ignore
/// use pmoconfig::get_config;
/// use pmoqobuz::QobuzConfigExt;
///
/// let config = get_config();
/// let (username, password) = config.get_qobuz_credentials()?;
/// println!("Qobuz user: {}", username);
/// ```
pub trait QobuzConfigExt {
/// Récupère le nom d'utilisateur Qobuz depuis la configuration
///
/// # Returns
///
/// Le nom d'utilisateur (email) configuré pour Qobuz
///
/// # Errors
///
/// Retourne une erreur si le nom d'utilisateur n'est pas configuré
fn get_qobuz_username(&self) -> Result<String>;
/// Définit le nom d'utilisateur Qobuz dans la configuration
///
/// # Arguments
///
/// * `username` - Le nom d'utilisateur (email) Qobuz
fn set_qobuz_username(&self, username: &str) -> Result<()>;
/// Récupère le mot de passe Qobuz depuis la configuration
///
/// # Returns
///
/// Le mot de passe configuré pour Qobuz
///
/// # Errors
///
/// Retourne une erreur si le mot de passe n'est pas configuré
fn get_qobuz_password(&self) -> Result<String>;
/// Définit le mot de passe Qobuz dans la configuration
///
/// # Arguments
///
/// * `password` - Le mot de passe Qobuz
fn set_qobuz_password(&self, password: &str) -> Result<()>;
/// Récupère les credentials Qobuz (username et password)
///
/// # Returns
///
/// Un tuple (username, password) contenant les credentials Qobuz
///
/// # Errors
///
/// Retourne une erreur si l'un des credentials n'est pas configuré
///
/// # Exemple
///
/// ```rust,ignore
/// use pmoconfig::get_config;
/// use pmoqobuz::QobuzConfigExt;
///
/// let config = get_config();
/// match config.get_qobuz_credentials() {
/// Ok((username, password)) => {
/// println!("Credentials configured for: {}", username);
/// }
/// Err(e) => {
/// eprintln!("Qobuz credentials not configured: {}", e);
/// }
/// }
/// ```
fn get_qobuz_credentials(&self) -> Result<(String, String)>;
/// Récupère l'App ID Qobuz depuis la configuration
///
/// # Returns
///
/// L'App ID configuré pour Qobuz, ou None si non configuré
///
/// # Note
///
/// Si aucun App ID n'est configuré, le client utilisera soit le Spoofer
/// pour en obtenir un dynamiquement, soit un App ID par défaut.
fn get_qobuz_appid(&self) -> Result<Option<String>>;
/// Définit l'App ID Qobuz dans la configuration
///
/// # Arguments
///
/// * `appid` - L'App ID Qobuz (ex: "1401488693436528")
fn set_qobuz_appid(&self, appid: &str) -> Result<()>;
/// Récupère le secret Qobuz depuis la configuration
///
/// # Returns
///
/// Le secret encodé en base64, ou None si non configuré
///
/// # Note
///
/// Le secret est la valeur `configvalue` du code Python.
/// Il est décodé et XORé avec l'App ID pour obtenir le secret `s4`
/// utilisé pour signer les requêtes sensibles.
///
/// Si aucun secret n'est configuré, le client utilisera le Spoofer
/// pour en obtenir un dynamiquement.
fn get_qobuz_secret(&self) -> Result<Option<String>>;
/// Définit le secret Qobuz dans la configuration
///
/// # Arguments
///
/// * `secret` - Le secret encodé en base64 (configvalue)
fn set_qobuz_secret(&self, secret: &str) -> Result<()>;
/// Récupère le secret brut du Spoofer depuis la configuration
///
/// # Returns
///
/// Le secret brut (non XORé), ou None si non configuré
///
/// # Note
///
/// Ce secret est obtenu par le Spoofer et est utilisé directement
/// sans XOR avec l'App ID. Si ce secret est présent, il est testé
/// en priorité avant de relancer le Spoofer.
fn get_qobuz_spoofer_secret(&self) -> Result<Option<String>>;
/// Définit le secret brut du Spoofer dans la configuration
///
/// # Arguments
///
/// * `secret` - Le secret brut obtenu par le Spoofer
fn set_qobuz_spoofer_secret(&self, secret: &str) -> Result<()>;
/// Récupère le token d'authentification depuis la configuration
///
/// # Returns
///
/// Le token d'authentification, ou None si non configuré ou expiré
fn get_qobuz_auth_token(&self) -> Result<Option<String>>;
/// Récupère l'ID utilisateur depuis la configuration
///
/// # Returns
///
/// L'ID utilisateur, ou None si non configuré
fn get_qobuz_user_id(&self) -> Result<Option<String>>;
/// Récupère le timestamp d'expiration du token
///
/// # Returns
///
/// Le timestamp d'expiration (Unix timestamp), ou None si non configuré
fn get_qobuz_token_expires_at(&self) -> Result<Option<u64>>;
/// Récupère le label de l'abonnement depuis la configuration
fn get_qobuz_subscription_label(&self) -> Result<Option<String>>;
/// Sauvegarde les informations d'authentification dans la configuration
///
/// # Arguments
///
/// * `token` - Le token d'authentification
/// * `user_id` - L'ID utilisateur
/// * `subscription_label` - Le label de l'abonnement (optionnel)
/// * `expires_at` - Timestamp d'expiration (Unix timestamp)
fn set_qobuz_auth_info(
&self,
token: &str,
user_id: &str,
subscription_label: Option<&str>,
expires_at: u64,
) -> Result<()>;
/// Supprime les informations d'authentification de la configuration
fn clear_qobuz_auth_info(&self) -> Result<()>;
/// Vérifie si le token d'authentification est encore valide
///
/// # Returns
///
/// true si un token existe et n'est pas expiré, false sinon
fn is_qobuz_auth_valid(&self) -> bool;
/// Récupère le répertoire de cache Qobuz
///
/// # Returns
///
/// Le chemin absolu du répertoire de cache, créé s'il n'existe pas
fn get_qobuz_cache_dir(&self) -> Result<String>;
/// Définit le répertoire de cache Qobuz
fn set_qobuz_cache_dir(&self, directory: String) -> Result<()>;
/// Récupère le nombre maximum de requêtes concurrentes
///
/// # Returns
///
/// Le nombre maximum de requêtes concurrentes, ou None si non configuré (défaut: 2)
fn get_qobuz_rate_limit_max_concurrent(&self) -> Result<Option<usize>>;
/// Définit le nombre maximum de requêtes concurrentes
fn set_qobuz_rate_limit_max_concurrent(&self, max: usize) -> Result<()>;
/// Récupère le délai minimum entre requêtes en millisecondes
///
/// # Returns
///
/// Le délai minimum en ms, ou None si non configuré (défaut: 400ms)
fn get_qobuz_rate_limit_min_delay_ms(&self) -> Result<Option<u64>>;
/// Définit le délai minimum entre requêtes
fn set_qobuz_rate_limit_min_delay_ms(&self, delay_ms: u64) -> Result<()>;
/// Vérifie si le rate limiting est activé
///
/// # Returns
///
/// true si activé (défaut), false sinon
fn is_qobuz_rate_limiting_enabled(&self) -> bool;
/// 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<()>;
/// Nombre de workers concurrents pour l'enregistrement des tracks en cache.
///
/// Contrôle le semaphore dans `register_tracks_lazy` : plus la valeur est
/// haute, plus les covers sont téléchargées en parallèle, mais plus la
/// contention sur le mutex SQLite est forte.
///
/// Défaut : 4 (adapté à une machine sous contrainte mémoire / Docker).
fn get_qobuz_register_concurrency(&self) -> usize;
/// Nombre de pages de playlist chargées en parallèle via `/playlist/get`.
///
/// La page 1 est toujours séquentielle (pour obtenir `total`). Les pages
/// suivantes sont lancées simultanément jusqu'à cette limite.
/// Valeur trop haute → risque de rate limiting Qobuz.
///
/// Défaut : 3.
fn get_qobuz_page_concurrency(&self) -> usize;
}
impl QobuzConfigExt for Config {
fn get_qobuz_username(&self) -> Result<String> {
match self.get_value(&["accounts", "qobuz", "username"])? {
Value::String(s) => Ok(s),
_ => Err(anyhow!("Qobuz username not configured")),
}
}
fn set_qobuz_username(&self, username: &str) -> Result<()> {
self.set_value(
&["accounts", "qobuz", "username"],
Value::String(username.to_string()),
)
}
fn get_qobuz_password(&self) -> Result<String> {
match self.get_value(&["accounts", "qobuz", "password"])? {
Value::String(s) => {
// Déchiffrement automatique si le mot de passe est chiffré
pmoconfig::encryption::get_password(&s)
.map_err(|e| anyhow!("Failed to decrypt password: {}", e))
}
_ => Err(anyhow!("Qobuz password not configured")),
}
}
fn set_qobuz_password(&self, password: &str) -> Result<()> {
self.set_value(
&["accounts", "qobuz", "password"],
Value::String(password.to_string()),
)
}
fn get_qobuz_credentials(&self) -> Result<(String, String)> {
let username = self.get_qobuz_username()?;
let password = self.get_qobuz_password()?;
Ok((username, password))
}
fn get_qobuz_appid(&self) -> Result<Option<String>> {
match self.get_value(&["accounts", "qobuz", "appid"]) {
Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)),
Ok(Value::String(_)) => Ok(None), // Empty string
Ok(_) => Ok(None), // Wrong type
Err(_) => Ok(None), // Not configured
}
}
fn set_qobuz_appid(&self, appid: &str) -> Result<()> {
self.set_value(
&["accounts", "qobuz", "appid"],
Value::String(appid.to_string()),
)
}
fn get_qobuz_secret(&self) -> Result<Option<String>> {
match self.get_value(&["accounts", "qobuz", "secret"]) {
Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)),
Ok(Value::String(_)) => Ok(None), // Empty string
Ok(_) => Ok(None), // Wrong type
Err(_) => Ok(None), // Not configured
}
}
fn set_qobuz_secret(&self, secret: &str) -> Result<()> {
self.set_value(
&["accounts", "qobuz", "secret"],
Value::String(secret.to_string()),
)
}
fn get_qobuz_spoofer_secret(&self) -> Result<Option<String>> {
match self.get_value(&["accounts", "qobuz", "spoofer_secret"]) {
Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)),
Ok(Value::String(_)) => Ok(None), // Empty string
Ok(_) => Ok(None), // Wrong type
Err(_) => Ok(None), // Not configured
}
}
fn set_qobuz_spoofer_secret(&self, secret: &str) -> Result<()> {
self.set_value(
&["accounts", "qobuz", "spoofer_secret"],
Value::String(secret.to_string()),
)
}
fn get_qobuz_auth_token(&self) -> Result<Option<String>> {
match self.get_value(&["accounts", "qobuz", "auth_token"]) {
Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)),
Ok(Value::String(_)) => Ok(None), // Empty string
Ok(_) => Ok(None), // Wrong type
Err(_) => Ok(None), // Not configured
}
}
fn get_qobuz_user_id(&self) -> Result<Option<String>> {
match self.get_value(&["accounts", "qobuz", "user_id"]) {
Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)),
Ok(Value::String(_)) => Ok(None), // Empty string
Ok(_) => Ok(None), // Wrong type
Err(_) => Ok(None), // Not configured
}
}
fn get_qobuz_token_expires_at(&self) -> Result<Option<u64>> {
match self.get_value(&["accounts", "qobuz", "token_expires_at"]) {
Ok(Value::Number(n)) if n.is_u64() => Ok(Some(n.as_u64().unwrap())),
Ok(Value::Number(n)) if n.is_i64() => Ok(Some(n.as_i64().unwrap() as u64)),
Ok(_) => Ok(None), // Wrong type
Err(_) => Ok(None), // Not configured
}
}
fn get_qobuz_subscription_label(&self) -> Result<Option<String>> {
match self.get_value(&["accounts", "qobuz", "subscription_label"]) {
Ok(Value::String(s)) if !s.is_empty() => Ok(Some(s)),
Ok(Value::String(_)) => Ok(None), // Empty string
Ok(_) => Ok(None), // Wrong type
Err(_) => Ok(None), // Not configured
}
}
fn set_qobuz_auth_info(
&self,
token: &str,
user_id: &str,
subscription_label: Option<&str>,
expires_at: u64,
) -> Result<()> {
self.set_value(
&["accounts", "qobuz", "auth_token"],
Value::String(token.to_string()),
)?;
self.set_value(
&["accounts", "qobuz", "user_id"],
Value::String(user_id.to_string()),
)?;
self.set_value(
&["accounts", "qobuz", "token_expires_at"],
Value::Number(serde_yaml::Number::from(expires_at)),
)?;
if let Some(label) = subscription_label {
self.set_value(
&["accounts", "qobuz", "subscription_label"],
Value::String(label.to_string()),
)?;
}
Ok(())
}
fn clear_qobuz_auth_info(&self) -> Result<()> {
// On ne propage pas les erreurs car les valeurs peuvent ne pas exister
let _ = self.set_value(
&["accounts", "qobuz", "auth_token"],
Value::String(String::new()),
);
let _ = self.set_value(
&["accounts", "qobuz", "user_id"],
Value::String(String::new()),
);
let _ = self.set_value(
&["accounts", "qobuz", "token_expires_at"],
Value::Number(serde_yaml::Number::from(0)),
);
let _ = self.set_value(
&["accounts", "qobuz", "subscription_label"],
Value::String(String::new()),
);
Ok(())
}
fn is_qobuz_auth_valid(&self) -> bool {
let token_present = self
.get_qobuz_auth_token()
.ok()
.flatten()
.map(|token| !token.is_empty())
.unwrap_or(false);
let user_present = self
.get_qobuz_user_id()
.ok()
.flatten()
.map(|user_id| !user_id.is_empty())
.unwrap_or(false);
token_present && user_present
}
fn get_qobuz_cache_dir(&self) -> Result<String> {
self.get_managed_dir(&["host", "qobuz_cache", "directory"], "cache_qobuz")
}
fn set_qobuz_cache_dir(&self, directory: String) -> Result<()> {
self.set_managed_dir(&["host", "qobuz_cache", "directory"], directory)
}
fn get_qobuz_rate_limit_max_concurrent(&self) -> Result<Option<usize>> {
match self.get_value(&["accounts", "qobuz", "rate_limit", "max_concurrent"]) {
Ok(Value::Number(n)) if n.is_u64() => Ok(Some(n.as_u64().unwrap() as usize)),
Ok(_) => Ok(None),
Err(_) => Ok(Some(2)), // Default: 2 concurrent requests
}
}
fn set_qobuz_rate_limit_max_concurrent(&self, max: usize) -> Result<()> {
self.set_value(
&["accounts", "qobuz", "rate_limit", "max_concurrent"],
Value::Number(serde_yaml::Number::from(max)),
)
}
fn get_qobuz_rate_limit_min_delay_ms(&self) -> Result<Option<u64>> {
match self.get_value(&["accounts", "qobuz", "rate_limit", "min_delay_ms"]) {
Ok(Value::Number(n)) if n.is_u64() => Ok(Some(n.as_u64().unwrap())),
Ok(_) => Ok(None),
Err(_) => Ok(Some(400)), // Default: 400ms
}
}
fn set_qobuz_rate_limit_min_delay_ms(&self, delay_ms: u64) -> Result<()> {
self.set_value(
&["accounts", "qobuz", "rate_limit", "min_delay_ms"],
Value::Number(serde_yaml::Number::from(delay_ms)),
)
}
fn is_qobuz_rate_limiting_enabled(&self) -> bool {
match self.get_value(&["accounts", "qobuz", "rate_limit", "enabled"]) {
Ok(Value::Bool(b)) => b,
_ => true, // Default: enabled
}
}
fn set_qobuz_rate_limiting_enabled(&self, enabled: bool) -> Result<()> {
self.set_value(
&["accounts", "qobuz", "rate_limit", "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()),
)
}
fn get_qobuz_register_concurrency(&self) -> usize {
match self.get_value(&["accounts", "qobuz", "register_concurrency"]) {
Ok(Value::Number(n)) if n.as_u64().unwrap_or(0) >= 1 => {
n.as_u64().unwrap() as usize
}
_ => 4,
}
}
fn get_qobuz_page_concurrency(&self) -> usize {
match self.get_value(&["accounts", "qobuz", "page_concurrency"]) {
Ok(Value::Number(n)) if n.as_u64().unwrap_or(0) >= 1 => {
n.as_u64().unwrap() as usize
}
_ => 3,
}
}
}