diff --git a/Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md b/Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md index 69b9902f..7ed292d0 100644 --- a/Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md +++ b/Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md @@ -112,6 +112,280 @@ sortis récemment. Utile pour le catalogue de la webapp. --- +--- + +## 7. Robustesse des requêtes et du parsing API + +Analyse comparative approfondie (`qbz/crates/qbz-qobuz/src/`) révélant quatre gaps dans +pmoqobuz par rapport à qbz. + +--- + +### 7a. Signature générique — **À FAIRE** (priorité basse, effort très faible) + +**Problème** : pmoqobuz a une fonction de signature dédiée par endpoint +(`sign_track_get_file_url`, `sign_userlib_get_albums`, `sign_track_get_list`). Chaque nouvel +endpoint signé nécessite une nouvelle fonction, avec risque de divergence silencieuse. + +**Ce que fait qbz** (`auth.rs`, l.55-60) : +```rust +fn sign_request(method_name: &str, params: &[(&str, &str)], timestamp: u64, secret: &str) -> String { + // Concatène method + pairs key+value triées alphabétiquement + timestamp + secret + // MD5 du résultat +} +``` +Tous les endpoints partagent la même logique. Ajouter un endpoint = zéro code de signature. + +**Pour pmoqobuz** : remplacer les 3 fonctions par une `sign_request` générique. +Le tri alphabétique des paramètres est implicitement respecté par nos fonctions actuelles +(vérifier que l'ordre de `sign_track_get_list` correspond bien à la convention qbz). + +--- + +### 7b. Métadonnées audio dans `TrackResponse` — **À FAIRE** (priorité haute, effort faible) + +**Problème** : `TrackResponse` (la struct de désérialisation interne) ne capte pas les champs +de qualité audio retournés par `track/get` et `track/getList` : + +``` +maximum_sampling_rate → absente de TrackResponse +maximum_bit_depth → absente de TrackResponse +hires_streamable → absente de TrackResponse +``` + +Conséquence : après notre `get_tracks_batch`, les champs `Track.sample_rate` et +`Track.bit_depth` restent `None` (ils sont `#[serde(skip)]` dans `models.rs`), alors que +l'API les a retournés. La qualité audio n'est connue qu'après lecture effective via CMAF. + +**Ce que fait qbz** (`types.rs`, l.204-215) : +```rust +pub struct Track { + pub maximum_sampling_rate: Option, // 44100.0, 96000.0, 192000.0 + pub maximum_bit_depth: Option, // 16, 24 + pub hires_streamable: bool, + ... +} +``` + +**Pour pmoqobuz** : +1. Ajouter `maximum_sampling_rate: Option`, `maximum_bit_depth: Option` à `TrackResponse` +2. Les propager dans `Track` via `parse_track` (remplacer les `#[serde(skip)]`) +3. Ces valeurs alimentent `AudioMetadata` dans `register_tracks_lazy` sans attendre la lecture + +**Impact** : les métadonnées hi-res (24-bit/96kHz) sont disponibles dès le chargement de la +playlist, pas seulement après la première lecture. + +--- + +### 7c. Parsing des restrictions de stream — **À FAIRE** (priorité moyenne, effort moyen) + +**Problème** : la réponse de `track/getFileUrl` contient un champ `restrictions[]` qui signale +des blocages (ex: `"FormatRestrictedByFormatAvailability"`, `"SampleRestrictedByRightHolders"`). +pmoqobuz ne le parse pas — un track restreint retourne une URL qui échoue silencieusement à +la lecture. + +**Ce que fait qbz** (`types.rs`, l.92-112, `client.rs`, l.1959-2012) : +```rust +pub struct StreamUrl { + pub url: String, + pub restrictions: Vec, + ... +} + +pub fn has_restrictions(&self) -> bool { + self.restrictions.iter().any(|r| { + r.code == "FormatRestrictedByFormatAvailability" + || r.code == "SampleRestrictedByRightHolders" + }) +} +``` +Si `has_restrictions()`, qbz essaie la qualité inférieure suivante (voir 7d). + +**Pour pmoqobuz** : +- Ajouter `restrictions: Vec` au parsing de `FileUrlResponse` dans `catalog.rs` +- Retourner une erreur explicite (`QobuzError::TrackRestricted`) si restrictions présentes +- Prépare la base pour le fallback de qualité (7d) + +--- + +### 7d. Fallback automatique de qualité — **À FAIRE** (priorité moyenne, effort moyen) + +**Problème** : si le format demandé (ex: Hi-Res 24-bit) n'est pas disponible pour un track, +`get_file_url` échoue. pmoqobuz n'a pas de dégradation automatique. + +**Ce que fait qbz** (`client.rs`, l.1959-2012) : +``` +UltraHiRes (27) → HiRes (7) → Lossless (6) → MP3 (5) +``` +Essaie chaque qualité jusqu'à obtenir une URL sans restrictions. Retourne +`TrackUnavailable` seulement si toutes les qualités échouent. + +**Pour pmoqobuz** : ajouter `get_file_url_with_fallback` dans `catalog.rs` qui itère sur +`[format_id_configured, 6 (lossless), 5 (mp3)]` jusqu'à succès. +Le path CMAF n'est pas concerné (format géré côté serveur). + +--- + +### 7e. Respect du header `Retry-After` sur 429 — **À FAIRE** (priorité moyenne, effort moyen) + +**Problème** : `retry.rs` classifie correctement les 429 comme transitoires, mais le backoff +est fixe (250 ms → 500 ms → 1 s). Qobuz peut indiquer un délai précis via le header +`Retry-After`. L'ignorer risque soit de retentar trop tôt (nouveau 429), soit d'attendre trop +longtemps (backoff fixe parfois plus long que nécessaire). + +**Ce que fait qbz** (`client.rs`, l.2497-2505) : +```rust +if status == StatusCode::TOO_MANY_REQUESTS { + let retry_after = response.headers() + .get(RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .unwrap_or(2); + return Err(ApiError::RateLimited(retry_after)); +} +``` +Le délai est passé à la logique de retry qui dort exactement `retry_after` secondes. + +**Pour pmoqobuz** : dans `mod.rs::handle_response`, sur 429, lire le header et +propager la valeur via une variante `QobuzError::RateLimited(u64)`. +`call_with_auth_repair` dans `client.rs` peut ensuite `tokio::time::sleep` ce délai +avant de retenter, au lieu du backoff fixe. + +--- + +--- + +## 8. Recherche — **À FAIRE** (priorité haute) + +pmoqobuz n'expose aucune recherche. qbz montre que Qobuz a un vrai moteur de recherche +multi-catégories. + +### 8a. Endpoints disponibles + +``` +GET /album/search?query=…&limit=…&offset=…[&type=…] +GET /track/search?query=…&limit=…&offset=…[&type=…] +GET /artist/search?query=…&limit=…&offset=…[&type=…] +GET /playlist/search?query=…&limit=…&offset=… +GET /catalog/search?query=…&limit=…&offset=… ← combiné (albums + tracks + artists + playlists) +``` + +Tous non-authentifiés (pas de token requis). Signature pattern : +`sign_search(method, query, limit, offset, search_type, timestamp, secret)` +où les params signés sont concaténés **dans l'ordre alphabétique** : +`limit{L}offset{O}query{Q}[type{T}]`. + +### 8b. Paramètre `type` (filtre sémantique) + +Sur album/track/artist search, le param `type` affine la signification de `query` : +- `MainArtist` — cherche dans le nom de l'artiste principal +- `Performer` — cherche dans les performers/interprètes +- `Composer` — cherche dans le compositeur +- `Label` — cherche dans le nom du label +- `ReleaseName` — cherche dans le titre de la release + +Sans `type`, la recherche est full-text sur tous les champs. + +### 8c. Structure de retour + +```rust +pub struct SearchResultsPage { + pub items: Vec, + pub total: u32, + pub offset: u32, + pub limit: u32, +} +``` + +`catalog/search` retourne un objet avec clés `albums`, `tracks`, `artists`, `playlists`, +`most_popular` — chacun étant une `SearchResultsPage` — mais qbz le désérialise en `Value` +brut (pas de struct dédiée). + +### 8d. Ce qu'il faut implémenter dans pmoqobuz + +1. `signing::sign_search(method, query, limit, offset, search_type, ts, secret) -> String` + (signature spécifique avec ordre alpha des params) +2. `QobuzApi::search_tracks(query, limit, offset, search_type) -> Result>` +3. `QobuzApi::search_albums(query, limit, offset, search_type) -> Result>` +4. `QobuzApi::search_artists(query, limit, offset) -> Result>` +5. `QobuzApi::catalog_search(query, limit, offset) -> Result` + avec `CatalogSearchResult { albums, tracks, artists, playlists }` +6. Exposer via `QobuzClient` + endpoint REST `/qobuz/search?q=…&type=track|album|artist|all` + +Priorité : **catalog_search** en premier (un seul endpoint couvre tous les cas UI). + +--- + +## 9. Découverte (Discover) et playlists éditoriales — **À FAIRE** (priorité moyenne) + +Les "Daily Q", "Weekly Q" et radios ne sont **pas** des endpoints API dynamiques distincts. +Ce sont des playlists Qobuz standard (avec des IDs fixes par compte), accessibles via +`/playlist/get`. Ce qui manque, c'est l'accès au catalogue de découverte éditorialisé. + +### 9a. Endpoints Discover + +``` +GET /discover/index?[genre_ids=112,119] ← tableau de bord +GET /discover/playlists?[tags=…&genre_ids=…]&limit=…&offset=… +GET /discover/newReleases?[genre_ids=…]&limit=…&offset=… +GET /discover/mostStreamed?[genre_ids=…]&limit=…&offset=… +GET /discover/albumOfTheWeek?[genre_ids=…] +GET /discover/pressAward?[genre_ids=…]&limit=…&offset=… +GET /discover/qobuzissims?[genre_ids=…]&limit=…&offset=… +GET /discover/idealDiscography?[genre_ids=…]&limit=…&offset=… +``` + +Tous authentifiés. Signature : `sign_request("discover{endpoint_slug}", params, ts, secret)`. + +### 9b. Tags de playlists + +``` +GET /playlist/getTags +→ Vec +``` + +Permet de filtrer `discover/playlists` par tag (`partner`, `label`, etc.). + +### 9c. Albums mis en avant + +``` +GET /album/getFeatured?type={new-releases|press-awards|most-streamed}[&genre_id=…] +→ SearchResultsPage +``` + +Alternative à `discover/newReleases` qui retourne des albums complets avec métadonnées. + +### 9d. Structure `DiscoverResponse` + +```rust +pub struct DiscoverResponse { + pub containers: DiscoverContainers, +} +pub struct DiscoverContainers { + pub playlists: Option>, + pub new_releases: Option>, + pub most_streamed: Option>, + pub qobuzissims: Option>, + pub album_of_the_week: Option>, + pub press_awards: Option>, + pub ideal_discography: Option>, + pub playlists_tags: Option>, +} +``` + +### 9e. Daily Q / Weekly Q / Radio + +Ces playlists sont des **playlists Qobuz standard** générées par Qobuz dans la bibliothèque +utilisateur. Elles apparaissent dans `getUserPlaylists` avec des noms spéciaux. Il n'y a pas +d'endpoint dédié — elles se chargent comme n'importe quelle playlist via `/playlist/get`. + +Pour les exposer, il suffit de : +1. Ajouter un filtre dans `get_user_playlists` pour identifier ces playlists (par propriétaire + `qobuz` + nom pattern) et les exposer séparément dans l'API REST +2. Ou laisser l'UI trier les playlists par propriétaire + +--- + ## Résumé de priorités | # | Amélioration | Effort | Impact | État | @@ -122,3 +396,10 @@ sortis récemment. Utile pour le catalogue de la webapp. | 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 | +| 7a | Signature générique `sign_request` | Très faible | Maintenabilité | À faire | +| 7b | Métadonnées audio dans TrackResponse | Faible | Élevé (qualité metadata) | À faire | +| 7c | Parsing restrictions stream | Moyen | Moyen (robustesse) | À faire | +| 7d | Fallback automatique de qualité | Moyen | Moyen (robustesse) | À faire | +| 7e | Respect `Retry-After` 429 | Moyen | Moyen (résilience rate limit) | À faire | +| 8 | Recherche (track/album/artist/catalog) | Moyen | Élevé (fonctionnalité manquante) | À faire | +| 9 | Discover + playlists éditoriales | Moyen | Moyen (catalogue) | À faire |