diff --git a/.kilo/package-lock.json b/.kilo/package-lock.json
new file mode 100644
index 00000000..e3c64234
--- /dev/null
+++ b/.kilo/package-lock.json
@@ -0,0 +1,380 @@
+{
+ "name": ".kilo",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "@kilocode/plugin": "7.3.41"
+ }
+ },
+ "node_modules/@kilocode/plugin": {
+ "version": "7.3.41",
+ "resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.3.41.tgz",
+ "integrity": "sha512-1Ku7BEzxAGtegjf86yuu28swVD/AFjngyjhjqHWjHcPOv57pg0G+kfQz9JInxjeGwGwwrZ/q93aBqMhdibUEvw==",
+ "license": "MIT",
+ "dependencies": {
+ "@kilocode/sdk": "7.3.41",
+ "effect": "4.0.0-beta.59",
+ "zod": "4.1.8"
+ },
+ "peerDependencies": {
+ "@opentui/core": ">=0.2.6",
+ "@opentui/keymap": ">=0.2.6",
+ "@opentui/solid": ">=0.2.6"
+ },
+ "peerDependenciesMeta": {
+ "@opentui/core": {
+ "optional": true
+ },
+ "@opentui/keymap": {
+ "optional": true
+ },
+ "@opentui/solid": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@kilocode/sdk": {
+ "version": "7.3.41",
+ "resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.3.41.tgz",
+ "integrity": "sha512-BVbsjOZTjyPcHGsiwJGRAwWcZyLx28BqdVC/JldZrtVH4f+eKsOXot/d0iYquu+zYUHFarAcF+QZGwOK5jc53Q==",
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "7.0.6"
+ }
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
+ "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
+ "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
+ "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
+ "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
+ "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
+ "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/effect": {
+ "version": "4.0.0-beta.59",
+ "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.59.tgz",
+ "integrity": "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "fast-check": "^4.6.0",
+ "find-my-way-ts": "^0.1.6",
+ "ini": "^6.0.0",
+ "kubernetes-types": "^1.30.0",
+ "msgpackr": "^1.11.9",
+ "multipasta": "^0.2.7",
+ "toml": "^4.1.1",
+ "uuid": "^13.0.0",
+ "yaml": "^2.8.3"
+ }
+ },
+ "node_modules/fast-check": {
+ "version": "4.8.0",
+ "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz",
+ "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "pure-rand": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=12.17.0"
+ }
+ },
+ "node_modules/find-my-way-ts": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
+ "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
+ "license": "MIT"
+ },
+ "node_modules/ini": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
+ "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
+ "license": "ISC",
+ "engines": {
+ "node": "^20.17.0 || >=22.9.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/kubernetes-types": {
+ "version": "1.30.0",
+ "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
+ "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/msgpackr": {
+ "version": "1.12.1",
+ "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz",
+ "integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==",
+ "license": "MIT",
+ "optionalDependencies": {
+ "msgpackr-extract": "^3.0.2"
+ }
+ },
+ "node_modules/msgpackr-extract": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
+ "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "node-gyp-build-optional-packages": "5.2.2"
+ },
+ "bin": {
+ "download-msgpackr-prebuilds": "bin/download-prebuilds.js"
+ },
+ "optionalDependencies": {
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
+ "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
+ "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
+ "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
+ "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
+ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
+ }
+ },
+ "node_modules/multipasta": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
+ "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
+ "license": "MIT"
+ },
+ "node_modules/node-gyp-build-optional-packages": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
+ "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^2.0.1"
+ },
+ "bin": {
+ "node-gyp-build-optional-packages": "bin.js",
+ "node-gyp-build-optional-packages-optional": "optional.js",
+ "node-gyp-build-optional-packages-test": "build-test.js"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pure-rand": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
+ "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/toml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
+ "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "13.0.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
+ "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/yaml": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.1.8",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md b/Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md
index 47302dc2..b0d8c2de 100644
--- a/Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md
+++ b/Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md
@@ -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.
---
@@ -115,6 +112,314 @@ 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 UPnP contextuelle — **À FAIRE** (priorité haute)
+
+### 8a. Principe : le ContainerID détermine le scope et le type
+
+L'action UPnP `Search(ContainerID, SearchCriteria)` passe déjà le container d'origine.
+On l'utilise pour décider quoi chercher et où, plutôt que de parser `SearchCriteria`.
+
+**Mapping ContainerID → (scope, type)** :
+
+| ContainerID | Scope | Type | Endpoint Qobuz |
+|---|---|---|---|
+| `qobuz`, `qobuz:discover`, `qobuz:discover:*`, `qobuz:genres`, `qobuz:genre:*` | Catalog | All | `/catalog/search` → containers groupés |
+| `qobuz:discover:artists` | Catalog | Artists | `/artist/search` |
+| `qobuz:discover:albums:*` | Catalog | Albums | `/album/search` |
+| `qobuz:favorites` | UserLibrary | All | filtre cache → containers groupés |
+| `qobuz:favorites:albums` | UserLibrary | Albums | filtre cache albums |
+| `qobuz:favorites:tracks` | UserLibrary | Tracks | filtre cache tracks |
+| `qobuz:favorites:artists` | UserLibrary | Artists | filtre cache artistes |
+| `qobuz:favorites:playlists` | UserLibrary | Playlists | filtre cache playlists |
+
+**SearchCriteria** : extrait le texte brut — `dc:title contains "Pink Floyd"` → `"Pink Floyd"`,
+`upnp:artist contains "Miles"` → `"Miles"`, chaîne nue ou `*` → passé tel quel.
+
+### 8b. Types dans `pmosource`
+
+```rust
+pub enum SearchScope { Catalog, UserLibrary }
+
+pub enum MediaSearchType { All, Tracks, Albums, Artists, Playlists }
+
+pub struct SearchQuery {
+ pub text: String,
+ pub media_type: MediaSearchType,
+ pub scope: SearchScope,
+ pub limit: u32,
+ pub offset: u32,
+}
+```
+
+Le trait `MusicSource::search()` passe de `&str` à `&SearchQuery`.
+
+### 8c. Résultats groupés via containers virtuels navigables
+
+Quand `media_type = All`, `search()` retourne des containers virtuels :
+
+```
+BrowseResult::Containers([
+ Container { id: "qobuz:search:catalog:Pink Floyd:albums", title: "Albums (12)", ... },
+ Container { id: "qobuz:search:catalog:Pink Floyd:artists", title: "Artistes (3)", ... },
+ Container { id: "qobuz:search:catalog:Pink Floyd:tracks", title: "Titres (47)", ... },
+ Container { id: "qobuz:search:catalog:Pink Floyd:playlists",title: "Playlists (2)", ... },
+])
+```
+
+**Format d'ID** : `qobuz:search:{scope}:{type}:{query}` — parsé avec `splitn(5, ':')` pour
+que la query puisse contenir des `:` sans ambiguïté.
+
+Quand le control point browse dans `qobuz:search:catalog:Pink Floyd:albums`, `browse()` de
+`QobuzSource` reconnaît le pattern, re-exécute `/album/search?query=Pink+Floyd` (le cache API
+absorbe les appels redondants) et retourne les items directement.
+
+### 8d. Recherche dans les favoris (UserLibrary)
+
+Pas d'endpoint Qobuz — filtre client-side sur le cache. Pour chaque type :
+- `get_favorite_albums()`, `get_favorite_tracks()`, `get_favorite_artists()`, `get_user_playlists()`
+- Filtre : `title.to_lowercase().contains(&query.to_lowercase())` ou sur `artist.name`
+
+Si le cache est chaud → instantané. Sinon charge les favoris avant de filtrer.
+
+### 8e. Endpoints Qobuz utilisés
+
+```
+GET /catalog/search?query=…&limit=… → All types (catalog scope)
+GET /album/search?query=… → Albums only
+GET /track/search?query=… → Tracks only
+GET /artist/search?query=… → Artists only
+GET /playlist/search?query=… → Playlists only
+```
+
+Déjà partiellement implémentés : `QobuzApi::search(query, type_)` passe `type_` au param
+`type` de `/catalog/search`. Il faut ajouter les endpoints dédiés `/album/search` etc. pour
+les recherches typées — ils ont leur propre signature et des params de pagination corrects.
+
+### 8f. Fichiers à modifier
+
+| Fichier | Changement |
+|---|---|
+| `pmosource/src/lib.rs` | Ajouter `SearchQuery`, `SearchScope`, `MediaSearchType` ; changer signature `search()` |
+| `pmomediaserver/src/content_handler.rs` | Parser `container_id` → `SearchQuery` ; parser `SearchCriteria` |
+| `pmoqobuz/src/api/catalog.rs` | Ajouter `search_albums`, `search_tracks`, `search_artists`, `search_playlists` |
+| `pmoqobuz/src/client.rs` | Wrappers typés avec cache |
+| `pmoqobuz/src/source.rs` | Réécrire `search()` + étendre `browse()` pour les virtual containers |
+
+---
+
+## 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,6 +427,13 @@ 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 |
+| 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 |
diff --git a/Blackboard/Todo/search_navigation_bug.md b/Blackboard/Todo/search_navigation_bug.md
new file mode 100644
index 00000000..bf0dd300
--- /dev/null
+++ b/Blackboard/Todo/search_navigation_bug.md
@@ -0,0 +1,108 @@
+# Bug : Navigation dans les résultats de recherche Qobuz
+
+## Symptôme
+
+1. On tape "Camille" dans la barre de recherche → spinner → 4 containers s'affichent : "Albums (1000+)", "Artistes (1000+)", "Titres (1000+)", "Playlists (1000+)". ✓
+2. On clique sur "Artistes (1000+)" → on retombe sur les **mêmes 4 containers** au lieu de la liste des artistes. ✗
+3. La breadcrumb en haut montre bien qu'on est dans "Artistes (1000+)" — donc la navigation a eu lieu, mais le contenu affiché est wrong.
+
+## Cause racine identifiée (côté frontend)
+
+Dans `pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue` :
+
+```typescript
+const isSearchMode = computed(() => searchQuery.value !== '');
+
+const browseData = computed(() =>
+ isSearchMode.value
+ ? searchResults.value // ← toujours ça quand on est en search mode
+ : getBrowseCached(props.serverId, props.containerId),
+);
+```
+
+Quand `isSearchMode` est true (après une recherche), `browseData` retourne TOUJOURS `searchResults` (les 4 groupes), peu importe le `containerId` courant. Donc cliquer sur "Artistes" change `containerId` → le watcher charge bien les artistes du serveur dans le cache → mais `browseData` ignore le cache et re-affiche `searchResults`.
+
+Le serveur de son côté fonctionne correctement :
+- Browse de `qobuz:search:catalog:artists:camille` → appelle `execute_search(Artists, "camille")` → retourne la liste des artistes
+- Le log DIDL confirme que les bons artistes sont retournés
+
+## Ce qui a été tenté (et raté)
+
+### Tentative : stocker les résultats dans browseCache
+
+`searchServer()` dans `useMediaServers.ts` modifié pour ne plus écrire dans `searchResults` mais directement dans `browseCache` sous la clé `search:camille`, puis naviguer vers cet ID.
+
+Résultat : `isSearchMode` devient toujours false (searchQuery jamais set), donc `browseData` utilise `getBrowseCached`. Mais les 4 groupes n'apparaissent plus. Cause non confirmée — probablement un problème de réactivité Vue ou de timing entre le navigate et le watcher.
+
+**État actuel du code** : ce fix a été partiellement appliqué (voir commits récents). `handleSearch` appelle encore l'ancienne `searchServer()`. Le code est dans un état incohérent — voir diff.
+
+## Architecture correcte (UPnP)
+
+L'utilisateur a clarifié l'architecture attendue :
+
+1. **Media server** : implémente correctement l'action UPnP `Search` — retourne un DIDL contenant des containers virtuels navigables (les 4 groupes). Les IDs de ces containers (`qobuz:search:catalog:artists:camille`, etc.) sont opaques pour le control point.
+
+2. **GetSearchCapabilities** : doit retourner des caps non vides pour que les control points (BubbleUPnP, PMOMusic frontend) reconnaissent le serveur comme searchable. **BubbleUPnP ne reconnaissait pas PMOMusic comme searchable avant les modifications récentes.**
+
+3. **Control point / Frontend** : envoie `Search(ContainerID, SearchCriteria)` → reçoit DIDL avec des containers → les navigue via Browse normalement. Le control point ne connaît RIEN des IDs internes Qobuz.
+
+4. **Pas de endpoint `/search` spécifique Qobuz** dans le control point — c'est l'action UPnP standard `Search` qui fait tout.
+
+## Fix correct à implémenter
+
+### Côté frontend (`MediaBrowser.vue` + `useMediaServers.ts`)
+
+Supprimer `isSearchMode`, `searchResults`, `searchQuery`. Remplacer par :
+
+```typescript
+// browseData devient simplement :
+const browseData = computed(() =>
+ getBrowseCached(props.serverId, props.containerId)
+);
+```
+
+`searchServer()` doit stocker dans `browseCache` sous l'ID retourné par le serveur et naviguer vers cet ID. Quand l'utilisateur clique ensuite sur un sous-container (Artistes, Albums…), `browseContainer` est appelé avec l'ID correct, le serveur retourne les bons résultats, le cache est peuplé, `browseData` l'affiche.
+
+La clé : **sortir du search mode dès que la navigation a eu lieu**. Ce que `isSearchMode` empêche actuellement.
+
+### Côté serveur (`pmocontrol/src/pmoserver_ext.rs`)
+
+L'endpoint REST `/servers/{id}/search` appelle `server.search("0", query, 0, 200)` via UPnP Search. Il retourne actuellement `container_id: "search"` (fictif).
+
+Il devrait retourner le vrai `container_id` issu du DIDL (ex: `qobuz:search:catalog:all:camille`) pour que le frontend puisse le mettre dans le cache et naviguer vers un ID que le serveur reconnaît lors d'un Browse ultérieur.
+
+**Mais** : mettre la logique de construction de cet ID dans le control point viole la séparation des couches. La bonne approche est que le serveur retourne dans le DIDL des containers avec des IDs navigables, et que le control point les utilise tels quels.
+
+### Vérifier aussi
+
+- `GetSearchCapabilities` dans `pmomediaserver/src/content_handler.rs` retourne `"dc:title,dc:creator,upnp:artist,upnp:album,upnp:genre"` — vérifier que c'est bien annoncé dans le service descriptor UPnP (sinon BubbleUPnP ne propose pas la recherche).
+
+## Fichiers clés
+
+| Fichier | Rôle |
+|---|---|
+| `pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue` | Bug `isSearchMode` / `browseData` |
+| `pmoapp/webapp/src/composables/useMediaServers.ts` | `searchServer()`, `browseCache` |
+| `pmoapp/webapp/src/services/pmocontrol/api.ts` | Appel REST `/search` |
+| `pmocontrol/src/pmoserver_ext.rs` | Handler REST `search_server()` |
+| `pmomediaserver/src/contentdirectory/handlers.rs` | UPnP `search_handler()`, logs `━━━ SEARCH ━━━` |
+| `pmomediaserver/src/content_handler.rs` | `ContentHandler::search()` |
+| `pmoqobuz/src/source.rs` | `search_grouped()`, `execute_search()`, `parse_object_id()` |
+
+## Format des IDs virtuels Qobuz
+
+```
+qobuz:search:{scope}:{type}:{query}
+ scope : catalog | favorites
+ type : all | albums | artists | tracks | playlists
+ query : texte libre (peut contenir ':' — splitn(5) utilisé)
+```
+
+Exemples :
+- `qobuz:search:catalog:all:camille` → Browse → 4 containers groupés
+- `qobuz:search:catalog:artists:camille` → Browse → liste d'artistes
+- `qobuz:search:favorites:albums:bach` → Browse → albums favoris
+
+## État du code à la fin de la session
+
+Les logs de debug (`━━━ BROWSE ━━━`, `━━━ SEARCH ━━━`, preview DIDL) ont été ajoutés dans `handlers.rs` au niveau `warn`. Le Makefile a été fixé pour propager `RUST_LOG` à travers `osascript`. La logique serveur Qobuz fonctionne. Seul le frontend est cassé.
diff --git a/Cargo.lock b/Cargo.lock
index 5f159b5d..c5bde027 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "PMOMusic"
-version = "0.3.51"
+version = "0.3.52"
dependencies = [
"axum 0.8.7",
"console-subscriber",
@@ -4072,6 +4072,7 @@ dependencies = [
"cbc",
"chrono",
"ctr",
+ "futures",
"hex",
"hkdf",
"indexmap 2.12.0",
diff --git a/Makefile b/Makefile
index 59a0f0d0..fb950f15 100644
--- a/Makefile
+++ b/Makefile
@@ -171,7 +171,9 @@ run: debug
run-release: release
@echo "$(YELLOW)→ Lancement de l'application (release) via Terminal.app...$(NC)"
@echo "$(BLUE) (Terminal.app est nécessaire pour le multicast sur macOS Sequoia+)$(NC)"
- @osascript -e 'tell application "Terminal" to do script "cd \"$(CURDIR)\" && ./$(RUST_TARGET)/$(BINARY_NAME) 2>&1 | tee pmomusic.log; exit"'
+ @RUST_LOG_PREFIX=""; \
+ if [ -n "$(RUST_LOG)" ]; then RUST_LOG_PREFIX="export RUST_LOG='$(RUST_LOG)' && "; fi; \
+ osascript -e "tell application \"Terminal\" to do script \"cd '$(CURDIR)' && $${RUST_LOG_PREFIX}./$(RUST_TARGET)/$(BINARY_NAME) 2>&1 | tee pmomusic.log; exit\""
## size: Affiche la taille du binaire
size:
diff --git a/PMOMusic/Cargo.toml b/PMOMusic/Cargo.toml
index 2f8ba0ef..efbbf138 100644
--- a/PMOMusic/Cargo.toml
+++ b/PMOMusic/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "PMOMusic"
-version = "0.3.51"
+version = "0.3.52"
edition = "2024"
[dependencies]
diff --git a/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue b/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue
index d08028de..e1861b5c 100644
--- a/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue
+++ b/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue
@@ -22,27 +22,24 @@ const {
loading,
loadingMore,
error,
- searchResults,
- searchQuery,
searchServer,
- clearSearch,
} = useMediaServers();
const searchInput = ref('');
async function handleSearch() {
if (searchInput.value.trim()) {
- await searchServer(props.serverId, searchInput.value.trim());
+ const virtualId = await searchServer(props.serverId, searchInput.value.trim());
+ if (virtualId) {
+ emit("navigate", virtualId);
+ }
}
}
function handleClearSearch() {
searchInput.value = '';
- clearSearch();
}
-const isSearchMode = computed(() => searchQuery.value !== '');
-
const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
useRenderers();
const uiStore = useUIStore();
@@ -52,9 +49,7 @@ const sentinelRef = ref(null);
let observer: IntersectionObserver | null = null;
const browseData = computed(() =>
- isSearchMode.value
- ? searchResults.value
- : getBrowseCached(props.serverId, props.containerId),
+ getBrowseCached(props.serverId, props.containerId),
);
const containers = computed(
@@ -65,7 +60,7 @@ const items = computed(
() => browseData.value?.entries.filter((e) => !e.is_container) || [],
);
-const canLoadMore = computed(() => !isSearchMode.value && hasMore(props.serverId, props.containerId));
+const canLoadMore = computed(() => hasMore(props.serverId, props.containerId));
function setupObserver() {
if (observer) observer.disconnect();
@@ -198,7 +193,7 @@ async function handleQueueItem(itemId: string, rendererId: string) {
@keyup.enter="handleSearch"
/>
-
-
-
Aucun résultat
-
- -
-
-
![]()
-
-
-
-
-
-
-
{{ item.title }}
-
{{ item.artist }}
-
-
-
-
-
-
-
- ([])
-const searchResults = ref(null)
-const searchQuery = ref('')
const CACHE_DURATION_MS = 2000
const BROWSE_WINDOW_SIZE = 200
@@ -214,28 +212,23 @@ export function useMediaServers() {
}
}
- // Recherche dans un serveur
- async function searchServer(serverId: string, query: string) {
- console.log(`[useMediaServers] searchServer called: serverId=${serverId}, query=${query}`);
- if (!query.trim()) {
- searchResults.value = null
- searchQuery.value = ''
- return
- }
+ // Recherche dans un serveur — retourne l'ID du container virtuel de résultats
+ async function searchServer(serverId: string, query: string): Promise {
+ if (!query.trim()) return null
try {
loading.value = true
error.value = null
- searchQuery.value = query
- console.log(`[useMediaServers] Calling API searchServer for server ${serverId}`);
const data = await api.searchServer(serverId, query)
- console.log(`[useMediaServers] Search returned ${data.entries.length} entries, total=${data.total_count}`);
- searchResults.value = {
- container_id: 'search',
+ // data.container_id est l'ID virtuel réel (ex: "qobuz:search:catalog:all:camille")
+ const key = browseCacheKey(serverId, data.container_id)
+ browseCache.value.set(key, {
+ container_id: data.container_id,
entries: data.entries,
total_count: data.total_count,
- }
+ })
+ return data.container_id
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur recherche'
console.error(`[useMediaServers] Erreur search ${serverId}:`, e)
@@ -245,11 +238,6 @@ export function useMediaServers() {
}
}
- function clearSearch() {
- searchResults.value = null
- searchQuery.value = ''
- }
-
// Getters
function getServerById(id: string) {
return serversCache.value.get(id)
@@ -304,13 +292,9 @@ export function useMediaServers() {
getServerById,
getBrowseCached,
hasMore,
- // Search
- searchResults,
- searchQuery,
- searchServer,
- clearSearch,
// Actions
fetchServers,
+ searchServer,
browseContainer,
loadMoreBrowse,
setPath,
diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs
index 1bcfdce4..ecaac228 100644
--- a/pmocontrol/src/pmoserver_ext.rs
+++ b/pmocontrol/src/pmoserver_ext.rs
@@ -8,6 +8,8 @@ use crate::control_point::ControlPoint;
#[cfg(feature = "pmoserver")]
use crate::media_server::{MediaBrowser, playback_item_from_entry};
#[cfg(feature = "pmoserver")]
+use crate::MediaEntry;
+#[cfg(feature = "pmoserver")]
use crate::model::{RendererCapabilities, RendererProtocol};
#[cfg(feature = "pmoserver")]
use crate::openapi::{
@@ -2392,6 +2394,22 @@ struct SearchQuery {
q: String,
}
+#[cfg(feature = "pmoserver")]
+fn search_result_container_id(entries: &[ContainerEntry]) -> String {
+ entries
+ .iter()
+ .find(|entry| entry.is_container)
+ .and_then(|entry| {
+ let parts: Vec<&str> = entry.id.splitn(5, ':').collect();
+ if parts.len() == 5 && parts[0] == "qobuz" && parts[1] == "search" {
+ Some(format!("qobuz:search:{}:all:{}", parts[2], parts[4]))
+ } else {
+ None
+ }
+ })
+ .unwrap_or_else(|| "search".to_string())
+}
+
/// GET /control/servers/{server_id}/search?q= - Recherche dans un serveur
#[cfg(feature = "pmoserver")]
#[utoipa::path(
@@ -2503,8 +2521,10 @@ async fn search_server(
})
.collect();
+ let container_id = search_result_container_id(&container_entries);
+
Ok(Json(BrowseResponse {
- container_id: "search".to_string(),
+ container_id,
entries: container_entries,
total_count,
offset: 0,
diff --git a/pmomediaserver/src/content_handler.rs b/pmomediaserver/src/content_handler.rs
index 21e71489..fb1e7312 100644
--- a/pmomediaserver/src/content_handler.rs
+++ b/pmomediaserver/src/content_handler.rs
@@ -12,7 +12,7 @@
use pmodidl::{Container, DIDLLite};
use pmosource::api::{get_source as get_source_from_registry, list_all_sources};
-use pmosource::{BrowseResult, MusicSource, MusicSourceError};
+use pmosource::{BrowseResult, MediaSearchType, MusicSource, MusicSourceError, SearchQuery, SearchScope};
use pmodidl::ToXmlElement;
use std::collections::HashSet;
use std::sync::Arc;
@@ -45,6 +45,82 @@ fn to_didl_lite(containers: &[Container], items: &[pmodidl::Item]) -> Result &str {
+ let trimmed = criteria.trim();
+ if trimmed == "*" || trimmed.is_empty() {
+ return "";
+ }
+ // Pattern : contains "" (UPnP CDS search syntax)
+ if let Some(pos) = trimmed.find("contains") {
+ let after = trimmed[pos + "contains".len()..].trim();
+ if after.starts_with('"') && after.ends_with('"') && after.len() >= 2 {
+ return &after[1..after.len() - 1];
+ }
+ }
+ trimmed
+}
+
+/// Détermine le scope et le type de média depuis le ContainerID UPnP.
+fn container_to_search_context(container_id: &str) -> (SearchScope, MediaSearchType) {
+ // Containers virtuels de résultats de recherche : qobuz:search:{scope}:{type}:{query}
+ // Le CP peut appeler Search sur ces containers — on préserve leur scope+type.
+ let search_parts: Vec<&str> = container_id.splitn(5, ':').collect();
+ if search_parts.len() >= 4 && search_parts[0] == "qobuz" && search_parts[1] == "search" {
+ let scope = if search_parts[2] == "favorites" {
+ SearchScope::UserLibrary
+ } else {
+ SearchScope::Catalog
+ };
+ let media_type = match search_parts.get(3).copied().unwrap_or("") {
+ "albums" => MediaSearchType::Albums,
+ "tracks" => MediaSearchType::Tracks,
+ "artists" => MediaSearchType::Artists,
+ "playlists" => MediaSearchType::Playlists,
+ _ => MediaSearchType::All,
+ };
+ return (scope, media_type);
+ }
+
+ // Scope UserLibrary : tout ce qui est sous qobuz:favorites
+ if container_id.starts_with("qobuz:favorites") {
+ let media_type = match container_id {
+ "qobuz:favorites:albums" => MediaSearchType::Albums,
+ "qobuz:favorites:tracks" => MediaSearchType::Tracks,
+ "qobuz:favorites:artists" => MediaSearchType::Artists,
+ "qobuz:favorites:playlists" => MediaSearchType::Playlists,
+ _ => MediaSearchType::All,
+ };
+ return (SearchScope::UserLibrary, media_type);
+ }
+
+ // Un artiste spécifique → rechercher ses albums dans le catalog
+ if container_id.starts_with("qobuz:artist:") {
+ return (SearchScope::Catalog, MediaSearchType::Albums);
+ }
+
+ // Un album spécifique → rechercher ses pistes
+ if container_id.starts_with("qobuz:album:") {
+ return (SearchScope::Catalog, MediaSearchType::Tracks);
+ }
+
+ // Scope Catalog : reste de la hiérarchie
+ let media_type = if container_id.starts_with("qobuz:discover:artists") {
+ MediaSearchType::Artists
+ } else if container_id.starts_with("qobuz:discover:albums") {
+ MediaSearchType::Albums
+ } else {
+ MediaSearchType::All
+ };
+ (SearchScope::Catalog, media_type)
+}
+
/// Handler pour le service ContentDirectory
///
/// Ce handler gère toutes les opérations du ContentDirectory en utilisant
@@ -518,13 +594,30 @@ impl ContentHandler {
"ContentDirectory::Search"
);
+ let text = extract_search_text(search_criteria);
+ let (scope, media_type) = container_to_search_context(container_id);
+ tracing::debug!(
+ container_id,
+ search_criteria,
+ extracted_text = text,
+ scope = ?scope,
+ media_type = ?media_type,
+ "ContentHandler::search resolved"
+ );
+ let query = SearchQuery {
+ text: text.to_string(),
+ media_type,
+ scope,
+ limit: 200,
+ offset: 0,
+ };
+
let mut all_containers = Vec::new();
let mut all_items = Vec::new();
- // Rechercher dans toutes les sources qui supportent la recherche
for source in list_all_sources().await {
if source.capabilities().supports_search {
- if let Ok(result) = source.search(search_criteria).await {
+ if let Ok(result) = source.search(&query).await {
match result {
BrowseResult::Containers(c) => all_containers.extend(c),
BrowseResult::Items(i) => all_items.extend(i),
@@ -540,7 +633,6 @@ impl ContentHandler {
let total = (all_containers.len() + all_items.len()) as u32;
let didl = to_didl_lite(&all_containers, &all_items)?;
- // Compute a global update ID from active sources, ensure it starts at 1
let update_id = if total > 0 {
let sources = list_all_sources().await;
let mut combined_id = 0u32;
diff --git a/pmomediaserver/src/contentdirectory/handlers.rs b/pmomediaserver/src/contentdirectory/handlers.rs
index 2d98ffc9..3bce6c99 100644
--- a/pmomediaserver/src/contentdirectory/handlers.rs
+++ b/pmomediaserver/src/contentdirectory/handlers.rs
@@ -50,7 +50,7 @@ use tracing::{debug, error, info};
pub fn browse_handler() -> ActionHandler {
action_handler!(|data| {
let mut data = data;
- debug!("📂 Browse handler called");
+ tracing::warn!("━━━ BROWSE ━━━");
let handler = ContentHandler::new();
@@ -100,6 +100,7 @@ pub fn browse_handler() -> ActionHandler {
})?;
// Définir les arguments de sortie
+ tracing::warn!(object_id, returned, total, didl_preview = &didl[..didl.len().min(300)], "━━━ BROWSE DIDL ━━━");
set!(&mut data, "Result", didl);
set!(&mut data, "NumberReturned", returned);
set!(&mut data, "TotalMatches", total);
@@ -139,7 +140,7 @@ pub fn browse_handler() -> ActionHandler {
pub fn search_handler() -> ActionHandler {
action_handler!(|data| {
let mut data = data;
- debug!("🔍 Search handler called");
+ tracing::warn!("━━━ SEARCH ━━━");
let handler = ContentHandler::new();
diff --git a/pmoqobuz/Cargo.toml b/pmoqobuz/Cargo.toml
index 71011b26..fba6b29e 100644
--- a/pmoqobuz/Cargo.toml
+++ b/pmoqobuz/Cargo.toml
@@ -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 }
diff --git a/pmoqobuz/src/api/catalog.rs b/pmoqobuz/src/api/catalog.rs
index 683e67bf..5cfcd40c 100644
--- a/pmoqobuz/src/api/catalog.rs
+++ b/pmoqobuz/src/api/catalog.rs
@@ -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> {
+ 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 = Vec::new();
- let mut fallback_tracks: Vec