push-ltynmtvvsnkt #100

Merged
eric merged 4 commits from push-ltynmtvvsnkt into main 2026-06-11 11:15:32 +02:00
9 changed files with 230 additions and 21 deletions
Showing only changes of commit bcfffa6df8 - Show all commits

View File

@@ -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 |

2
Cargo.lock generated
View File

@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "PMOMusic"
version = "0.3.50"
version = "0.3.51"
dependencies = [
"axum 0.8.7",
"console-subscriber",

View File

@@ -1,6 +1,6 @@
[package]
name = "PMOMusic"
version = "0.3.50"
version = "0.3.51"
edition = "2024"
[dependencies]

View File

@@ -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, &timestamp, &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

View File

@@ -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,

View File

@@ -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();

View File

@@ -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", &params).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

View File

@@ -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

View File

@@ -1 +1 @@
0.3.50
0.3.51