Big debug of pmoqobuz step 1

This commit is contained in:
2025-12-12 21:42:04 +00:00
parent 7389c55970
commit cd19d68703
26 changed files with 18565 additions and 652 deletions

View File

@@ -67,7 +67,7 @@ impl QobuzApi {
///
/// * `QobuzError::Unauthorized` - Credentials invalides
/// * `QobuzError::SubscriptionRequired` - Compte gratuit (non éligible)
pub async fn login(&mut self, username: &str, password: &str) -> Result<AuthInfo> {
pub async fn login(&self, username: &str, password: &str) -> Result<AuthInfo> {
info!("Attempting to login to Qobuz as {}", username);
let params = [("username", username), ("password", password)];
@@ -105,14 +105,13 @@ impl QobuzApi {
/// Vérifie si le client est authentifié
pub fn is_authenticated(&self) -> bool {
self.user_auth_token.is_some() && self.user_id.is_some()
self.auth_token().is_some() && self.user_id().is_some()
}
/// Déconnecte l'utilisateur
pub fn logout(&mut self) {
pub fn logout(&self) {
debug!("Logging out");
self.user_auth_token = None;
self.user_id = None;
self.clear_auth();
}
}
@@ -122,7 +121,7 @@ mod tests {
#[test]
fn test_is_authenticated() {
let mut api = QobuzApi::new("test_app_id").unwrap();
let api = QobuzApi::new("test_app_id").unwrap();
assert!(!api.is_authenticated());
api.set_auth_token("token".to_string(), "user123".to_string());

View File

@@ -225,26 +225,19 @@ impl QobuzApi {
debug!("Fetching file URL for track {}", track_id);
// Vérifier que le secret est disponible
let secret = self
.secret()
.ok_or_else(|| {
QobuzError::Configuration(
"Secret not configured. Cannot sign track/getFileUrl request.".to_string(),
)
})?;
let secret = self.secret().ok_or_else(|| {
QobuzError::Configuration(
"Secret not configured. Cannot sign track/getFileUrl request.".to_string(),
)
})?;
let format_id = self.format_id.id().to_string();
let intent = "stream";
let timestamp = signing::get_timestamp();
// Signer la requête (comme Python: track_getFileUrl)
let signature = signing::sign_track_get_file_url(
&format_id,
intent,
track_id,
&timestamp,
secret,
);
let signature =
signing::sign_track_get_file_url(&format_id, intent, track_id, &timestamp, &secret);
debug!(
"Signing track/getFileUrl: track_id={}, format_id={}, ts={}",

View File

@@ -13,6 +13,7 @@ use crate::models::AudioFormat;
use reqwest::{Client, Response};
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::sync::RwLock;
use std::time::Duration;
use tracing::{debug, warn};
@@ -45,9 +46,9 @@ pub struct QobuzApi {
/// - Depuis le Spoofer (secrets dynamiques)
secret: Option<Vec<u8>>,
/// Token d'authentification utilisateur
user_auth_token: Option<String>,
user_auth_token: RwLock<Option<String>>,
/// ID utilisateur
user_id: Option<String>,
user_id: RwLock<Option<String>>,
/// Format audio par défaut
format_id: AudioFormat,
}
@@ -66,8 +67,8 @@ impl QobuzApi {
client,
app_id: app_id.into(),
secret: None,
user_auth_token: None,
user_id: None,
user_auth_token: RwLock::new(None),
user_id: RwLock::new(None),
format_id: AudioFormat::default(),
})
}
@@ -132,9 +133,15 @@ impl QobuzApi {
}
/// Définit le token d'authentification
pub fn set_auth_token(&mut self, token: String, user_id: String) {
self.user_auth_token = Some(token);
self.user_id = Some(user_id);
pub fn set_auth_token(&self, token: String, user_id: String) {
*self.user_auth_token.write().unwrap() = Some(token);
*self.user_id.write().unwrap() = Some(user_id);
}
/// Efface les informations d'authentification
pub fn clear_auth(&self) {
*self.user_auth_token.write().unwrap() = None;
*self.user_id.write().unwrap() = None;
}
/// Définit le format audio par défaut
@@ -153,13 +160,13 @@ impl QobuzApi {
}
/// Retourne le token d'authentification si disponible
pub fn auth_token(&self) -> Option<&str> {
self.user_auth_token.as_deref()
pub fn auth_token(&self) -> Option<String> {
self.user_auth_token.read().unwrap().clone()
}
/// Retourne l'ID utilisateur si disponible
pub fn user_id(&self) -> Option<&str> {
self.user_id.as_deref()
pub fn user_id(&self) -> Option<String> {
self.user_id.read().unwrap().clone()
}
/// Effectue une requête GET à l'API
@@ -200,7 +207,7 @@ impl QobuzApi {
// Ajouter les headers
request = request.header("X-App-Id", &self.app_id);
if let Some(ref token) = self.user_auth_token {
if let Some(token) = self.auth_token() {
request = request.header("X-User-Auth-Token", token);
}
@@ -269,10 +276,10 @@ mod tests {
#[test]
fn test_set_auth_token() {
let mut api = QobuzApi::new("test_app_id").unwrap();
let api = QobuzApi::new("test_app_id").unwrap();
api.set_auth_token("test_token".to_string(), "user123".to_string());
assert_eq!(api.auth_token(), Some("test_token"));
assert_eq!(api.user_id(), Some("user123"));
assert_eq!(api.auth_token().as_deref(), Some("test_token"));
assert_eq!(api.user_id().as_deref(), Some("user123"));
}
#[test]

View File

@@ -115,13 +115,8 @@ mod tests {
#[test]
fn test_sign_track_get_file_url() {
let signature = sign_track_get_file_url(
"27",
"stream",
"12345",
"1234567890.123",
b"test_secret",
);
let signature =
sign_track_get_file_url("27", "stream", "12345", "1234567890.123", b"test_secret");
// Vérifier que c'est un hash MD5 valide (32 caractères hex)
assert_eq!(signature.len(), 32);

View File

@@ -41,8 +41,9 @@ impl Spoofer {
.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 bundle_url_regex = 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)
.and_then(|cap| cap.get(1))
@@ -168,19 +169,14 @@ impl Spoofer {
// 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
);
}
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) => {
eprintln!(
"Erreur de décodage base64 pour timezone {}: {}",

View File

@@ -32,9 +32,8 @@ struct UserPlaylistsResponse {
impl QobuzApi {
/// Vérifie que l'utilisateur est authentifié
fn ensure_authenticated(&self) -> Result<&str> {
self.user_id
.as_deref()
fn ensure_authenticated(&self) -> Result<String> {
self.user_id()
.ok_or_else(|| QobuzError::Unauthorized("Not authenticated".to_string()))
}
@@ -43,7 +42,11 @@ impl QobuzApi {
let user_id = self.ensure_authenticated()?;
debug!("Fetching favorite albums for user {}", user_id);
let params = [("user_id", user_id), ("type", "albums"), ("limit", "1000")];
let params = [
("user_id", user_id.as_str()),
("type", "albums"),
("limit", "1000"),
];
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", &params).await?;
@@ -64,7 +67,11 @@ impl QobuzApi {
let user_id = self.ensure_authenticated()?;
debug!("Fetching favorite artists for user {}", user_id);
let params = [("user_id", user_id), ("type", "artists"), ("limit", "1000")];
let params = [
("user_id", user_id.as_str()),
("type", "artists"),
("limit", "1000"),
];
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", &params).await?;
@@ -84,7 +91,11 @@ impl QobuzApi {
let user_id = self.ensure_authenticated()?;
debug!("Fetching favorite tracks for user {}", user_id);
let params = [("user_id", user_id), ("type", "tracks"), ("limit", "1000")];
let params = [
("user_id", user_id.as_str()),
("type", "tracks"),
("limit", "1000"),
];
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", &params).await?;
@@ -105,7 +116,7 @@ impl QobuzApi {
let user_id = self.ensure_authenticated()?;
debug!("Fetching playlists for user {}", user_id);
let params = [("user_id", user_id), ("limit", "1000")];
let params = [("user_id", user_id.as_str()), ("limit", "1000")];
let response: UserPlaylistsResponse =
self.get("/playlist/getUserPlaylists", &params).await?;
@@ -126,7 +137,7 @@ impl QobuzApi {
album_id, user_id
);
let params = [("album_id", album_id), ("user_id", user_id)];
let params = [("album_id", album_id), ("user_id", user_id.as_str())];
self.get::<serde_json::Value>("/favorite/create", &params)
.await?;
@@ -141,7 +152,7 @@ impl QobuzApi {
album_id, user_id
);
let params = [("album_ids", album_id), ("user_id", user_id)];
let params = [("album_ids", album_id), ("user_id", user_id.as_str())];
self.get::<serde_json::Value>("/favorite/delete", &params)
.await?;
@@ -156,7 +167,7 @@ impl QobuzApi {
track_id, user_id
);
let params = [("track_id", track_id), ("user_id", user_id)];
let params = [("track_id", track_id), ("user_id", user_id.as_str())];
self.get::<serde_json::Value>("/favorite/create", &params)
.await?;
@@ -171,7 +182,7 @@ impl QobuzApi {
track_id, user_id
);
let params = [("track_ids", track_id), ("user_id", user_id)];
let params = [("track_ids", track_id), ("user_id", user_id.as_str())];
self.get::<serde_json::Value>("/favorite/delete", &params)
.await?;
@@ -212,19 +223,16 @@ impl QobuzApi {
self.ensure_authenticated()?;
// Vérifier que le secret est disponible
let secret = self
.secret()
.ok_or_else(|| {
QobuzError::Configuration(
"Secret not configured. Cannot sign userLibrary/getAlbumsList request."
.to_string(),
)
})?;
let secret = self.secret().ok_or_else(|| {
QobuzError::Configuration(
"Secret not configured. Cannot sign userLibrary/getAlbumsList request.".to_string(),
)
})?;
let timestamp = signing::get_timestamp();
// Signer la requête (comme Python: userlib_getAlbums)
let signature = signing::sign_userlib_get_albums(&timestamp, secret);
let signature = signing::sign_userlib_get_albums(&timestamp, &secret);
debug!(
"Signing userLibrary/getAlbumsList: app_id={}, ts={}",
@@ -239,7 +247,7 @@ impl QobuzApi {
let params = [
("app_id", self.app_id()),
("user_auth_token", user_auth_token),
("user_auth_token", user_auth_token.as_str()),
("request_ts", timestamp.as_str()),
("request_sig", signature.as_str()),
];
@@ -252,9 +260,9 @@ impl QobuzApi {
///
/// Cette méthode est équivalente au test fait dans `setSec()` en Python.
/// Elle retourne `true` si le secret fonctionne, `false` sinon.
pub async fn test_secret(&self, secret: &[u8]) -> bool {
pub async fn test_secret(&self, _secret: &[u8]) -> bool {
// Sauvegarder le secret actuel
let current_secret = self.secret().map(|s| s.to_vec());
let _current_secret = self.secret();
// Définir temporairement le nouveau secret
// Note: cette méthode nécessite &mut self, donc on doit la rendre mutable

View File

@@ -24,7 +24,6 @@ use crate::{client::QobuzClient, error::QobuzError, models::*};
#[derive(Clone)]
pub struct QobuzState {
pub client: Arc<QobuzClient>,
#[cfg(feature = "covers")]
pub cover_cache: Option<Arc<pmocovers::Cache>>,
}
@@ -121,7 +120,6 @@ async fn get_album(
) -> Result<Json<Album>, AppError> {
let mut album = state.client.get_album(&id).await?;
#[cfg(feature = "covers")]
if let Some(ref cover_cache) = state.cover_cache {
album = cache_album_image(album, cover_cache).await;
}
@@ -163,7 +161,6 @@ async fn get_artist_albums(
) -> Result<Json<Vec<Album>>, AppError> {
let mut albums = state.client.get_artist_albums(&id).await?;
#[cfg(feature = "covers")]
if let Some(ref cover_cache) = state.cover_cache {
albums = cache_albums_images(albums, cover_cache).await;
}
@@ -208,7 +205,6 @@ async fn search(
.search(&params.q, params.search_type.as_deref())
.await?;
#[cfg(feature = "covers")]
if let Some(ref cover_cache) = state.cover_cache {
result.albums = cache_albums_images(result.albums, cover_cache).await;
}
@@ -222,7 +218,6 @@ async fn get_favorite_albums(
) -> Result<Json<Vec<Album>>, AppError> {
let mut albums = state.client.get_favorite_albums().await?;
#[cfg(feature = "covers")]
if let Some(ref cover_cache) = state.cover_cache {
albums = cache_albums_images(albums, cover_cache).await;
}
@@ -270,7 +265,6 @@ async fn get_featured_albums(
.get_featured_albums(params.genre_id.as_deref(), &params.type_)
.await?;
#[cfg(feature = "covers")]
if let Some(ref cover_cache) = state.cover_cache {
albums = cache_albums_images(albums, cover_cache).await;
}
@@ -300,7 +294,7 @@ async fn get_cache_stats(
// ============ Helpers pour le cache d'images ============
#[cfg(all(feature = "pmoserver", feature = "covers"))]
#[cfg(feature = "pmoserver")]
async fn cache_album_image(mut album: Album, cover_cache: &Arc<pmocovers::Cache>) -> Album {
if let Some(ref image_url) = album.image {
match cover_cache.add_from_url(image_url, None).await {
@@ -315,7 +309,7 @@ async fn cache_album_image(mut album: Album, cover_cache: &Arc<pmocovers::Cache>
album
}
#[cfg(all(feature = "pmoserver", feature = "covers"))]
#[cfg(feature = "pmoserver")]
async fn cache_albums_images(
albums: Vec<Album>,
cover_cache: &Arc<pmocovers::Cache>,

View File

@@ -188,6 +188,13 @@ impl QobuzCache {
/// Retourne des statistiques sur le cache
pub async fn stats(&self) -> CacheStats {
self.albums.run_pending_tasks().await;
self.tracks.run_pending_tasks().await;
self.artists.run_pending_tasks().await;
self.playlists.run_pending_tasks().await;
self.searches.run_pending_tasks().await;
self.stream_urls.run_pending_tasks().await;
CacheStats {
albums_count: self.albums.entry_count(),
tracks_count: self.tracks.entry_count(),

View File

@@ -8,8 +8,9 @@ use crate::cache::QobuzCache;
use crate::config_ext::QobuzConfigExt;
use crate::error::{QobuzError, Result};
use crate::models::*;
use pmoconfig::Config;
use std::sync::Arc;
use pmoconfig::{self, Config};
use std::future::Future;
use std::sync::{Arc, Mutex};
use tracing::{debug, info};
/// Client Qobuz haut-niveau avec cache
@@ -19,10 +20,38 @@ pub struct QobuzClient {
/// Cache en mémoire
cache: Arc<QobuzCache>,
/// Informations d'authentification
auth_info: Option<AuthInfo>,
auth_info: Mutex<Option<AuthInfo>>,
/// Identifiants utilisateur pour relogin automatique
credentials: Option<(String, String)>,
/// Configuration partagée (pour persister les tokens)
config: Option<Arc<Config>>,
#[cfg(feature = "disk-cache")]
/// Cache disque optionnel
disk_cache: Option<Arc<dyn crate::disk_cache::CacheStore>>,
}
impl QobuzClient {
fn build_client(
api: QobuzApi,
auth_info: Option<AuthInfo>,
credentials: Option<(String, String)>,
config: Option<Arc<Config>>,
) -> Self {
if let Some(info) = &auth_info {
api.set_auth_token(info.token.clone(), info.user_id.clone());
}
Self {
api,
cache: Arc::new(QobuzCache::new()),
auth_info: Mutex::new(auth_info),
credentials,
config,
#[cfg(feature = "disk-cache")]
disk_cache: None,
}
}
/// Crée un nouveau client et authentifie avec les credentials fournis
///
/// # Arguments
@@ -49,14 +78,17 @@ impl QobuzClient {
pub async fn with_app_id(app_id: &str, username: &str, password: &str) -> Result<Self> {
info!("Creating Qobuz client with app ID: {}", app_id);
let mut api = QobuzApi::new(app_id)?;
let api = QobuzApi::new(app_id)?;
let auth_info = api.login(username, password).await?;
Ok(Self {
let client = Self::build_client(
api,
cache: Arc::new(QobuzCache::new()),
auth_info: Some(auth_info),
})
Some(auth_info),
Some((username.to_string(), password.to_string())),
None,
);
Ok(client.finalize_disk_cache().await)
}
/// Crée un client en utilisant la configuration de pmoconfig
@@ -90,160 +122,94 @@ impl QobuzClient {
/// 4. Fallback ultime → utilise DEFAULT_APP_ID sans secret (requêtes signées échoueront)
pub async fn from_config_obj(config: &Config) -> Result<Self> {
let (username, password) = config.get_qobuz_credentials()?;
let credentials = (username.clone(), password.clone());
let config_arc = Arc::new(config.clone());
// Étape 0 : Essayer de réutiliser le token stocké dans la configuration
// Note: On ne vérifie PAS l'expiration - si le token est invalide, les requêtes
// échoueront avec 401/403 et déclencheront un re-login automatique
if let (Ok(Some(token)), Ok(Some(user_id))) =
(config.get_qobuz_auth_token(), config.get_qobuz_user_id())
{
info!("✓ Found stored authentication token in configuration");
// Récupérer l'App ID et le secret depuis la config pour créer l'API
let config_appid = config.get_qobuz_appid()?;
let config_secret = config.get_qobuz_secret()?;
match (config_appid, config_secret) {
(Some(app_id), Some(secret)) => match QobuzApi::with_secret(&app_id, &secret) {
Ok(mut api) => {
// Réutiliser le token de la configuration
api.set_auth_token(token.clone(), user_id.clone());
info!("✓ Reusing authentication token (no login required)");
info!(" → Token will be validated on first API request");
let auth_info = AuthInfo {
token,
user_id,
subscription_label: config.get_qobuz_subscription_label().ok().flatten(),
};
return Ok(Self {
api,
cache: Arc::new(QobuzCache::new()),
auth_info: Some(auth_info),
});
}
Err(e) => {
debug!("Failed to create API with stored credentials: {}", e);
info!("→ Credentials in config are invalid, will perform login");
// Continuer vers le login normal
}
},
_ => {
debug!("No appid/secret in config, cannot reuse token");
info!("→ Missing AppID/secret, will perform login");
// Continuer vers le login normal
}
}
} else {
debug!("No stored authentication token found in configuration, will perform login");
}
// Récupérer l'App ID et le secret depuis la config
let config_appid = config.get_qobuz_appid()?;
let config_secret = config.get_qobuz_secret()?;
// Déterminer comment créer l'API
let mut used_config_credentials = false;
let mut api = match (config_appid, config_secret) {
// Cas 1: AppID ET secret configurés → test avec authentification
(Some(app_id), Some(secret)) => {
info!(
"Creating Qobuz API with configured App ID: {} and secret",
app_id
);
match QobuzApi::with_secret(&app_id, &secret) {
Ok(mut test_api) => {
// Tenter l'authentification pour valider les credentials
debug!("Testing configured credentials with login...");
match test_api.login(&username, &password).await {
Ok(auth_info) => {
info!("✓ Configured credentials are valid");
// Sauvegarder le token dans la configuration
use std::time::{SystemTime, UNIX_EPOCH, Duration};
let expires_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ Duration::from_secs(24 * 3600).as_secs(); // 24h
if let Err(e) = config.set_qobuz_auth_info(
&auth_info.token,
&auth_info.user_id,
auth_info.subscription_label.as_deref(),
expires_at,
) {
debug!("Failed to save authentication to config: {}", e);
} else {
info!("✓ Saved authentication token to configuration");
}
// Les credentials sont valides, retourner directement
return Ok(Self {
api: test_api,
cache: Arc::new(QobuzCache::new()),
auth_info: Some(auth_info),
});
}
Err(e) if e.is_auth_error() => {
info!("✗ Configured credentials failed authentication: {}", e);
info!("→ Falling back to Spoofer to obtain new credentials...");
// Continuer vers le Spoofer (voir après le match)
}
Err(e) => {
// Autre erreur (réseau, etc.) → propager
return Err(e);
}
}
Ok(api) => {
used_config_credentials = true;
api
}
Err(e) => {
info!("✗ Failed to create API with configured credentials: {}", e);
info!(" Falling back to Spoofer...");
// Continuer vers le Spoofer
info!(
"✗ Failed to create API with configured credentials: {}. Falling back to Spoofer...",
e
);
Self::try_spoofer_fallback(config).await?
}
}
// Si on arrive ici, les credentials configurés ont échoué
// → Appel du Spoofer
Self::try_spoofer_fallback(config).await?
}
// Cas 2: Aucun ou seulement l'un des deux → utiliser directement le Spoofer
_ => {
info!("AppID or secret not configured, using Spoofer to obtain valid credentials...");
info!(
"AppID or secret not configured, using Spoofer to obtain valid credentials..."
);
Self::try_spoofer_fallback(config).await?
}
};
// Authentifier l'utilisateur
let auth_info = api.login(&username, &password).await?;
if config.is_qobuz_auth_valid() {
match (config.get_qobuz_auth_token(), config.get_qobuz_user_id()) {
(Ok(Some(token)), Ok(Some(user_id)))
if !token.is_empty() && !user_id.is_empty() =>
{
info!("✓ Reusing authentication token (optimistic, no login)");
api.set_auth_token(token.clone(), user_id.clone());
// Sauvegarder le token dans la configuration pour éviter de re-login la prochaine fois
use std::time::{Duration, SystemTime, UNIX_EPOCH};
let expires_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ Duration::from_secs(24 * 3600).as_secs(); // 24h
let auth_info = AuthInfo {
token,
user_id,
subscription_label: config.get_qobuz_subscription_label().ok().flatten(),
};
if let Err(e) = config.set_qobuz_auth_info(
&auth_info.token,
&auth_info.user_id,
auth_info.subscription_label.as_deref(),
expires_at,
) {
debug!("Failed to save authentication to config: {}", e);
} else {
info!("✓ Saved authentication token to configuration");
let client = Self::build_client(
api,
Some(auth_info),
Some(credentials.clone()),
Some(config_arc.clone()),
);
return Ok(client.finalize_disk_cache().await);
}
_ => {
debug!(
"Auth marked valid in config but token/user_id missing or invalid, performing login"
);
}
}
}
Ok(Self {
api,
cache: Arc::new(QobuzCache::new()),
auth_info: Some(auth_info),
})
// Authentifier l'utilisateur
let mut auth_result = api.login(&username, &password).await;
if used_config_credentials {
if let Err(err) = &auth_result {
if err.is_auth_error() {
info!("✗ Configured credentials failed authentication: {}", err);
info!("→ Falling back to Spoofer to obtain new credentials...");
api = Self::try_spoofer_fallback(config).await?;
auth_result = api.login(&username, &password).await;
}
}
}
let auth_info = auth_result?;
Self::persist_auth_info(config, &auth_info);
let client = Self::build_client(api, Some(auth_info), Some(credentials), Some(config_arc));
Ok(client.finalize_disk_cache().await)
}
/// Tente d'utiliser le Spoofer pour obtenir des credentials valides
@@ -281,7 +247,10 @@ impl QobuzClient {
return Ok(test_api);
}
Err(e) => {
debug!("Failed to create API with secret from {}: {}", timezone, e);
debug!(
"Failed to create API with secret from {}: {}",
timezone, e
);
continue;
}
}
@@ -298,13 +267,19 @@ impl QobuzClient {
}
}
Err(e) => {
info!("Spoofer failed to extract app_id: {}, falling back to DEFAULT_APP_ID", e);
info!(
"Spoofer failed to extract app_id: {}, falling back to DEFAULT_APP_ID",
e
);
QobuzApi::new(DEFAULT_APP_ID)
}
}
}
Err(e) => {
info!("Spoofer failed: {}, falling back to DEFAULT_APP_ID without secret", e);
info!(
"Spoofer failed: {}, falling back to DEFAULT_APP_ID without secret",
e
);
QobuzApi::new(DEFAULT_APP_ID)
}
}
@@ -321,8 +296,17 @@ impl QobuzClient {
}
/// Retourne les informations d'authentification
pub fn auth_info(&self) -> Option<&AuthInfo> {
self.auth_info.as_ref()
pub fn auth_info(&self) -> Option<AuthInfo> {
self.auth_info.lock().unwrap().clone()
}
#[cfg(feature = "disk-cache")]
fn user_id(&self) -> Option<String> {
self.auth_info
.lock()
.unwrap()
.as_ref()
.map(|info| info.user_id.clone())
}
/// Retourne une référence au cache
@@ -330,6 +314,159 @@ impl QobuzClient {
self.cache.clone()
}
#[cfg(feature = "disk-cache")]
pub async fn purge_disk_cache(&self) -> Result<usize> {
if let Some(disk) = &self.disk_cache {
disk.purge_expired()
.await
.map_err(|err| QobuzError::Cache(err.to_string()))
} else {
Ok(0)
}
}
#[cfg(feature = "disk-cache")]
pub fn with_disk_cache(mut self, store: Arc<dyn crate::disk_cache::CacheStore>) -> Self {
self.disk_cache = Some(store);
self
}
#[cfg(feature = "disk-cache")]
fn attach_default_disk_cache(mut self) -> Self {
if let Some(store) = Self::default_disk_cache_store(self.config.as_deref()) {
self = self.with_disk_cache(store);
}
self
}
#[cfg(not(feature = "disk-cache"))]
fn attach_default_disk_cache(self) -> Self {
self
}
#[cfg(feature = "disk-cache")]
async fn finalize_disk_cache(self) -> Self {
let client = self.attach_default_disk_cache();
if let Err(err) = client.purge_disk_cache().await {
debug!("Failed to purge disk cache on startup: {}", err);
}
client
}
#[cfg(not(feature = "disk-cache"))]
async fn finalize_disk_cache(self) -> Self {
self.attach_default_disk_cache()
}
#[cfg(feature = "disk-cache")]
fn default_disk_cache_store(
config: Option<&Config>,
) -> Option<Arc<dyn crate::disk_cache::CacheStore>> {
let cache_dir = match config {
Some(cfg) => match cfg.get_qobuz_cache_dir() {
Ok(dir) => dir,
Err(err) => {
debug!("Failed to read qobuz cache dir from config: {}", err);
return None;
}
},
None => {
let global = pmoconfig::get_config();
match global.get_qobuz_cache_dir() {
Ok(dir) => dir,
Err(err) => {
debug!(
"Failed to read qobuz cache dir from global config: {}",
err
);
return None;
}
}
}
};
let dir_path = std::path::PathBuf::from(&cache_dir);
if let Err(err) = std::fs::create_dir_all(&dir_path) {
debug!(
"Failed to create disk cache directory {}: {}",
dir_path.display(),
err
);
return None;
}
let db_path = dir_path.join("qobuz_cache.sqlite");
match crate::disk_cache::SqliteCacheStore::new(db_path) {
Ok(store) => {
let store: Arc<dyn crate::disk_cache::CacheStore> = Arc::new(store);
Some(store)
}
Err(err) => {
debug!("Failed to initialize SQLite disk cache: {}", err);
None
}
}
}
async fn call_with_auth_repair<T, F, Fut>(&self, operation: &str, mut op: F) -> Result<T>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T>> + Send,
{
match op().await {
Ok(result) => Ok(result),
Err(err) if err.is_auth_error() => {
info!(
"Authentication error during {}. Attempting automatic repair...",
operation
);
self.repair_auth().await?;
op().await
}
Err(err) => Err(err),
}
}
async fn repair_auth(&self) -> Result<()> {
let (username, password) = self.credentials.as_ref().cloned().ok_or_else(|| {
QobuzError::Unauthorized(
"Cannot repair authentication without stored credentials".to_string(),
)
})?;
let auth_info = self.api.login(&username, &password).await?;
if let Some(config) = &self.config {
Self::persist_auth_info(config.as_ref(), &auth_info);
}
let mut guard = self.auth_info.lock().unwrap();
*guard = Some(auth_info);
Ok(())
}
fn persist_auth_info(config: &Config, auth_info: &AuthInfo) {
use std::time::{Duration, SystemTime, UNIX_EPOCH};
let expires_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ Duration::from_secs(24 * 3600).as_secs(); // 24h
if let Err(e) = config.set_qobuz_auth_info(
&auth_info.token,
&auth_info.user_id,
auth_info.subscription_label.as_deref(),
expires_at,
) {
debug!("Failed to save authentication to config: {}", e);
} else {
info!("✓ Saved authentication token to configuration");
}
}
// ============ Albums ============
/// Récupère un album par son ID
@@ -341,7 +478,9 @@ impl QobuzClient {
}
// Sinon, récupérer depuis l'API
let album = self.api.get_album(album_id).await?;
let album = self
.call_with_auth_repair("get_album", || self.api.get_album(album_id))
.await?;
// Mettre en cache
self.cache
@@ -353,7 +492,9 @@ impl QobuzClient {
/// Récupère les tracks d'un album
pub async fn get_album_tracks(&self, album_id: &str) -> Result<Vec<Track>> {
let tracks = self.api.get_album_tracks(album_id).await?;
let tracks = self
.call_with_auth_repair("get_album_tracks", || self.api.get_album_tracks(album_id))
.await?;
// Mettre les tracks en cache
for track in &tracks {
@@ -372,7 +513,9 @@ impl QobuzClient {
return Ok(track);
}
let track = self.api.get_track(track_id).await?;
let track = self
.call_with_auth_repair("get_track", || self.api.get_track(track_id))
.await?;
self.cache
.put_track(track_id.to_string(), track.clone())
.await;
@@ -391,7 +534,9 @@ impl QobuzClient {
}
// Sinon, récupérer depuis l'API
let info = self.api.get_file_url(track_id).await?;
let info = self
.call_with_auth_repair("get_file_url", || self.api.get_file_url(track_id))
.await?;
let url = info.url.clone();
// Mettre en cache
@@ -410,7 +555,11 @@ impl QobuzClient {
}
// Pour récupérer un artiste, on doit passer par get_artist_albums
let albums = self.api.get_artist_albums(artist_id).await?;
let albums = self
.call_with_auth_repair("get_artist_albums_for_artist", || {
self.api.get_artist_albums(artist_id)
})
.await?;
if let Some(first_album) = albums.first() {
let artist = first_album.artist.clone();
@@ -428,12 +577,14 @@ impl QobuzClient {
/// Récupère les albums d'un artiste
pub async fn get_artist_albums(&self, artist_id: &str) -> Result<Vec<Album>> {
self.api.get_artist_albums(artist_id).await
self.call_with_auth_repair("get_artist_albums", || self.api.get_artist_albums(artist_id))
.await
}
/// Récupère les artistes similaires
pub async fn get_similar_artists(&self, artist_id: &str) -> Result<Vec<Artist>> {
self.api.get_similar_artists(artist_id).await
self.call_with_auth_repair("get_similar_artists", || self.api.get_similar_artists(artist_id))
.await
}
// ============ Playlists ============
@@ -445,7 +596,9 @@ impl QobuzClient {
return Ok(playlist);
}
let playlist = self.api.get_playlist(playlist_id).await?;
let playlist = self
.call_with_auth_repair("get_playlist", || self.api.get_playlist(playlist_id))
.await?;
self.cache
.put_playlist(playlist_id.to_string(), playlist.clone())
.await;
@@ -455,14 +608,18 @@ impl QobuzClient {
/// Récupère les tracks d'une playlist
pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result<Vec<Track>> {
self.api.get_playlist_tracks(playlist_id).await
self.call_with_auth_repair("get_playlist_tracks", || {
self.api.get_playlist_tracks(playlist_id)
})
.await
}
// ============ Catalogue ============
/// Récupère la liste des genres
pub async fn get_genres(&self) -> Result<Vec<Genre>> {
self.api.get_genres().await
self.call_with_auth_repair("get_genres", || self.api.get_genres())
.await
}
/// Récupère les albums featured (nouveautés, éditeur, etc.)
@@ -471,7 +628,10 @@ impl QobuzClient {
genre_id: Option<&str>,
type_: &str,
) -> Result<Vec<Album>> {
self.api.get_featured_albums(genre_id, type_).await
self.call_with_auth_repair("get_featured_albums", || {
self.api.get_featured_albums(genre_id, type_)
})
.await
}
/// Récupère les playlists featured
@@ -480,7 +640,10 @@ impl QobuzClient {
genre_id: Option<&str>,
tags: Option<&str>,
) -> Result<Vec<Playlist>> {
self.api.get_featured_playlists(genre_id, tags).await
self.call_with_auth_repair("get_featured_playlists", || {
self.api.get_featured_playlists(genre_id, tags)
})
.await
}
// ============ Recherche ============
@@ -502,7 +665,9 @@ impl QobuzClient {
}
// Sinon, rechercher via l'API
let result = self.api.search(query, type_).await?;
let result = self
.call_with_auth_repair("search", || self.api.search(query, type_))
.await?;
// Mettre en cache
self.cache.put_search(cache_key, result.clone()).await;
@@ -538,56 +703,188 @@ impl QobuzClient {
/// Récupère les albums favoris de l'utilisateur
pub async fn get_favorite_albums(&self) -> Result<Vec<Album>> {
self.api.get_favorite_albums().await
#[cfg(feature = "disk-cache")]
let user_id = self
.user_id()
.ok_or_else(|| QobuzError::Unauthorized("Missing authenticated user ID".into()))?;
#[cfg(feature = "disk-cache")]
let ttl = std::time::Duration::from_secs(6 * 3600);
#[cfg(feature = "disk-cache")]
if let Some(disk) = &self.disk_cache {
if let Some(entry) = disk
.get_json::<Vec<Album>>(&user_id, "favorites_albums", "all")
.await?
{
if entry.fresh {
return Ok(entry.value);
}
}
}
let albums = self
.call_with_auth_repair("get_favorite_albums", || self.api.get_favorite_albums())
.await?;
#[cfg(feature = "disk-cache")]
if let Some(disk) = &self.disk_cache {
let _ = disk
.put_json(&user_id, "favorites_albums", "all", ttl, &albums)
.await;
}
Ok(albums)
}
/// Récupère les artistes favoris de l'utilisateur
pub async fn get_favorite_artists(&self) -> Result<Vec<Artist>> {
self.api.get_favorite_artists().await
self.call_with_auth_repair("get_favorite_artists", || self.api.get_favorite_artists())
.await
}
/// Récupère les tracks favorites de l'utilisateur
pub async fn get_favorite_tracks(&self) -> Result<Vec<Track>> {
self.api.get_favorite_tracks().await
#[cfg(feature = "disk-cache")]
let user_id = self
.user_id()
.ok_or_else(|| QobuzError::Unauthorized("Missing authenticated user ID".into()))?;
#[cfg(feature = "disk-cache")]
let ttl = std::time::Duration::from_secs(6 * 3600);
#[cfg(feature = "disk-cache")]
if let Some(disk) = &self.disk_cache {
if let Some(entry) = disk
.get_json::<Vec<Track>>(&user_id, "favorites_tracks", "all")
.await?
{
if entry.fresh {
return Ok(entry.value);
}
}
}
let tracks = self
.call_with_auth_repair("get_favorite_tracks", || self.api.get_favorite_tracks())
.await?;
#[cfg(feature = "disk-cache")]
if let Some(disk) = &self.disk_cache {
let _ = disk
.put_json(&user_id, "favorites_tracks", "all", ttl, &tracks)
.await;
}
Ok(tracks)
}
/// Récupère les playlists de l'utilisateur
pub async fn get_user_playlists(&self) -> Result<Vec<Playlist>> {
self.api.get_user_playlists().await
#[cfg(feature = "disk-cache")]
let user_id = self
.user_id()
.ok_or_else(|| QobuzError::Unauthorized("Missing authenticated user ID".into()))?;
#[cfg(feature = "disk-cache")]
let ttl = std::time::Duration::from_secs(6 * 3600);
#[cfg(feature = "disk-cache")]
if let Some(disk) = &self.disk_cache {
if let Some(entry) = disk
.get_json::<Vec<Playlist>>(&user_id, "user_playlists", "all")
.await?
{
if entry.fresh {
return Ok(entry.value);
}
}
}
let playlists = self
.call_with_auth_repair("get_user_playlists", || self.api.get_user_playlists())
.await?;
#[cfg(feature = "disk-cache")]
if let Some(disk) = &self.disk_cache {
let _ = disk
.put_json(&user_id, "user_playlists", "all", ttl, &playlists)
.await;
}
Ok(playlists)
}
/// Ajoute un album aux favoris
pub async fn add_favorite_album(&self, album_id: &str) -> Result<()> {
self.api.add_favorite_album(album_id).await
self.call_with_auth_repair("add_favorite_album", || {
self.api.add_favorite_album(album_id)
})
.await
}
/// Supprime un album des favoris
pub async fn remove_favorite_album(&self, album_id: &str) -> Result<()> {
self.api.remove_favorite_album(album_id).await
self.call_with_auth_repair("remove_favorite_album", || {
self.api.remove_favorite_album(album_id)
})
.await
}
/// Ajoute un track aux favoris
pub async fn add_favorite_track(&self, track_id: &str) -> Result<()> {
self.api.add_favorite_track(track_id).await
self.call_with_auth_repair("add_favorite_track", || {
self.api.add_favorite_track(track_id)
})
.await
}
/// Supprime un track des favoris
pub async fn remove_favorite_track(&self, track_id: &str) -> Result<()> {
self.api.remove_favorite_track(track_id).await
self.call_with_auth_repair("remove_favorite_track", || {
self.api.remove_favorite_track(track_id)
})
.await
}
/// Ajoute un track à une playlist
pub async fn add_to_playlist(&self, playlist_id: &str, track_id: &str) -> Result<()> {
self.api.add_to_playlist(playlist_id, track_id).await
self.call_with_auth_repair("add_to_playlist", || {
self.api.add_to_playlist(playlist_id, track_id)
})
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config_ext::QobuzConfigExt;
#[test]
fn test_audio_format() {
assert_eq!(AudioFormat::default(), AudioFormat::Flac_Lossless);
}
#[tokio::test]
async fn from_config_reuses_token_without_login() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().to_string_lossy().to_string();
let config = pmoconfig::Config::load_config(&config_path).unwrap();
config.set_qobuz_username("user@example.com").unwrap();
config.set_qobuz_password("password").unwrap();
config.set_qobuz_appid("1401488693436528").unwrap();
// base64 for "secret"
config.set_qobuz_secret("c2VjcmV0").unwrap();
config
.set_qobuz_auth_info("token123", "user123", Some("Hi-Fi"), 1_700_000_000)
.unwrap();
let client = QobuzClient::from_config_obj(&config).await.unwrap();
let auth_info = client.auth_info().expect("auth info");
assert_eq!(auth_info.token, "token123");
assert_eq!(auth_info.user_id, "user123");
}
}

View File

@@ -188,6 +188,36 @@ pub trait QobuzConfigExt {
/// 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<()>;
}
impl QobuzConfigExt for Config {
@@ -343,23 +373,21 @@ impl QobuzConfigExt for Config {
}
fn is_qobuz_auth_valid(&self) -> bool {
// Vérifier si un token existe
if self.get_qobuz_auth_token().ok().flatten().is_none() {
return false;
}
let token_present = self
.get_qobuz_auth_token()
.ok()
.flatten()
.map(|token| !token.is_empty())
.unwrap_or(false);
// Vérifier si le token n'est pas expiré
if let Ok(Some(expires_at)) = self.get_qobuz_token_expires_at() {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let user_present = self
.get_qobuz_user_id()
.ok()
.flatten()
.map(|user_id| !user_id.is_empty())
.unwrap_or(false);
now < expires_at
} else {
false
}
token_present && user_present
}
fn get_qobuz_cache_dir(&self) -> Result<String> {
@@ -369,4 +397,48 @@ impl QobuzConfigExt for Config {
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),
)
}
}

View File

@@ -1,366 +1,215 @@
//! Cache disque simple pour les données volumineuses de l'API Qobuz
//!
//! Ce module gère le cache sur disque des données qui changent rarement :
//! - Favoris (albums, tracks, artistes)
//! - Playlists utilisateur
//! - Bibliothèque
//!
//! Contrairement à pmocache (conçu pour des fichiers binaires avec téléchargement),
//! ce cache est optimisé pour du JSON provenant de l'API.
#![cfg(feature = "disk-cache")]
use anyhow::{anyhow, Result};
use anyhow::anyhow;
use serde::{de::DeserializeOwned, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use tracing::{debug, info};
use std::{
path::PathBuf,
time::{Duration, SystemTime, UNIX_EPOCH},
};
/// Cache disque pour données JSON de l'API Qobuz
pub struct DiskCache {
/// Répertoire de cache
cache_dir: PathBuf,
use rusqlite::{params, Connection};
use tokio::task;
#[derive(Debug, Clone)]
pub struct CacheEntry<T> {
pub value: T,
pub age: Duration,
pub fresh: bool,
}
impl DiskCache {
/// Crée un nouveau cache disque
///
/// # Arguments
///
/// * `cache_dir` - Répertoire où stocker les fichiers cachés
///
/// # Example
///
/// ```rust,no_run
/// use pmoqobuz::disk_cache::DiskCache;
///
/// let cache = DiskCache::new(".pmomusic/cache/qobuz")?;
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn new<P: AsRef<Path>>(cache_dir: P) -> Result<Self> {
let cache_dir = cache_dir.as_ref().to_path_buf();
// Créer le répertoire s'il n'existe pas
if !cache_dir.exists() {
fs::create_dir_all(&cache_dir)?;
info!("Created cache directory: {}", cache_dir.display());
}
Ok(Self { cache_dir })
}
/// Construit le chemin d'un fichier de cache
///
/// Format: `{cache_dir}/{key}.json`
fn cache_path(&self, key: &str) -> PathBuf {
self.cache_dir.join(format!("{}.json", key))
}
/// Sauvegarde des données dans le cache
///
/// # Arguments
///
/// * `key` - Identifiant unique du cache (ex: "favorites_albums_123456")
/// * `data` - Données à sauvegarder
///
/// # Example
///
/// ```rust,no_run
/// # use pmoqobuz::disk_cache::DiskCache;
/// # use pmoqobuz::Album;
/// # let cache = DiskCache::new(".cache")?;
/// let albums: Vec<Album> = vec![/* ... */];
/// cache.save("favorites_albums_123", &albums)?;
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn save<T: Serialize>(&self, key: &str, data: &T) -> Result<()> {
let path = self.cache_path(key);
let json = serde_json::to_string_pretty(data)?;
fs::write(&path, json)?;
debug!("Saved cache to {}", path.display());
Ok(())
}
/// Charge des données depuis le cache
///
/// # Arguments
///
/// * `key` - Identifiant unique du cache
///
/// # Returns
///
/// Les données désérialisées, ou None si le cache n'existe pas
///
/// # Example
///
/// ```rust,no_run
/// # use pmoqobuz::disk_cache::DiskCache;
/// # use pmoqobuz::Album;
/// # let cache = DiskCache::new(".cache")?;
/// if let Some(albums) = cache.load::<Vec<Album>>("favorites_albums_123")? {
/// println!("Loaded {} albums from cache", albums.len());
/// }
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn load<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
let path = self.cache_path(key);
if !path.exists() {
debug!("Cache file does not exist: {}", path.display());
return Ok(None);
}
let json = fs::read_to_string(&path)?;
let data: T = serde_json::from_str(&json)?;
debug!("Loaded cache from {}", path.display());
Ok(Some(data))
}
/// Charge des données avec vérification du TTL
///
/// # Arguments
///
/// * `key` - Identifiant unique du cache
/// * `ttl` - Durée de validité maximale
///
/// # Returns
///
/// Les données si le cache existe ET n'est pas expiré, None sinon
///
/// # Example
///
/// ```rust,no_run
/// # use pmoqobuz::disk_cache::DiskCache;
/// # use pmoqobuz::Album;
/// # use std::time::Duration;
/// # let cache = DiskCache::new(".cache")?;
/// // Cache valide pendant 1 heure
/// if let Some(albums) = cache.load_with_ttl::<Vec<Album>>(
/// "favorites_albums_123",
/// Duration::from_secs(3600)
/// )? {
/// println!("Cache still valid!");
/// } else {
/// println!("Cache expired or missing");
/// }
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn load_with_ttl<T: DeserializeOwned>(
#[async_trait::async_trait]
pub trait CacheStore: Send + Sync {
async fn get_json<T: DeserializeOwned + Send>(
&self,
user_id: &str,
namespace: &str,
key: &str,
) -> anyhow::Result<Option<CacheEntry<T>>>;
async fn put_json<T: Serialize + Send + Sync>(
&self,
user_id: &str,
namespace: &str,
key: &str,
ttl: Duration,
) -> Result<Option<T>> {
let path = self.cache_path(key);
value: &T,
) -> anyhow::Result<()>;
if !path.exists() {
debug!("Cache file does not exist: {}", path.display());
return Ok(None);
}
async fn invalidate(
&self,
user_id: &str,
namespace: &str,
key: &str,
) -> anyhow::Result<()>;
// Vérifier l'âge du fichier
let metadata = fs::metadata(&path)?;
let modified = metadata.modified()?;
let age = SystemTime::now()
.duration_since(modified)
.unwrap_or(Duration::MAX);
async fn purge_expired(&self) -> anyhow::Result<usize>;
}
if age > ttl {
debug!(
"Cache expired (age: {}s > ttl: {}s): {}",
age.as_secs(),
ttl.as_secs(),
path.display()
pub struct SqliteCacheStore {
db_path: PathBuf,
}
impl SqliteCacheStore {
pub fn new(db_path: PathBuf) -> anyhow::Result<Self> {
let store = Self { db_path };
store.init_blocking()?;
Ok(store)
}
fn init_blocking(&self) -> anyhow::Result<()> {
let conn = Connection::open(&self.db_path)?;
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS qobuz_cache (
user_id TEXT NOT NULL,
namespace TEXT NOT NULL,
key TEXT NOT NULL,
fetched_at INTEGER NOT NULL,
ttl_seconds INTEGER NOT NULL,
json BLOB NOT NULL,
PRIMARY KEY (user_id, namespace, key)
);
// Optionnel : supprimer le fichier expiré
let _ = fs::remove_file(&path);
return Ok(None);
}
debug!(
"Cache valid (age: {}s < ttl: {}s): {}",
age.as_secs(),
ttl.as_secs(),
path.display()
);
let json = fs::read_to_string(&path)?;
let data: T = serde_json::from_str(&json)?;
Ok(Some(data))
}
/// Invalide (supprime) un cache
///
/// # Arguments
///
/// * `key` - Identifiant unique du cache
pub fn invalidate(&self, key: &str) -> Result<()> {
let path = self.cache_path(key);
if path.exists() {
fs::remove_file(&path)?;
debug!("Invalidated cache: {}", path.display());
}
CREATE INDEX IF NOT EXISTS qobuz_cache_expiry
ON qobuz_cache (fetched_at, ttl_seconds);
"#,
)?;
Ok(())
}
/// Supprime tous les fichiers de cache
pub fn clear_all(&self) -> Result<()> {
for entry in fs::read_dir(&self.cache_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
fs::remove_file(&path)?;
debug!("Removed cache file: {}", path.display());
}
fn now_seconds() -> i64 {
match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(duration) => i64::try_from(duration.as_secs()).unwrap_or(i64::MAX),
Err(_) => 0,
}
info!("Cleared all cache files");
Ok(())
}
/// Retourne la taille totale du cache en octets
pub fn size(&self) -> Result<u64> {
let mut total = 0u64;
for entry in fs::read_dir(&self.cache_dir)? {
let entry = entry?;
let metadata = entry.metadata()?;
if metadata.is_file() {
total += metadata.len();
}
}
Ok(total)
}
/// Retourne le nombre de fichiers en cache
pub fn count(&self) -> Result<usize> {
let mut count = 0;
for entry in fs::read_dir(&self.cache_dir)? {
let entry = entry?;
if entry.path().extension().and_then(|s| s.to_str()) == Some("json") {
count += 1;
}
}
Ok(count)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use tempfile::tempdir;
#[async_trait::async_trait]
impl CacheStore for SqliteCacheStore {
async fn get_json<T: DeserializeOwned + Send>(
&self,
user_id: &str,
namespace: &str,
key: &str,
) -> anyhow::Result<Option<CacheEntry<T>>> {
let user_id = user_id.to_owned();
let namespace = namespace.to_owned();
let key = key.to_owned();
let db_path = self.db_path.clone();
#[derive(Debug, Serialize, Deserialize, PartialEq)]
struct TestData {
id: String,
value: i32,
task::spawn_blocking(move || {
let conn = Connection::open(db_path)?;
let mut stmt = conn.prepare(
"SELECT fetched_at, ttl_seconds, json
FROM qobuz_cache
WHERE user_id = ?1 AND namespace = ?2 AND key = ?3",
)?;
let result = stmt.query_row(
params![user_id, namespace, key],
|row| {
let fetched_at: i64 = row.get(0)?;
let ttl_seconds: i64 = row.get(1)?;
let data: Vec<u8> = row.get(2)?;
let now = Self::now_seconds();
let fresh = now <= fetched_at + ttl_seconds;
let age_secs = if now >= fetched_at {
(now - fetched_at) as u64
} else {
0
};
let age = Duration::from_secs(age_secs);
let value = serde_json::from_slice(&data)?;
Ok(CacheEntry {
value,
age,
fresh,
})
},
);
match result {
Ok(entry) => Ok(Some(entry)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(err) => Err(err.into()),
}
})
.await
.map_err(|err| anyhow!(err))?
}
#[test]
fn test_save_and_load() -> Result<()> {
let dir = tempdir()?;
let cache = DiskCache::new(dir.path())?;
async fn put_json<T: Serialize + Send + Sync>(
&self,
user_id: &str,
namespace: &str,
key: &str,
ttl: Duration,
value: &T,
) -> anyhow::Result<()> {
let user_id = user_id.to_owned();
let namespace = namespace.to_owned();
let key = key.to_owned();
let db_path = self.db_path.clone();
let ttl_seconds = i64::try_from(ttl.as_secs()).unwrap_or(i64::MAX);
let now = Self::now_seconds();
let json = serde_json::to_vec(value)?;
let data = TestData {
id: "test123".to_string(),
value: 42,
};
// Sauvegarder
cache.save("test_key", &data)?;
// Charger
let loaded: Option<TestData> = cache.load("test_key")?;
assert!(loaded.is_some());
assert_eq!(loaded.unwrap(), data);
Ok(())
task::spawn_blocking(move || {
let conn = Connection::open(db_path)?;
let tx = conn.transaction()?;
tx.execute(
"INSERT OR REPLACE INTO qobuz_cache
(user_id, namespace, key, fetched_at, ttl_seconds, json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![user_id, namespace, key, now, ttl_seconds, json],
)?;
tx.commit()?;
Ok(())
})
.await
.map_err(|err| anyhow!(err))?
}
#[test]
fn test_load_nonexistent() -> Result<()> {
let dir = tempdir()?;
let cache = DiskCache::new(dir.path())?;
async fn invalidate(
&self,
user_id: &str,
namespace: &str,
key: &str,
) -> anyhow::Result<()> {
let user_id = user_id.to_owned();
let namespace = namespace.to_owned();
let key = key.to_owned();
let db_path = self.db_path.clone();
let loaded: Option<TestData> = cache.load("nonexistent")?;
assert!(loaded.is_none());
Ok(())
task::spawn_blocking(move || {
let conn = Connection::open(db_path)?;
conn.execute(
"DELETE FROM qobuz_cache
WHERE user_id = ?1 AND namespace = ?2 AND key = ?3",
params![user_id, namespace, key],
)?;
Ok(())
})
.await
.map_err(|err| anyhow!(err))?
}
#[test]
fn test_ttl() -> Result<()> {
let dir = tempdir()?;
let cache = DiskCache::new(dir.path())?;
async fn purge_expired(&self) -> anyhow::Result<usize> {
let db_path = self.db_path.clone();
let now = Self::now_seconds();
let data = TestData {
id: "test123".to_string(),
value: 42,
};
cache.save("test_key", &data)?;
// Charger immédiatement (< TTL)
let loaded: Option<TestData> =
cache.load_with_ttl("test_key", Duration::from_secs(60))?;
assert!(loaded.is_some());
// Charger avec TTL expiré
let loaded: Option<TestData> = cache.load_with_ttl("test_key", Duration::from_secs(0))?;
assert!(loaded.is_none());
Ok(())
}
#[test]
fn test_invalidate() -> Result<()> {
let dir = tempdir()?;
let cache = DiskCache::new(dir.path())?;
let data = TestData {
id: "test123".to_string(),
value: 42,
};
cache.save("test_key", &data)?;
assert!(cache.load::<TestData>("test_key")?.is_some());
cache.invalidate("test_key")?;
assert!(cache.load::<TestData>("test_key")?.is_none());
Ok(())
}
#[test]
fn test_size_and_count() -> Result<()> {
let dir = tempdir()?;
let cache = DiskCache::new(dir.path())?;
assert_eq!(cache.count()?, 0);
assert_eq!(cache.size()?, 0);
let data = TestData {
id: "test123".to_string(),
value: 42,
};
cache.save("test1", &data)?;
cache.save("test2", &data)?;
assert_eq!(cache.count()?, 2);
assert!(cache.size()? > 0);
Ok(())
task::spawn_blocking(move || {
let conn = Connection::open(db_path)?;
let changes = conn.execute(
"DELETE FROM qobuz_cache
WHERE (fetched_at + ttl_seconds) <= ?1",
params![now],
)?;
Ok(changes)
})
.await
.map_err(|err| anyhow!(err))?
}
}
pub use {CacheEntry, CacheStore, SqliteCacheStore};

View File

@@ -46,7 +46,7 @@
//! ### Exemple basique avec configuration automatique
//!
//! ```rust,no_run
//! use pmoqobuz::QobuzClient;
//! use pmoqobuz::{QobuzClient, ToDIDL};
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
@@ -66,7 +66,7 @@
//! ### Exemple avec credentials personnalisés
//!
//! ```rust,no_run
//! use pmoqobuz::QobuzClient;
//! use pmoqobuz::{QobuzClient, ToDIDL};
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
@@ -82,7 +82,7 @@
//! ### Export DIDL-Lite
//!
//! ```rust,no_run
//! use pmoqobuz::QobuzClient;
//! use pmoqobuz::{QobuzClient, ToDIDL};
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
@@ -115,19 +115,16 @@
//!
//! ```rust,no_run
//! use pmoqobuz::{QobuzSource, QobuzClient};
//! use pmoaudiocache::Cache as AudioCache;
//! use pmocovers::Cache as CoverCache;
//! use std::sync::Arc;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! let client = QobuzClient::from_config().await?;
//! let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?);
//! let audio_cache = Arc::new(AudioCache::new("./cache/audio", 100)?);
//!
//! let source = QobuzSource::new_with_cache(
//! client,
//! "http://localhost:8080",
//! Some(cover_cache),
//! None,
//! );
//! let source = QobuzSource::new(client, cover_cache, audio_cache);
//! # Ok(())
//! # }
//! ```
@@ -139,7 +136,8 @@
//! ```rust,no_run
//! use pmoqobuz::{QobuzSource, QobuzClient};
//! use pmocovers::Cache as CoverCache;
//! use pmoaudiocache::AudioCache;
//! use pmoaudiocache::Cache as AudioCache;
//! use pmosource::MusicSource;
//! use std::sync::Arc;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
@@ -147,15 +145,10 @@
//! let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?);
//! let audio_cache = Arc::new(AudioCache::new("./cache/audio", 100)?);
//!
//! let source = QobuzSource::new_with_cache(
//! client.clone(),
//! "http://localhost:8080",
//! Some(cover_cache),
//! Some(audio_cache),
//! );
//! let source = QobuzSource::new(client, cover_cache, audio_cache);
//!
//! // Add a track with caching
//! let tracks = client.get_favorite_tracks().await?;
//! let tracks = source.client().get_favorite_tracks().await?;
//! if let Some(track) = tracks.first() {
//! let track_id = source.add_track(track).await?;
//! // Audio and cover are now cached with rich metadata
@@ -218,6 +211,7 @@ pub mod cache;
pub mod client;
pub mod config_ext;
pub mod didl;
#[cfg(feature = "disk-cache")]
pub mod disk_cache;
pub mod error;
pub mod models;

View File

@@ -141,7 +141,6 @@ pub trait QobuzServerExt {
/// Ok(())
/// }
/// ```
#[cfg(feature = "covers")]
async fn init_qobuz_client_with_covers(
&mut self,
username: &str,
@@ -176,7 +175,6 @@ pub trait QobuzServerExt {
/// Ok(())
/// }
/// ```
#[cfg(feature = "covers")]
async fn init_qobuz_client_configured_with_covers(
&mut self,
cover_cache: Arc<pmocovers::Cache>,

View File

@@ -51,7 +51,6 @@ impl QobuzServerExt for Server {
// Créer l'état de l'API sans cache d'images
let state = QobuzState {
client: client.clone(),
#[cfg(feature = "covers")]
cover_cache: None,
};
@@ -75,7 +74,6 @@ impl QobuzServerExt for Server {
self.init_qobuz_client(&username, &password).await
}
#[cfg(feature = "covers")]
async fn init_qobuz_client_with_covers(
&mut self,
username: &str,
@@ -106,7 +104,6 @@ impl QobuzServerExt for Server {
Ok(client)
}
#[cfg(feature = "covers")]
async fn init_qobuz_client_configured_with_covers(
&mut self,
cover_cache: Arc<pmocovers::Cache>,

View File

@@ -40,13 +40,18 @@ const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
/// # Examples
///
/// ```no_run
/// use std::sync::Arc;
/// use pmoaudiocache::cache as audio_cache;
/// use pmocovers::cache as cover_cache;
/// use pmoqobuz::{QobuzSource, QobuzClient};
/// use pmosource::MusicSource;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = QobuzClient::from_config().await?;
/// let source = QobuzSource::new(client);
/// let cover_cache = Arc::new(cover_cache::new_cache("/tmp/qobuz_covers", 256)?);
/// let audio_cache = Arc::new(audio_cache::new_cache("/tmp/qobuz_audio", 64)?);
/// let source = QobuzSource::new(client, cover_cache, audio_cache);
///
/// println!("Source: {}", source.name());
/// println!("Supports FIFO: {}", source.supports_fifo());
@@ -214,6 +219,274 @@ impl QobuzSource {
Ok(track_id)
}
/// Add track with lazy audio caching (cover eager, audio lazy)
///
/// This method caches cover art immediately (small, needed for UI) but
/// defers audio download until the track is actually played.
///
/// # Arguments
///
/// * `track` - The Qobuz track to add
///
/// # Returns
///
/// The track ID (e.g., "qobuz://track/12345")
pub async fn add_track_lazy(&self, track: &Track) -> Result<String> {
let track_id = format!("qobuz://track/{}", track.id);
// Get streaming URL
let stream_url = self
.inner
.client
.get_stream_url(&track.id)
.await
.map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))?;
// 1. Cache cover EAGERLY (small, UI needs it)
let cached_cover_pk = if let Some(ref album) = track.album {
if let Some(ref image_url) = album.image {
self.inner.cache_manager.cache_cover(image_url).await.ok()
} else {
None
}
} else {
None
};
// 2. Prepare rich metadata from Qobuz track
let metadata = AudioMetadata {
title: Some(track.title.clone()),
artist: track.performer.as_ref().map(|p| p.name.clone()),
album: track.album.as_ref().map(|a| a.title.clone()),
duration_secs: Some(track.duration as u64),
year: track.album.as_ref().and_then(|a| {
a.release_date
.as_ref()
.and_then(|d| d.split('-').next()?.parse().ok())
}),
track_number: Some(track.track_number),
track_total: track.album.as_ref().and_then(|a| a.tracks_count),
disc_number: Some(track.media_number),
disc_total: None,
genre: track.album.as_ref().and_then(|a| {
if !a.genres.is_empty() {
Some(a.genres.join(", "))
} else {
None
}
}),
sample_rate: track.sample_rate,
channels: track.channels,
bitrate: None,
conversion: None,
};
// 3. Cache audio LAZILY (KEY CHANGE: use cache_audio_lazy)
let cached_audio_pk = self
.inner
.cache_manager
.cache_audio_lazy(&stream_url, Some(metadata))
.await
.ok();
// 4. Store metadata
self.inner
.cache_manager
.update_metadata(
track_id.clone(),
pmosource::TrackMetadata {
original_uri: stream_url,
cached_audio_pk,
cached_cover_pk,
},
)
.await;
Ok(track_id)
}
/// Load full album into pmoplaylist with lazy audio
///
/// This method fetches all tracks from a Qobuz album and adds them to a playlist
/// with lazy audio loading. Covers are downloaded eagerly, audio lazily.
///
/// # Arguments
///
/// * `playlist_id` - ID of the target playlist
/// * `album_id` - Qobuz album ID
///
/// # Returns
///
/// Number of tracks successfully added
pub async fn add_album_to_playlist(
&self,
playlist_id: &str,
album_id: &str,
) -> Result<usize> {
use tracing::{info, warn, debug};
// 1. Get tracks from Qobuz (goes through rate limiter)
let tracks = self
.inner
.client
.get_album_tracks(album_id)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
if tracks.is_empty() {
return Ok(0);
}
info!(
"Adding album {} ({} tracks) to playlist {} with lazy audio",
album_id,
tracks.len(),
playlist_id
);
// 2. Add each track lazily + collect lazy PKs
let mut lazy_pks = Vec::with_capacity(tracks.len());
for (i, track) in tracks.iter().enumerate() {
match self.add_track_lazy(track).await {
Ok(track_id) => {
debug!("Track {}/{}: {} (lazy)", i + 1, tracks.len(), track.title);
// Extract lazy PK from cache manager
if let Some(metadata) = self.inner.cache_manager.get_metadata(&track_id).await {
if let Some(audio_pk) = metadata.cached_audio_pk {
lazy_pks.push(audio_pk);
}
}
}
Err(e) => {
warn!(
"Failed to add track {} ({}): {}",
i + 1,
track.title,
e
);
// Continue with other tracks
}
}
}
// 3. Batch insert into playlist (single DB transaction)
let playlist_manager = pmoplaylist::PlaylistManager();
let writer = playlist_manager
.get_write_handle(playlist_id.to_string())
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
writer
.push_lazy_batch(lazy_pks.clone())
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
// 4. Enable lazy mode with lookahead of 2 tracks
playlist_manager.enable_lazy_mode(playlist_id, 2);
info!(
"Album {} added: {}/{} tracks",
album_id,
lazy_pks.len(),
tracks.len()
);
Ok(lazy_pks.len())
}
/// Load Qobuz playlist into pmoplaylist with lazy audio
///
/// This method fetches all tracks from a Qobuz playlist and adds them to a pmoplaylist
/// with lazy audio loading. Covers are downloaded eagerly, audio lazily.
///
/// # Arguments
///
/// * `playlist_id` - ID of the target pmoplaylist
/// * `qobuz_playlist_id` - Qobuz playlist ID
///
/// # Returns
///
/// Number of tracks successfully added
pub async fn add_qobuz_playlist_to_playlist(
&self,
playlist_id: &str,
qobuz_playlist_id: &str,
) -> Result<usize> {
use tracing::{debug, info, warn};
// 1. Get tracks from Qobuz playlist (goes through rate limiter)
let tracks = self
.inner
.client
.get_playlist_tracks(qobuz_playlist_id)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
if tracks.is_empty() {
return Ok(0);
}
info!(
"Adding Qobuz playlist {} ({} tracks) to pmoplaylist {} with lazy audio",
qobuz_playlist_id,
tracks.len(),
playlist_id
);
// 2. Add each track lazily + collect lazy PKs
let mut lazy_pks = Vec::with_capacity(tracks.len());
for (i, track) in tracks.iter().enumerate() {
match self.add_track_lazy(track).await {
Ok(track_id) => {
debug!("Track {}/{}: {} (lazy)", i + 1, tracks.len(), track.title);
// Extract lazy PK from cache manager
if let Some(metadata) = self.inner.cache_manager.get_metadata(&track_id).await {
if let Some(audio_pk) = metadata.cached_audio_pk {
lazy_pks.push(audio_pk);
}
}
}
Err(e) => {
warn!(
"Failed to add track {} ({}): {}",
i + 1,
track.title,
e
);
// Continue with other tracks
}
}
}
// 3. Batch insert into playlist (single DB transaction)
let playlist_manager = pmoplaylist::PlaylistManager();
let writer = playlist_manager
.get_write_handle(playlist_id.to_string())
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
writer
.push_lazy_batch(lazy_pks.clone())
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
// 4. Enable lazy mode with lookahead of 2 tracks
playlist_manager.enable_lazy_mode(playlist_id, 2);
info!(
"Qobuz playlist {} added: {}/{} tracks",
qobuz_playlist_id,
lazy_pks.len(),
tracks.len()
);
Ok(lazy_pks.len())
}
/// Increment update counter (called on catalog changes)
async fn increment_update_id(&self) {
let mut counter = self.inner.update_counter.write().await;