diff --git a/Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md b/Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md index 4b6f688d..47302dc2 100644 --- a/Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md +++ b/Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md @@ -43,10 +43,21 @@ manuellement. Qobuz peut les invalider à tout moment en changeant son bundle JS --- -## 3. Chargement batch de tracks — `track/getList` — **À FAIRE** (priorité haute) +## 3. Chargement batch de tracks — `track/getList` — **FAIT** -**Problème** : lors du chargement d'une playlist, `pmoqobuz` fait N requêtes `track/get` individuelles -(une par track). Sur une playlist de 200 tracks, c'est 200 requêtes séquentielles. +**Problème** : les tracks retournées par `/playlist/get?extra=tracks` et +`/favorite/getUserFavorites` ont des métadonnées incomplètes (parfois sans `performer`, +jamais de `sample_rate`/`bit_depth`/`channels`). Cela déclenchait des appels individuels +`get_track` lazy à la lecture. + +**Implémentation réalisée** : +- `signing::sign_track_get_list(ids_csv, timestamp, secret)` — signature MD5 pour `track/getList` +- `QobuzApi::post_json_with_query` — POST avec auth headers + query sig + JSON body +- `QobuzApi::get_tracks_batch(&[&str])` — fenêtres de 50 IDs, appels en série +- `QobuzClient::get_tracks_batch` — wrapper avec cache (skip les IDs déjà en cache) +- `get_playlist_tracks` : phase 1 pagination existante, phase 2 enrichissement si secret disponible +- `get_favorite_tracks` : même enrichissement en phase 2 +- Fallback gracieux si le secret est absent ou si `track/getList` échoue **Ce que fait qbz** (`get_tracks_batch`, l.1323) : ``` @@ -57,9 +68,6 @@ POST /track/getList - Fenêtre de 50 IDs max par appel (limite API Qobuz) - Les fenêtres supérieures à 50 sont découpées et appelées en série (respecte les quotas) -**Impact pour pmoqobuz** : réduire le temps de chargement d'une playlist de plusieurs minutes à -quelques secondes. À brancher dans `catalog.rs` et dans le chargement des favoris. - --- ## 4. Pagination concurrente des playlists — **À FAIRE** (priorité moyenne) @@ -113,7 +121,7 @@ sortis récemment. Utile pour le catalogue de la webapp. |---|---|---|---|---| | 1 | Streaming CMAF | Élevé | Critique (pipeline futur) | **Fait** | | 2 | Bundle extraction avec cache disque | Moyen | Élevé (résilience) | **Fait** | -| 3 | Batch `track/getList` | Faible | Élevé (performances) | À faire | +| 3 | Batch `track/getList` | Faible | Élevé (performances) | **Fait** | | 4 | Pagination concurrente playlists | Faible | Moyen | À faire | | 5 | Release watch endpoint | Faible | Faible (catalogue) | À faire | | 6 | `extra=track_ids` + batch à deux passes | Faible | Faible (optimisation) | À faire | diff --git a/Cargo.lock b/Cargo.lock index f119b03c..5f159b5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "PMOMusic" -version = "0.3.50" +version = "0.3.51" dependencies = [ "axum 0.8.7", "console-subscriber", diff --git a/PMOMusic/Cargo.toml b/PMOMusic/Cargo.toml index b37f9d90..2f8ba0ef 100644 --- a/PMOMusic/Cargo.toml +++ b/PMOMusic/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "PMOMusic" -version = "0.3.50" +version = "0.3.51" edition = "2024" [dependencies] diff --git a/pmoqobuz/src/api/catalog.rs b/pmoqobuz/src/api/catalog.rs index b0a3b10d..683e67bf 100644 --- a/pmoqobuz/src/api/catalog.rs +++ b/pmoqobuz/src/api/catalog.rs @@ -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, +} + 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> { + 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> { + 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 = 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 { 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> { debug!("Fetching tracks for playlist {}", playlist_id); const PAGE_SIZE: u32 = 50; - let mut all_tracks = Vec::new(); + let mut ordered_ids: Vec = Vec::new(); + let mut fallback_tracks: Vec = 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 = + full_tracks.into_iter().map(|t| (t.id.clone(), t)).collect(); + let enriched: Vec = 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 diff --git a/pmoqobuz/src/api/mod.rs b/pmoqobuz/src/api/mod.rs index d11ee16a..e570cae4 100644 --- a/pmoqobuz/src/api/mod.rs +++ b/pmoqobuz/src/api/mod.rs @@ -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( + &self, + endpoint: &str, + query_params: &[(&str, &str)], + body: serde_json::Value, + ) -> Result { + 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( &self, diff --git a/pmoqobuz/src/api/signing.rs b/pmoqobuz/src/api/signing.rs index e4f5889c..f2909fb7 100644 --- a/pmoqobuz/src/api/signing.rs +++ b/pmoqobuz/src/api/signing.rs @@ -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(); diff --git a/pmoqobuz/src/api/user.rs b/pmoqobuz/src/api/user.rs index c8b23ced..2d636def 100644 --- a/pmoqobuz/src/api/user.rs +++ b/pmoqobuz/src/api/user.rs @@ -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> { 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 = 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 = 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 = + full_tracks.into_iter().map(|t| (t.id.clone(), t)).collect(); + let enriched: Vec = 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 diff --git a/pmoqobuz/src/client.rs b/pmoqobuz/src/client.rs index 9fbf0caf..8b976415 100644 --- a/pmoqobuz/src/client.rs +++ b/pmoqobuz/src/client.rs @@ -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> { + // Séparer les IDs déjà en cache de ceux à récupérer + let mut cached: std::collections::HashMap = + 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 { // Vérifier le cache d'abord diff --git a/version.txt b/version.txt index cd3dcaaf..6b9aa4e6 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.3.50 +0.3.51