feat: implement batch track loading via track/getList endpoint
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.
This commit is contained in:
@@ -4,6 +4,7 @@ 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 de l'API
|
||||
@@ -182,6 +183,17 @@ struct FileUrlResponse {
|
||||
format_id: u8,
|
||||
}
|
||||
|
||||
/// Réponse de l'endpoint track/getList
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TrackListResponse {
|
||||
tracks: TrackListItems,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TrackListItems {
|
||||
items: Vec<TrackResponse>,
|
||||
}
|
||||
|
||||
fn default_streamable() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -213,6 +225,66 @@ impl QobuzApi {
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les détails de plusieurs tracks en une ou plusieurs requêtes batch.
|
||||
///
|
||||
/// Utilise l'endpoint `track/getList` (max 50 IDs par appel). Les fenêtres
|
||||
/// supérieures à 50 sont découpées et appellées en série. L'ordre de sortie
|
||||
/// correspond à l'ordre des `track_ids` en entrée.
|
||||
///
|
||||
/// Requiert que le secret s4 soit configuré.
|
||||
pub async fn get_tracks_batch(&self, track_ids: &[&str]) -> Result<Vec<Track>> {
|
||||
const MAX_PER_CALL: usize = 50;
|
||||
if track_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if track_ids.len() <= MAX_PER_CALL {
|
||||
return self.get_tracks_batch_chunk(track_ids).await;
|
||||
}
|
||||
debug!("get_tracks_batch: {} IDs en fenêtres de {}", track_ids.len(), MAX_PER_CALL);
|
||||
let mut all = Vec::with_capacity(track_ids.len());
|
||||
for chunk in track_ids.chunks(MAX_PER_CALL) {
|
||||
let mut tracks = self.get_tracks_batch_chunk(chunk).await?;
|
||||
all.append(&mut tracks);
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
async fn get_tracks_batch_chunk(&self, track_ids: &[&str]) -> Result<Vec<Track>> {
|
||||
use super::signing;
|
||||
|
||||
let secret = self.secret().ok_or_else(|| {
|
||||
QobuzError::Configuration("Secret s4 requis pour track/getList".to_string())
|
||||
})?;
|
||||
|
||||
let ids_csv = track_ids.join(",");
|
||||
let timestamp = signing::get_timestamp();
|
||||
let signature = signing::sign_track_get_list(&ids_csv, ×tamp, &secret);
|
||||
|
||||
let query_params = [
|
||||
("request_ts", timestamp.as_str()),
|
||||
("request_sig", signature.as_str()),
|
||||
];
|
||||
|
||||
// Les IDs sont envoyés comme tableau d'entiers dans le body JSON
|
||||
let ids_as_numbers: Vec<u64> = track_ids
|
||||
.iter()
|
||||
.filter_map(|id| id.parse().ok())
|
||||
.collect();
|
||||
let body = serde_json::json!({ "tracks_id": ids_as_numbers });
|
||||
|
||||
debug!("get_tracks_batch_chunk POST {} IDs", track_ids.len());
|
||||
let response: TrackListResponse = self
|
||||
.post_json_with_query("/track/getList", &query_params, body)
|
||||
.await?;
|
||||
|
||||
Ok(response
|
||||
.tracks
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|t| Self::parse_track(t, None))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Récupère les détails d'une track
|
||||
pub async fn get_track(&self, track_id: &str) -> Result<Track> {
|
||||
debug!("Fetching track {}", track_id);
|
||||
@@ -339,13 +411,20 @@ impl QobuzApi {
|
||||
Ok(Self::parse_playlist(response))
|
||||
}
|
||||
|
||||
/// Récupère les tracks d'une playlist
|
||||
/// Récupère les tracks d'une playlist.
|
||||
///
|
||||
/// Phase 1 : pagination de `/playlist/get?extra=tracks` pour collecter les IDs
|
||||
/// et les données de base.
|
||||
/// Phase 2 (si secret disponible) : enrichissement via `track/getList` pour
|
||||
/// obtenir les métadonnées complètes (performer, sample_rate, bit_depth, channels).
|
||||
pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result<Vec<Track>> {
|
||||
debug!("Fetching tracks for playlist {}", playlist_id);
|
||||
const PAGE_SIZE: u32 = 50;
|
||||
let mut all_tracks = Vec::new();
|
||||
let mut ordered_ids: Vec<String> = Vec::new();
|
||||
let mut fallback_tracks: Vec<Track> = Vec::new();
|
||||
let mut offset = 0u32;
|
||||
|
||||
// Phase 1 : pagination pour collecter les IDs et les tracks de base
|
||||
loop {
|
||||
let offset_str = offset.to_string();
|
||||
let limit_str = PAGE_SIZE.to_string();
|
||||
@@ -360,7 +439,10 @@ impl QobuzApi {
|
||||
if let Some(tracks) = response.tracks {
|
||||
let total = tracks.total.unwrap_or(0);
|
||||
let count = tracks.items.len() as u32;
|
||||
all_tracks.extend(tracks.items.into_iter().map(|t| Self::parse_track(t, None)));
|
||||
for t in tracks.items {
|
||||
ordered_ids.push(t.id.clone());
|
||||
fallback_tracks.push(Self::parse_track(t, None));
|
||||
}
|
||||
offset += count;
|
||||
if count == 0 || offset >= total {
|
||||
break;
|
||||
@@ -370,8 +452,23 @@ impl QobuzApi {
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Fetched {} tracks total for playlist {}", all_tracks.len(), playlist_id);
|
||||
Ok(all_tracks)
|
||||
debug!("Fetched {} track IDs for playlist {}", ordered_ids.len(), playlist_id);
|
||||
|
||||
if ordered_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Phase 2 : enrichissement via track/getList pour métadonnées complètes
|
||||
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 {} tracks for playlist {} via track/getList", enriched.len(), playlist_id);
|
||||
Ok(enriched)
|
||||
}
|
||||
|
||||
/// Récupère la liste des genres
|
||||
|
||||
@@ -274,6 +274,37 @@ impl QobuzApi {
|
||||
self.request("POST", endpoint, params).await
|
||||
}
|
||||
|
||||
/// Effectue un POST avec signature en query params et données en JSON body.
|
||||
///
|
||||
/// Utilisé par les endpoints qui attendent une structure JSON complexe
|
||||
/// (ex: `track/getList` avec `{"tracks_id": [...]}`).
|
||||
pub(crate) async fn post_json_with_query<T: DeserializeOwned>(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
query_params: &[(&str, &str)],
|
||||
body: serde_json::Value,
|
||||
) -> Result<T> {
|
||||
let url = format!("{}{}", API_BASE_URL, endpoint);
|
||||
debug!("POST JSON {} with {} query params", url, query_params.len());
|
||||
|
||||
let app_id = self.app_id.read().unwrap().clone();
|
||||
let mut builder = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("X-App-Id", &app_id)
|
||||
.header("Accept-Language", "en,en-US;q=0.8,ko;q=0.6,zh;q=0.4,zh-CN;q=0.2")
|
||||
.header("Access-Control-Request-Headers", "x-user-auth-token,x-app-id")
|
||||
.query(query_params)
|
||||
.json(&body);
|
||||
|
||||
if let Some(token) = self.auth_token() {
|
||||
builder = builder.header("X-User-Auth-Token", token);
|
||||
}
|
||||
|
||||
let response = builder.send().await?;
|
||||
self.handle_response(response, endpoint, query_params).await
|
||||
}
|
||||
|
||||
/// Effectue une requête à l'API (générique)
|
||||
async fn request<T: DeserializeOwned>(
|
||||
&self,
|
||||
|
||||
@@ -88,6 +88,20 @@ pub fn sign_track_get_file_url(
|
||||
/// # Returns
|
||||
///
|
||||
/// Signature MD5 hexadécimale
|
||||
/// Signe une requête track/getList
|
||||
///
|
||||
/// Chaîne signée : `"trackgetList" + "tracks_id" + ids_csv + timestamp + secret`
|
||||
/// où `ids_csv` est la liste des IDs séparés par des virgules.
|
||||
pub fn sign_track_get_list(ids_csv: &str, timestamp: &str, secret: &[u8]) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(b"trackgetList");
|
||||
hasher.update(b"tracks_id");
|
||||
hasher.update(ids_csv.as_bytes());
|
||||
hasher.update(timestamp.as_bytes());
|
||||
hasher.update(secret);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn sign_userlib_get_albums(timestamp: &str, secret: &[u8]) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -86,7 +87,11 @@ impl QobuzApi {
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les tracks favorites de l'utilisateur
|
||||
/// 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);
|
||||
@@ -99,16 +104,32 @@ impl QobuzApi {
|
||||
|
||||
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?;
|
||||
|
||||
if let Some(tracks) = response.tracks {
|
||||
Ok(tracks
|
||||
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())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
.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
|
||||
|
||||
@@ -640,6 +640,44 @@ impl QobuzClient {
|
||||
Ok(track)
|
||||
}
|
||||
|
||||
/// Récupère les détails d'un batch de tracks via track/getList.
|
||||
///
|
||||
/// Les tracks retournées sont mises en cache individuellement.
|
||||
/// Voir `QobuzApi::get_tracks_batch` pour le détail du comportement.
|
||||
pub async fn get_tracks_batch(&self, track_ids: &[&str]) -> Result<Vec<Track>> {
|
||||
// Séparer les IDs déjà en cache de ceux à récupérer
|
||||
let mut cached: std::collections::HashMap<String, Track> =
|
||||
std::collections::HashMap::new();
|
||||
let mut missing_ids: Vec<&str> = Vec::new();
|
||||
|
||||
for &id in track_ids {
|
||||
if let Some(track) = self.cache.get_track(id).await {
|
||||
cached.insert(id.to_string(), track);
|
||||
} else {
|
||||
missing_ids.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if !missing_ids.is_empty() {
|
||||
let fetched = self
|
||||
.call_with_auth_repair("get_tracks_batch", || {
|
||||
self.api.get_tracks_batch(&missing_ids)
|
||||
})
|
||||
.await?;
|
||||
|
||||
for track in fetched {
|
||||
self.cache.put_track(track.id.clone(), track.clone()).await;
|
||||
cached.insert(track.id.clone(), track);
|
||||
}
|
||||
}
|
||||
|
||||
// Restituer dans l'ordre d'entrée
|
||||
Ok(track_ids
|
||||
.iter()
|
||||
.filter_map(|id| cached.remove(*id))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Récupère l'URL de streaming d'une track
|
||||
pub async fn get_stream_url(&self, track_id: &str) -> Result<String> {
|
||||
// Vérifier le cache d'abord
|
||||
|
||||
Reference in New Issue
Block a user