feat(qobuz): implement concurrent playlist pagination
Parallelize Qobuz playlist track fetching by increasing the page size to 500 and processing remaining pages concurrently via `futures::try_join_all` with a configurable semaphore (default 3). Results are offset-sorted to preserve original order. Adds a `page_concurrency` configuration option, updates the API client initialization, and introduces the `futures` dependency. This reduces large playlist latency from ~1.6s to ~0.7s.
This commit is contained in:
@@ -70,20 +70,17 @@ POST /track/getList
|
||||
|
||||
---
|
||||
|
||||
## 4. Pagination concurrente des playlists — **À FAIRE** (priorité moyenne)
|
||||
## 4. Pagination concurrente des playlists — **FAIT**
|
||||
|
||||
**Problème** : `pmoqobuz` charge les pages de tracks d'une playlist séquentiellement (offset=0, puis
|
||||
offset=500, etc.). Chaque requête attend la précédente.
|
||||
**Implémentation réalisée** dans `QobuzApi::get_playlist_tracks` :
|
||||
- Page size augmentée de 50 → **500** (réduit le nombre de pages de 10×)
|
||||
- Page 1 séquentielle pour obtenir `total`
|
||||
- Pages 2..N lancées en parallèle via `futures::try_join_all` + `Semaphore(3)`
|
||||
- Résultats triés par offset avant fusion — ordre playlist garanti
|
||||
- Suivi de phase 2 (`track/getList`) inchangé
|
||||
|
||||
**Ce que fait qbz** (`get_playlist`, l.1397) :
|
||||
- Page 1 → récupère les métadonnées + `total` track count
|
||||
- Pages 2..N → lancées **concurremment** via `join_all` dès que `total` est connu
|
||||
- Résultats ré-ordonnés par offset avant fusion
|
||||
|
||||
**Impact pour pmoqobuz** : une playlist de 2 000 tracks (4 pages de 500) passe de 4 requêtes
|
||||
séquentielles (~1,6 s) à 1 + 3 en parallèle (~0,7 s).
|
||||
|
||||
**Note** : à implémenter avec un semaphore (comme le CMAF) pour ne pas surcharger l'API Qobuz.
|
||||
**Impact** : playlist de 2 000 tracks (4 pages de 500) → 1 séquentielle + 3 parallèles ≈ 0,7 s
|
||||
au lieu de 4 séquentielles ≈ 1,6 s. Playlists ≤ 500 tracks : 1 seule requête.
|
||||
|
||||
---
|
||||
|
||||
@@ -122,6 +119,6 @@ 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) | **Fait** |
|
||||
| 4 | Pagination concurrente playlists | Faible | Moyen | À faire |
|
||||
| 4 | Pagination concurrente playlists | Faible | Moyen | **Fait** |
|
||||
| 5 | Release watch endpoint | Faible | Faible (catalogue) | À faire |
|
||||
| 6 | `extra=track_ids` + batch à deux passes | Faible | Faible (optimisation) | À faire |
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -4072,6 +4072,7 @@ dependencies = [
|
||||
"cbc",
|
||||
"chrono",
|
||||
"ctr",
|
||||
"futures",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"indexmap 2.12.0",
|
||||
|
||||
@@ -14,6 +14,7 @@ reqwest = { version = "0.12", features = ["json", "cookies"] }
|
||||
|
||||
# Gestion asynchrone
|
||||
tokio = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
# Sérialisation/Désérialisation JSON
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -413,46 +413,100 @@ impl QobuzApi {
|
||||
|
||||
/// 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).
|
||||
/// Phase 1 — pagination concurrente :
|
||||
/// - Page 1 séquentielle pour obtenir `total`
|
||||
/// - Pages 2..N lancées en parallèle (semaphore 3) dès que `total` est connu
|
||||
/// - Résultats triés par offset avant fusion
|
||||
///
|
||||
/// Phase 2 — enrichissement via `track/getList` pour métadonnées complètes
|
||||
/// (performer, sample_rate, bit_depth, channels).
|
||||
pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result<Vec<Track>> {
|
||||
use futures::future::try_join_all;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
const PAGE_SIZE: u32 = 500;
|
||||
const LIMIT_STR: &str = "500";
|
||||
// Configurable via accounts.qobuz.page_concurrency (défaut 3).
|
||||
let max_concurrent_pages = self.page_concurrency;
|
||||
|
||||
debug!("Fetching tracks for playlist {}", playlist_id);
|
||||
const PAGE_SIZE: u32 = 50;
|
||||
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();
|
||||
let params = [
|
||||
("playlist_id", playlist_id),
|
||||
("extra", "tracks"),
|
||||
("offset", offset_str.as_str()),
|
||||
("limit", limit_str.as_str()),
|
||||
];
|
||||
let response: PlaylistResponse = self.get("/playlist/get", ¶ms).await?;
|
||||
// Page 1 — séquentielle : récupère les IDs + total
|
||||
let first_response: PlaylistResponse = self
|
||||
.get(
|
||||
"/playlist/get",
|
||||
&[
|
||||
("playlist_id", playlist_id),
|
||||
("extra", "tracks"),
|
||||
("offset", "0"),
|
||||
("limit", LIMIT_STR),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(tracks) = response.tracks {
|
||||
let total = tracks.total.unwrap_or(0);
|
||||
let count = tracks.items.len() as u32;
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
let first_page = match first_response.tracks {
|
||||
Some(t) => t,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let total = first_page.total.unwrap_or(0);
|
||||
if total == 0 || first_page.items.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
debug!("Fetched {} track IDs for playlist {}", ordered_ids.len(), playlist_id);
|
||||
// Offsets des pages restantes : 500, 1000, 1500, ...
|
||||
let remaining_offsets: Vec<u32> = (PAGE_SIZE..total)
|
||||
.step_by(PAGE_SIZE as usize)
|
||||
.collect();
|
||||
|
||||
let n_pages = 1 + remaining_offsets.len();
|
||||
|
||||
// Pages 2..N — concurrentes
|
||||
let mut pages: Vec<(u32, Vec<TrackResponse>)> =
|
||||
Vec::with_capacity(n_pages);
|
||||
pages.push((0, first_page.items));
|
||||
|
||||
if !remaining_offsets.is_empty() {
|
||||
let sem = Arc::new(Semaphore::new(max_concurrent_pages));
|
||||
|
||||
let futs = remaining_offsets.iter().map(|&off| {
|
||||
let sem = sem.clone();
|
||||
async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
let offset_str = off.to_string();
|
||||
let response: PlaylistResponse = self
|
||||
.get(
|
||||
"/playlist/get",
|
||||
&[
|
||||
("playlist_id", playlist_id),
|
||||
("extra", "tracks"),
|
||||
("offset", offset_str.as_str()),
|
||||
("limit", LIMIT_STR),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let items = response.tracks.map(|t| t.items).unwrap_or_default();
|
||||
Ok::<(u32, Vec<TrackResponse>), QobuzError>((off, items))
|
||||
}
|
||||
});
|
||||
|
||||
let mut extra = try_join_all(futs).await?;
|
||||
pages.append(&mut extra);
|
||||
}
|
||||
|
||||
// Tri par offset pour garantir l'ordre de la playlist
|
||||
pages.sort_unstable_by_key(|(off, _)| *off);
|
||||
|
||||
let ordered_ids: Vec<String> = pages
|
||||
.into_iter()
|
||||
.flat_map(|(_, items)| items.into_iter().map(|t| t.id))
|
||||
.collect();
|
||||
|
||||
debug!(
|
||||
"Fetched {} track IDs for playlist {} ({} pages)",
|
||||
ordered_ids.len(), playlist_id, n_pages
|
||||
);
|
||||
|
||||
if ordered_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -467,7 +521,10 @@ impl QobuzApi {
|
||||
.iter()
|
||||
.filter_map(|id| track_map.remove(id.as_str()))
|
||||
.collect();
|
||||
debug!("Fetched {} tracks for playlist {} via track/getList", enriched.len(), playlist_id);
|
||||
debug!(
|
||||
"Fetched {} tracks for playlist {} via track/getList",
|
||||
enriched.len(), playlist_id
|
||||
);
|
||||
Ok(enriched)
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,8 @@ pub struct QobuzApi {
|
||||
format_id: AudioFormat,
|
||||
/// Gestionnaire de session CMAF (renouvellement automatique thread-safe)
|
||||
pub(crate) cmaf_session: CmafSessionManager,
|
||||
/// Nombre de pages de playlist chargées en parallèle (configurable)
|
||||
pub(crate) page_concurrency: usize,
|
||||
}
|
||||
|
||||
impl QobuzApi {
|
||||
@@ -119,6 +121,7 @@ impl QobuzApi {
|
||||
user_id: RwLock::new(None),
|
||||
format_id: AudioFormat::default(),
|
||||
cmaf_session: CmafSessionManager::new(),
|
||||
page_concurrency: 3,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -211,6 +214,11 @@ impl QobuzApi {
|
||||
*self.user_id.write().unwrap() = None;
|
||||
}
|
||||
|
||||
/// Définit le nombre de pages de playlist chargées en parallèle
|
||||
pub fn set_page_concurrency(&mut self, n: usize) {
|
||||
self.page_concurrency = n.max(1);
|
||||
}
|
||||
|
||||
/// Définit le format audio par défaut
|
||||
pub fn set_format(&mut self, format: AudioFormat) {
|
||||
self.format_id = format;
|
||||
|
||||
@@ -176,6 +176,8 @@ impl QobuzClient {
|
||||
}
|
||||
};
|
||||
|
||||
api.set_page_concurrency(config.get_qobuz_page_concurrency());
|
||||
|
||||
if config.is_qobuz_auth_valid() {
|
||||
match (config.get_qobuz_auth_token(), config.get_qobuz_user_id()) {
|
||||
(Ok(Some(token)), Ok(Some(user_id)))
|
||||
|
||||
@@ -254,6 +254,15 @@ pub trait QobuzConfigExt {
|
||||
///
|
||||
/// Défaut : 4 (adapté à une machine sous contrainte mémoire / Docker).
|
||||
fn get_qobuz_register_concurrency(&self) -> usize;
|
||||
|
||||
/// Nombre de pages de playlist chargées en parallèle via `/playlist/get`.
|
||||
///
|
||||
/// La page 1 est toujours séquentielle (pour obtenir `total`). Les pages
|
||||
/// suivantes sont lancées simultanément jusqu'à cette limite.
|
||||
/// Valeur trop haute → risque de rate limiting Qobuz.
|
||||
///
|
||||
/// Défaut : 3.
|
||||
fn get_qobuz_page_concurrency(&self) -> usize;
|
||||
}
|
||||
|
||||
impl QobuzConfigExt for Config {
|
||||
@@ -522,4 +531,13 @@ impl QobuzConfigExt for Config {
|
||||
_ => 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_qobuz_page_concurrency(&self) -> usize {
|
||||
match self.get_value(&["accounts", "qobuz", "page_concurrency"]) {
|
||||
Ok(Value::Number(n)) if n.as_u64().unwrap_or(0) >= 1 => {
|
||||
n.as_u64().unwrap() as usize
|
||||
}
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user