Replaces sequential track requests with a chunked batch enrichment workflow using MD5-signed POST requests (50-ID windows). Introduces cache-first fetching, order preservation via HashMap lookups, and graceful fallbacks for playlist and favorite loading.
298 lines
9.9 KiB
Rust
298 lines
9.9 KiB
Rust
//! Module d'accès aux données utilisateur (favoris)
|
|
|
|
use super::catalog::{AlbumResponse, ArtistResponse, PlaylistResponse, TrackResponse};
|
|
use super::QobuzApi;
|
|
use crate::error::{QobuzError, Result};
|
|
use crate::models::*;
|
|
use serde::Deserialize;
|
|
use std::collections::HashMap;
|
|
use tracing::debug;
|
|
|
|
/// Réponse paginée
|
|
#[derive(Debug, Deserialize)]
|
|
struct PaginatedResponse<T> {
|
|
items: Vec<T>,
|
|
}
|
|
|
|
/// Réponse de l'endpoint /favorite/getUserFavorites
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct FavoritesResponse {
|
|
#[serde(default)]
|
|
albums: Option<PaginatedResponse<AlbumResponse>>,
|
|
#[serde(default)]
|
|
artists: Option<PaginatedResponse<ArtistResponse>>,
|
|
#[serde(default)]
|
|
tracks: Option<PaginatedResponse<TrackResponse>>,
|
|
}
|
|
|
|
/// Réponse de l'endpoint /playlist/getUserPlaylists
|
|
#[derive(Debug, Deserialize)]
|
|
struct UserPlaylistsResponse {
|
|
playlists: PaginatedResponse<PlaylistResponse>,
|
|
}
|
|
|
|
impl QobuzApi {
|
|
/// Vérifie que l'utilisateur est authentifié
|
|
fn ensure_authenticated(&self) -> Result<String> {
|
|
self.user_id()
|
|
.ok_or_else(|| QobuzError::Unauthorized("Not authenticated".to_string()))
|
|
}
|
|
|
|
/// Récupère les albums favoris de l'utilisateur
|
|
pub async fn get_favorite_albums(&self) -> Result<Vec<Album>> {
|
|
let user_id = self.ensure_authenticated()?;
|
|
debug!("Fetching favorite albums for user {}", user_id);
|
|
|
|
let params = [
|
|
("user_id", user_id.as_str()),
|
|
("type", "albums"),
|
|
("limit", "1000"),
|
|
];
|
|
|
|
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?;
|
|
|
|
if let Some(albums) = response.albums {
|
|
Ok(albums
|
|
.items
|
|
.into_iter()
|
|
.map(QobuzApi::parse_album)
|
|
.filter(|a| a.streamable)
|
|
.collect())
|
|
} else {
|
|
Ok(Vec::new())
|
|
}
|
|
}
|
|
|
|
/// Récupère les artistes favoris de l'utilisateur
|
|
pub async fn get_favorite_artists(&self) -> Result<Vec<Artist>> {
|
|
let user_id = self.ensure_authenticated()?;
|
|
debug!("Fetching favorite artists for user {}", user_id);
|
|
|
|
let params = [
|
|
("user_id", user_id.as_str()),
|
|
("type", "artists"),
|
|
("limit", "1000"),
|
|
];
|
|
|
|
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?;
|
|
|
|
if let Some(artists) = response.artists {
|
|
Ok(artists
|
|
.items
|
|
.into_iter()
|
|
.map(QobuzApi::parse_artist)
|
|
.collect())
|
|
} else {
|
|
Ok(Vec::new())
|
|
}
|
|
}
|
|
|
|
/// Récupère les tracks favorites de l'utilisateur.
|
|
///
|
|
/// Si le secret s4 est disponible, les données de base retournées par
|
|
/// `/favorite/getUserFavorites` sont enrichies via `track/getList` pour
|
|
/// obtenir les métadonnées complètes (performer, sample_rate, bit_depth).
|
|
pub async fn get_favorite_tracks(&self) -> Result<Vec<Track>> {
|
|
let user_id = self.ensure_authenticated()?;
|
|
debug!("Fetching favorite tracks for user {}", user_id);
|
|
|
|
let params = [
|
|
("user_id", user_id.as_str()),
|
|
("type", "tracks"),
|
|
("limit", "1000"),
|
|
];
|
|
|
|
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?;
|
|
|
|
let base_tracks: Vec<Track> = match response.tracks {
|
|
Some(tracks) => tracks
|
|
.items
|
|
.into_iter()
|
|
.map(|t| QobuzApi::parse_track(t, None))
|
|
.filter(|t| t.streamable)
|
|
.collect(),
|
|
None => return Ok(Vec::new()),
|
|
};
|
|
|
|
if base_tracks.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
// Enrichissement via track/getList pour métadonnées complètes
|
|
let ordered_ids: Vec<String> = base_tracks.iter().map(|t| t.id.clone()).collect();
|
|
let id_refs: Vec<&str> = ordered_ids.iter().map(|s| s.as_str()).collect();
|
|
let full_tracks = self.get_tracks_batch(&id_refs).await?;
|
|
let mut track_map: HashMap<String, Track> =
|
|
full_tracks.into_iter().map(|t| (t.id.clone(), t)).collect();
|
|
let enriched: Vec<Track> = ordered_ids
|
|
.iter()
|
|
.filter_map(|id| track_map.remove(id.as_str()))
|
|
.collect();
|
|
debug!("Fetched {} favorite tracks via track/getList", enriched.len());
|
|
Ok(enriched)
|
|
}
|
|
|
|
/// Récupère les playlists de l'utilisateur
|
|
pub async fn get_user_playlists(&self) -> Result<Vec<Playlist>> {
|
|
let user_id = self.ensure_authenticated()?;
|
|
debug!("Fetching playlists for user {}", user_id);
|
|
|
|
let params = [("user_id", user_id.as_str()), ("limit", "1000")];
|
|
|
|
let response: UserPlaylistsResponse =
|
|
self.get("/playlist/getUserPlaylists", ¶ms).await?;
|
|
|
|
Ok(response
|
|
.playlists
|
|
.items
|
|
.into_iter()
|
|
.map(QobuzApi::parse_playlist)
|
|
.collect())
|
|
}
|
|
|
|
/// Ajoute un album aux favoris
|
|
pub async fn add_favorite_album(&self, album_id: &str) -> Result<()> {
|
|
let user_id = self.ensure_authenticated()?;
|
|
debug!(
|
|
"Adding album {} to favorites for user {}",
|
|
album_id, user_id
|
|
);
|
|
|
|
let params = [("album_id", album_id), ("user_id", user_id.as_str())];
|
|
|
|
self.get::<serde_json::Value>("/favorite/create", ¶ms)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Supprime un album des favoris
|
|
pub async fn remove_favorite_album(&self, album_id: &str) -> Result<()> {
|
|
let user_id = self.ensure_authenticated()?;
|
|
debug!(
|
|
"Removing album {} from favorites for user {}",
|
|
album_id, user_id
|
|
);
|
|
|
|
let params = [("album_ids", album_id), ("user_id", user_id.as_str())];
|
|
|
|
self.get::<serde_json::Value>("/favorite/delete", ¶ms)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Ajoute un track aux favoris
|
|
pub async fn add_favorite_track(&self, track_id: &str) -> Result<()> {
|
|
let user_id = self.ensure_authenticated()?;
|
|
debug!(
|
|
"Adding track {} to favorites for user {}",
|
|
track_id, user_id
|
|
);
|
|
|
|
let params = [("track_id", track_id), ("user_id", user_id.as_str())];
|
|
|
|
self.get::<serde_json::Value>("/favorite/create", ¶ms)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Supprime un track des favoris
|
|
pub async fn remove_favorite_track(&self, track_id: &str) -> Result<()> {
|
|
let user_id = self.ensure_authenticated()?;
|
|
debug!(
|
|
"Removing track {} from favorites for user {}",
|
|
track_id, user_id
|
|
);
|
|
|
|
let params = [("track_ids", track_id), ("user_id", user_id.as_str())];
|
|
|
|
self.get::<serde_json::Value>("/favorite/delete", ¶ms)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Ajoute un track à une playlist
|
|
pub async fn add_to_playlist(&self, playlist_id: &str, track_id: &str) -> Result<()> {
|
|
let user_id = self.ensure_authenticated()?;
|
|
debug!(
|
|
"Adding track {} to playlist {} for user {}",
|
|
track_id, playlist_id, user_id
|
|
);
|
|
|
|
let params = [("playlist_id", playlist_id), ("track_ids", track_id)];
|
|
|
|
self.get::<serde_json::Value>("/playlist/addTracks", ¶ms)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Récupère la liste des albums de la bibliothèque utilisateur
|
|
///
|
|
/// Cette méthode nécessite un secret s4 pour signer la requête.
|
|
/// Elle est principalement utilisée pour tester la validité d'un secret.
|
|
///
|
|
/// Dans le code Python, cette méthode est utilisée par `setSec()` pour
|
|
/// tester chaque secret retourné par le Spoofer.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Retourne `QobuzError::Configuration` si le secret n'est pas configuré.
|
|
/// Retourne `QobuzError::Unauthorized` si l'utilisateur n'est pas authentifié.
|
|
pub async fn userlib_get_albums(&self) -> Result<FavoritesResponse> {
|
|
use super::signing;
|
|
|
|
// Vérifier l'authentification
|
|
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 timestamp = signing::get_timestamp();
|
|
|
|
// Signer la requête (comme Python: userlib_getAlbums)
|
|
let signature = signing::sign_userlib_get_albums(×tamp, &secret);
|
|
|
|
let app_id = self.app_id();
|
|
|
|
debug!(
|
|
"Signing userLibrary/getAlbumsList: app_id={}, ts={}",
|
|
app_id, timestamp
|
|
);
|
|
|
|
// Construire les paramètres signés
|
|
let user_auth_token = self
|
|
.auth_token()
|
|
.ok_or_else(|| QobuzError::Unauthorized("No auth token".to_string()))?;
|
|
|
|
let params = [
|
|
("app_id", app_id.as_str()),
|
|
("user_auth_token", user_auth_token.as_str()),
|
|
("request_ts", timestamp.as_str()),
|
|
("request_sig", signature.as_str()),
|
|
];
|
|
|
|
// Utiliser POST (comme Python)
|
|
self.post("/userLibrary/getAlbumsList", ¶ms).await
|
|
}
|
|
|
|
/// Teste si un secret est valide en essayant de récupérer les albums
|
|
///
|
|
/// 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 {
|
|
// Sauvegarder le secret actuel
|
|
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
|
|
// Pour l'instant, on ne peut pas modifier self dans cette méthode
|
|
// TODO: Refactoriser pour permettre de tester les secrets
|
|
|
|
// Restaurer le secret original
|
|
false
|
|
}
|
|
}
|