Merge pull request 'push-pxrolutklrtm' (#101) from push-pxrolutklrtm into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m58s

Reviewed-on: #101
This commit was merged in pull request #101.
This commit is contained in:
2026-06-11 22:23:24 +02:00
20 changed files with 1496 additions and 306 deletions

380
.kilo/package-lock.json generated Normal file
View File

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

View File

@@ -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<f64>, // 44100.0, 96000.0, 192000.0
pub maximum_bit_depth: Option<u32>, // 16, 24
pub hires_streamable: bool,
...
}
```
**Pour pmoqobuz** :
1. Ajouter `maximum_sampling_rate: Option<f64>`, `maximum_bit_depth: Option<u32>` à `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<StreamRestriction>,
...
}
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<StreamRestriction>` 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::<u64>().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<PlaylistTag { id, slug, name (localisé) }>
```
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<Album>
```
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<DiscoverContainer<DiscoverPlaylist>>,
pub new_releases: Option<DiscoverContainer<DiscoverAlbum>>,
pub most_streamed: Option<DiscoverContainer<DiscoverAlbum>>,
pub qobuzissims: Option<DiscoverContainer<DiscoverAlbum>>,
pub album_of_the_week: Option<DiscoverContainer<DiscoverAlbum>>,
pub press_awards: Option<DiscoverContainer<DiscoverAlbum>>,
pub ideal_discography: Option<DiscoverContainer<DiscoverAlbum>>,
pub playlists_tags: Option<DiscoverContainer<PlaylistTag>>,
}
```
### 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 |

View File

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

3
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@@ -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<HTMLElement | null>(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"
/>
<button
v-if="searchInput || isSearchMode"
v-if="searchInput"
class="search-clear"
@click="handleClearSearch"
title="Effacer"
@@ -266,7 +261,7 @@ async function handleQueueItem(itemId: string, rendererId: string) {
v-if="!containers.length && !items.length"
class="browser-empty"
>
<p>{{ isSearchMode ? 'Aucun résultat' : 'Ce dossier est vide' }}</p>
<p>{{ 'Ce dossier est vide' }}</p>
</div>
<!-- Sentinel infinite scroll -->

View File

@@ -41,24 +41,33 @@ const {
currentPath,
setPath,
clearPath,
searchResults,
searchQuery,
searchServer,
clearSearch,
} = useMediaServers();
const searchInput = ref('');
const isSearchMode = computed(() => searchQuery.value !== '');
async function handleSearch() {
console.log('[ServerDrawer] handleSearch called, currentServer:', currentServer.value?.id, 'searchInput:', searchInput.value);
if (!currentServer.value || !searchInput.value.trim()) return;
await searchServer(currentServer.value.id, searchInput.value.trim());
const query = searchInput.value.trim();
isLoading.value = true;
try {
const virtualId = await searchServer(currentServer.value.id, query);
if (!virtualId) return;
currentContainerId.value = virtualId;
setPath([
{ id: '0', title: currentServer.value.friendly_name },
{ id: virtualId, title: `Recherche : ${query}` },
]);
} finally {
isLoading.value = false;
}
}
function handleClearSearch() {
searchInput.value = '';
clearSearch();
}
const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } =
@@ -473,7 +482,7 @@ function handleSettingsClick() {
@keyup.enter="handleSearch"
/>
<button
v-if="searchInput || isSearchMode"
v-if="searchInput"
class="search-clear-btn"
@click="handleClearSearch"
title="Effacer"
@@ -578,53 +587,6 @@ function handleSettingsClick() {
<p>Chargement...</p>
</div>
<!-- Résultats de recherche -->
<div v-else-if="isSearchMode && searchResults">
<p v-if="searchResults.entries.length === 0" class="empty-state">Aucun résultat</p>
<ul v-else class="content-list">
<li
v-for="item in searchResults.entries"
:key="item.id"
class="content-item"
:class="{ navigable: item.is_container }"
@click="handleItemClick(item)"
>
<div class="content-cover">
<img
v-if="item.album_art_uri && !getImageState(item.id).error"
:src="item.album_art_uri"
:alt="item.title"
class="cover-img"
:class="{ loaded: getImageState(item.id).loaded }"
@load="handleImageLoad(item.id)"
@error="handleImageError(item.id)"
/>
<div v-else class="cover-placeholder">
<Folder v-if="item.is_container" :size="24" />
<Music v-else :size="24" />
</div>
</div>
<div class="content-info">
<p class="content-title">{{ item.title }}</p>
<p v-if="item.artist" class="content-subtitle">{{ item.artist }}</p>
</div>
<div class="item-actions" @click.stop>
<button class="action-btn play-btn" @click="handlePlayItem($event, item)" title="Lire">
<Play :size="14" />
</button>
<button class="action-btn" @click="toggleMenu(item.id, $event)" title="Plus">
<MoreVertical :size="14" />
</button>
<div v-if="openMenuId === item.id" class="item-menu">
<button @click="handleAddToQueue($event, item)"><Plus :size="14" /> Ajouter à la queue</button>
<button @click="handleAddAfterCurrent($event, item)"><Plus :size="14" /> Après le current</button>
</div>
</div>
<ChevronRight v-if="item.is_container" :size="16" class="content-chevron" />
</li>
</ul>
</div>
<!-- Contenu du serveur -->
<ul v-else-if="browseData" class="content-list">
<li

View File

@@ -32,8 +32,6 @@ function browseCacheKey(serverId: string, containerId: string): string {
return `${encodeURIComponent(serverId)}:${encodeURIComponent(containerId)}`
}
const currentPath = ref<BreadcrumbItem[]>([])
const searchResults = ref<BrowseState | null>(null)
const searchQuery = ref<string>('')
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<string | null> {
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,

View File

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

View File

@@ -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
Ok(body)
}
/// Extrait le texte de recherche depuis un critère UPnP SearchCriteria.
///
/// Exemples supportés :
/// - `dc:title contains "Pink Floyd"` → `"Pink Floyd"`
/// - `upnp:artist contains "Miles"` → `"Miles"`
/// - `Pink Floyd` (texte nu) → `"Pink Floyd"`
/// - `*` → `""`
fn extract_search_text(criteria: &str) -> &str {
let trimmed = criteria.trim();
if trimmed == "*" || trimmed.is_empty() {
return "";
}
// Pattern : <property> contains "<query>" (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;

View File

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

View File

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

View File

@@ -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>> {
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;
use futures::future::try_join_all;
use std::sync::Arc;
use tokio::sync::Semaphore;
// 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 = [
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);
// 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?;
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());
}
// 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.as_str()),
];
let response: PlaylistResponse = self.get("/playlist/get", &params).await?;
("limit", LIMIT_STR),
],
)
.await?;
let items = response.tracks.map(|t| t.items).unwrap_or_default();
Ok::<(u32, Vec<TrackResponse>), QobuzError>((off, items))
}
});
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 mut extra = try_join_all(futs).await?;
pages.append(&mut extra);
}
debug!("Fetched {} track IDs for playlist {}", ordered_ids.len(), playlist_id);
// 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)
}
@@ -562,6 +619,16 @@ impl QobuzApi {
.collect())
}
/// Retourne uniquement les totaux de chaque type pour une requête (limit=1 pour minimiser le transfert).
pub async fn search_totals(&self, query: &str) -> Result<(u32, u32, u32, u32)> {
let response: SearchResponse = self.get("/catalog/search", &[("query", query), ("limit", "1")]).await?;
let albums = response.albums .as_ref().and_then(|r| r.total).unwrap_or(0);
let artists = response.artists .as_ref().and_then(|r| r.total).unwrap_or(0);
let tracks = response.tracks .as_ref().and_then(|r| r.total).unwrap_or(0);
let playlists= response.playlists.as_ref().and_then(|r| r.total).unwrap_or(0);
Ok((albums, artists, tracks, playlists))
}
/// Recherche dans le catalogue
pub async fn search(&self, query: &str, type_: Option<&str>) -> Result<SearchResult> {
debug!("Searching for '{}' (type: {:?})", query, type_);
@@ -605,6 +672,50 @@ impl QobuzApi {
})
}
/// Recherche dans les albums uniquement (`/album/search`)
pub async fn search_albums(&self, query: &str, limit: u32, offset: u32) -> Result<Vec<Album>> {
let limit_s = limit.to_string();
let offset_s = offset.to_string();
let params = [("query", query), ("limit", &limit_s), ("offset", &offset_s)];
#[derive(Deserialize)]
struct Resp { albums: PaginatedResponse<AlbumResponse> }
let resp: Resp = self.get("/album/search", &params).await?;
Ok(resp.albums.items.into_iter().map(Self::parse_album).filter(|a| a.streamable).collect())
}
/// Recherche dans les tracks uniquement (`/track/search`)
pub async fn search_tracks(&self, query: &str, limit: u32, offset: u32) -> Result<Vec<Track>> {
let limit_s = limit.to_string();
let offset_s = offset.to_string();
let params = [("query", query), ("limit", &limit_s), ("offset", &offset_s)];
#[derive(Deserialize)]
struct Resp { tracks: PaginatedResponse<TrackResponse> }
let resp: Resp = self.get("/track/search", &params).await?;
Ok(resp.tracks.items.into_iter().map(|t| Self::parse_track(t, None)).filter(|t| t.streamable).collect())
}
/// Recherche dans les artistes uniquement (`/artist/search`)
pub async fn search_artists(&self, query: &str, limit: u32, offset: u32) -> Result<Vec<Artist>> {
let limit_s = limit.to_string();
let offset_s = offset.to_string();
let params = [("query", query), ("limit", &limit_s), ("offset", &offset_s)];
#[derive(Deserialize)]
struct Resp { artists: PaginatedResponse<ArtistResponse> }
let resp: Resp = self.get("/artist/search", &params).await?;
Ok(resp.artists.items.into_iter().map(Self::parse_artist).collect())
}
/// Recherche dans les playlists uniquement (`/playlist/search`)
pub async fn search_playlists(&self, query: &str, limit: u32, offset: u32) -> Result<Vec<Playlist>> {
let limit_s = limit.to_string();
let offset_s = offset.to_string();
let params = [("query", query), ("limit", &limit_s), ("offset", &offset_s)];
#[derive(Deserialize)]
struct Resp { playlists: PaginatedResponse<PlaylistResponse> }
let resp: Resp = self.get("/playlist/search", &params).await?;
Ok(resp.playlists.items.into_iter().map(Self::parse_playlist).collect())
}
// Fonctions de parsing publiques (utilisées aussi par le module user)
pub(crate) fn parse_album(response: AlbumResponse) -> Album {

View File

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

View File

@@ -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)))
@@ -825,6 +827,11 @@ impl QobuzClient {
/// # Arguments
///
/// * `query` - Termes de recherche
/// Retourne les totaux réels de chaque type (appel économique limit=1).
pub async fn search_totals(&self, query: &str) -> Result<(u32, u32, u32, u32)> {
self.call_with_auth_repair("search_totals", || self.api.search_totals(query)).await
}
/// * `type_` - Type de recherche : None (tous), Some("albums"), Some("artists"), Some("tracks"), Some("playlists")
pub async fn search(&self, query: &str, type_: Option<&str>) -> Result<SearchResult> {
// Créer une clé de cache
@@ -847,28 +854,60 @@ impl QobuzClient {
Ok(result)
}
/// Recherche des albums
pub async fn search_albums(&self, query: &str) -> Result<Vec<Album>> {
let result = self.search(query, Some("albums")).await?;
Ok(result.albums)
/// Recherche des albums via `/album/search` (endpoint dédié, paginé)
pub async fn search_albums(&self, query: &str, limit: u32, offset: u32) -> Result<Vec<Album>> {
let cache_key = format!("albums:{}:{}:{}", query, limit, offset);
if let Some(r) = self.cache.get_search(&cache_key).await {
return Ok(r.albums);
}
let items = self
.call_with_auth_repair("search_albums", || self.api.search_albums(query, limit, offset))
.await?;
let result = SearchResult { albums: items.clone(), artists: vec![], tracks: vec![], playlists: vec![] };
self.cache.put_search(cache_key, result).await;
Ok(items)
}
/// Recherche des artistes
pub async fn search_artists(&self, query: &str) -> Result<Vec<Artist>> {
let result = self.search(query, Some("artists")).await?;
Ok(result.artists)
/// Recherche des artistes via `/artist/search` (endpoint dédié, paginé)
pub async fn search_artists(&self, query: &str, limit: u32, offset: u32) -> Result<Vec<Artist>> {
let cache_key = format!("artists:{}:{}:{}", query, limit, offset);
if let Some(r) = self.cache.get_search(&cache_key).await {
return Ok(r.artists);
}
let items = self
.call_with_auth_repair("search_artists", || self.api.search_artists(query, limit, offset))
.await?;
let result = SearchResult { albums: vec![], artists: items.clone(), tracks: vec![], playlists: vec![] };
self.cache.put_search(cache_key, result).await;
Ok(items)
}
/// Recherche des tracks
pub async fn search_tracks(&self, query: &str) -> Result<Vec<Track>> {
let result = self.search(query, Some("tracks")).await?;
Ok(result.tracks)
/// Recherche des tracks via `/track/search` (endpoint dédié, paginé)
pub async fn search_tracks(&self, query: &str, limit: u32, offset: u32) -> Result<Vec<Track>> {
let cache_key = format!("tracks:{}:{}:{}", query, limit, offset);
if let Some(r) = self.cache.get_search(&cache_key).await {
return Ok(r.tracks);
}
let items = self
.call_with_auth_repair("search_tracks", || self.api.search_tracks(query, limit, offset))
.await?;
let result = SearchResult { albums: vec![], artists: vec![], tracks: items.clone(), playlists: vec![] };
self.cache.put_search(cache_key, result).await;
Ok(items)
}
/// Recherche des playlists
pub async fn search_playlists(&self, query: &str) -> Result<Vec<Playlist>> {
let result = self.search(query, Some("playlists")).await?;
Ok(result.playlists)
/// Recherche des playlists via `/playlist/search` (endpoint dédié, paginé)
pub async fn search_playlists(&self, query: &str, limit: u32, offset: u32) -> Result<Vec<Playlist>> {
let cache_key = format!("playlists:{}:{}:{}", query, limit, offset);
if let Some(r) = self.cache.get_search(&cache_key).await {
return Ok(r.playlists);
}
let items = self
.call_with_auth_repair("search_playlists", || self.api.search_playlists(query, limit, offset))
.await?;
let result = SearchResult { albums: vec![], artists: vec![], tracks: vec![], playlists: items.clone() };
self.cache.put_search(cache_key, result).await;
Ok(items)
}
// ============ Favoris ============

View File

@@ -245,6 +245,24 @@ pub trait QobuzConfigExt {
/// Persiste la version du bundle après une extraction réussie.
fn set_qobuz_bundle_version(&self, version: &str) -> Result<()>;
/// Nombre de workers concurrents pour l'enregistrement des tracks en cache.
///
/// Contrôle le semaphore dans `register_tracks_lazy` : plus la valeur est
/// haute, plus les covers sont téléchargées en parallèle, mais plus la
/// contention sur le mutex SQLite est forte.
///
/// 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 {
@@ -504,4 +522,22 @@ impl QobuzConfigExt for Config {
Value::String(version.to_string()),
)
}
fn get_qobuz_register_concurrency(&self) -> usize {
match self.get_value(&["accounts", "qobuz", "register_concurrency"]) {
Ok(Value::Number(n)) if n.as_u64().unwrap_or(0) >= 1 => {
n.as_u64().unwrap() as usize
}
_ => 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,
}
}
}

View File

@@ -11,7 +11,7 @@ use pmoaudiocache::{AudioMetadata, Cache as AudioCache};
use pmocovers::Cache as CoverCache;
use pmodidl::{Container, Item};
use pmosource::SourceCacheManager;
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
use pmosource::{async_trait, BrowseResult, MediaSearchType, MusicSource, MusicSourceError, Result, SearchQuery, SearchScope};
use serde_json::json;
use std::sync::Arc;
use std::time::SystemTime;
@@ -115,6 +115,9 @@ struct QobuzSourceInner {
/// Base URL for streaming server (e.g., "http://192.168.0.138:8080")
base_url: String,
/// Nombre de workers concurrents pour register_tracks_lazy (configurable)
register_concurrency: usize,
/// Update tracking
update_counter: tokio::sync::RwLock<u32>,
last_change: tokio::sync::RwLock<SystemTime>,
@@ -142,15 +145,18 @@ impl QobuzSource {
/// Returns an error if the caches are not initialized in the registry
#[cfg(feature = "server")]
pub fn from_registry(client: QobuzClient, base_url: impl Into<String>) -> Result<Self> {
use crate::config_ext::QobuzConfigExt;
let cache_manager = SourceCacheManager::from_registry("qobuz".to_string())?;
let client = Arc::new(client);
cache_manager.register_lazy_provider(Arc::new(QobuzLazyProvider::new(client.clone())));
let register_concurrency = pmoconfig::get_config().get_qobuz_register_concurrency();
Ok(Self {
inner: Arc::new(QobuzSourceInner {
client,
cache_manager,
base_url: base_url.into(),
register_concurrency,
update_counter: tokio::sync::RwLock::new(0),
last_change: tokio::sync::RwLock::new(SystemTime::now()),
}),
@@ -180,6 +186,7 @@ impl QobuzSource {
client,
cache_manager,
base_url: base_url.into(),
register_concurrency: 4,
update_counter: tokio::sync::RwLock::new(0),
last_change: tokio::sync::RwLock::new(SystemTime::now()),
}),
@@ -1062,10 +1069,10 @@ impl QobuzSource {
/// Pour chaque track : cache la cover, enregistre la lazy entry, stocke les métadonnées.
/// Retourne la liste des lazy PKs enregistrés avec succès.
async fn register_tracks_lazy(&self, tracks: &[crate::models::Track]) -> Vec<String> {
// Limite la concurrence pour ne pas saturer l'API Qobuz ni la connexion réseau.
// Les covers déjà cachées sont retournées immédiatement (pas d'HTTP), donc même
// 600 tracks ne génèrent que ~N_albums_uniques téléchargements réels.
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(16));
// Configurable via accounts.qobuz.register_concurrency (défaut 4).
// SQLite sérialise les écritures — au-delà de ~4 workers on accumule
// des threads en attente du mutex DB sans gain de débit.
let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(self.inner.register_concurrency));
// On attache l'index original à chaque future pour pouvoir retrier dans l'ordre
// d'origine après complétion parallèle (JoinSet retourne dans l'ordre de fin).
@@ -1660,10 +1667,31 @@ impl QobuzSource {
/// - "qobuz:playlist:{id}" → Tracks in playlist
/// - etc.
fn parse_object_id(&self, object_id: &str) -> ObjectIdType {
tracing::debug!(object_id, "parse_object_id");
if object_id == "qobuz" || object_id == "0" {
return ObjectIdType::Root;
}
// Virtual search result containers: qobuz:search:{scope}:{type}:{query}
// Use splitn(5) so the query (last segment) can contain ':' without ambiguity.
let search_parts: Vec<&str> = object_id.splitn(5, ':').collect();
tracing::debug!(len = search_parts.len(), parts = ?search_parts, "parse_object_id splitn");
if search_parts.len() == 5 && search_parts[0] == "qobuz" && search_parts[1] == "search" {
let scope = match search_parts[2] {
"favorites" => SearchScope::UserLibrary,
_ => SearchScope::Catalog,
};
let media_type = match search_parts[3] {
"albums" => MediaSearchType::Albums,
"tracks" => MediaSearchType::Tracks,
"artists" => MediaSearchType::Artists,
"playlists" => MediaSearchType::Playlists,
_ => MediaSearchType::All,
};
tracing::debug!(scope = ?scope, media_type = ?media_type, query = search_parts[4], "parse_object_id → SearchResult");
return ObjectIdType::SearchResult(scope, media_type, search_parts[4].to_string());
}
let parts: Vec<&str> = object_id.split(':').collect();
match parts.as_slice() {
// Discover Catalog
@@ -1749,6 +1777,10 @@ enum ObjectIdType {
Artist(String),
Track(String),
// Containers virtuels de résultats de recherche
// (scope, media_type, query)
SearchResult(SearchScope, MediaSearchType, String),
Unknown,
}
@@ -1865,6 +1897,23 @@ impl MusicSource for QobuzSource {
Ok(BrowseResult::Containers(containers))
}
ObjectIdType::SearchResult(scope, media_type, query) => {
tracing::debug!(scope = ?scope, media_type = ?media_type, query, "browse → search");
let is_all_search = media_type == MediaSearchType::All;
let sq = SearchQuery {
text: query,
media_type,
scope,
limit: 200,
offset: 0,
};
if is_all_search {
self.search_grouped(&sq).await
} else {
self.execute_search(&sq).await
}
}
ObjectIdType::Track(_) => {
// Track object_ids ne sont pas browsables, retourner une erreur
Err(MusicSourceError::NotSupported(
@@ -2014,86 +2063,14 @@ impl MusicSource for QobuzSource {
Ok(items)
}
async fn search(&self, query: &str) -> Result<BrowseResult> {
async fn search(&self, query: &SearchQuery) -> Result<BrowseResult> {
use tracing::debug;
debug!(query = %query, "Qobuz search started");
debug!(text = %query.text, scope = ?query.scope, media_type = ?query.media_type, "Qobuz search");
// Search across Qobuz catalog (albums, tracks, artists, playlists)
let results = self
.inner
.client
.search(query, None)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
debug!(
albums = results.albums.len(),
artists = results.artists.len(),
tracks = results.tracks.len(),
playlists = results.playlists.len(),
"Qobuz search API results"
);
// Cache covers in parallel for all types
let (albums, tracks, artists, playlists) = tokio::join!(
self.cache_album_covers(results.albums),
self.cache_track_covers(results.tracks),
self.cache_artist_covers(results.artists),
self.cache_playlist_covers(results.playlists),
);
// Build containers from albums
let album_containers: Vec<Container> = albums
.into_iter()
.filter_map(|a| a.to_didl_container("qobuz:search").ok())
.collect();
// Build containers from artists (manual construction)
let artist_containers: Vec<Container> = artists
.into_iter()
.map(|artist| Container {
id: format!("qobuz:artist:{}", artist.id),
parent_id: "qobuz:search".to_string(),
restricted: Some("1".to_string()),
child_count: None,
searchable: Some("1".to_string()),
title: artist.name.clone(),
class: "object.container".to_string(),
artist: Some(artist.name.clone()),
album_art: artist.image_cached,
containers: vec![],
items: vec![],
})
.collect();
// Build containers from playlists
let playlist_containers: Vec<Container> = playlists
.into_iter()
.filter_map(|p| p.to_didl_container("qobuz:search").ok())
.collect();
// Combine all containers
let mut all_containers = Vec::new();
all_containers.extend(album_containers);
all_containers.extend(artist_containers);
all_containers.extend(playlist_containers);
// Build items from tracks
let track_items: Vec<Item> = tracks
.into_iter()
.filter_map(|t| t.to_didl_item("qobuz:search").ok())
.collect();
debug!(
containers = all_containers.len(),
items = track_items.len(),
"Qobuz search done"
);
if !all_containers.is_empty() || !track_items.is_empty() {
Ok(BrowseResult::Mixed { containers: all_containers, items: track_items })
if query.media_type == MediaSearchType::All {
self.search_grouped(query).await
} else {
Ok(BrowseResult::Items(vec![]))
self.execute_search(query).await
}
}
@@ -2109,7 +2086,7 @@ impl MusicSource for QobuzSource {
supports_high_res_audio: true,
max_sample_rate: Some(192_000), // Qobuz supports up to 192kHz
supports_multiple_formats: true,
supports_advanced_search: false, // TODO: Qobuz API supports it, not yet implemented
supports_advanced_search: true,
supports_pagination: true,
}
}
@@ -2419,6 +2396,189 @@ impl MusicSource for QobuzSource {
}
}
// Search helpers — inherent methods, called from both `search()` and `browse()`.
impl QobuzSource {
/// Recherche groupée : retourne des containers virtuels navigables (un par type).
pub(crate) async fn search_grouped(&self, query: &SearchQuery) -> Result<BrowseResult> {
use tracing::debug;
let scope_str = match query.scope {
SearchScope::Catalog => "catalog",
SearchScope::UserLibrary => "favorites",
};
let counts = if query.scope == SearchScope::UserLibrary {
self.search_favorites_counts(&query.text).await
} else {
self.search_catalog_counts(&query.text).await
};
let (n_albums, n_artists, n_tracks, n_playlists) = counts;
let parent_id = format!("qobuz:search:{}:all:{}", scope_str, query.text);
let mk_container = |type_str: &str, title: &str, count: usize| Container {
id: format!("qobuz:search:{}:{}:{}", scope_str, type_str, query.text),
parent_id: parent_id.clone(),
restricted: Some("1".to_string()),
child_count: Some(count.to_string()),
searchable: None,
title: format!("{} ({}{})", title, count, if count >= 1000 { "+" } else { "" }),
class: "object.container".to_string(),
artist: None,
album_art: None,
containers: vec![],
items: vec![],
};
let mut containers = Vec::new();
if n_albums > 0 { containers.push(mk_container("albums", "Albums", n_albums)); }
if n_artists > 0 { containers.push(mk_container("artists", "Artistes", n_artists)); }
if n_tracks > 0 { containers.push(mk_container("tracks", "Titres", n_tracks)); }
if n_playlists > 0 { containers.push(mk_container("playlists", "Playlists", n_playlists)); }
debug!(containers = containers.len(), "Search grouped result");
Ok(BrowseResult::Containers(containers))
}
async fn search_catalog_counts(&self, text: &str) -> (usize, usize, usize, usize) {
match self.inner.client.search_totals(text).await {
Ok((albums, artists, tracks, playlists)) => {
tracing::debug!(albums, artists, tracks, playlists, "search_catalog_counts totals");
(albums as usize, artists as usize, tracks as usize, playlists as usize)
}
Err(e) => {
tracing::warn!(error = %e, "search_catalog_counts failed");
(0, 0, 0, 0)
}
}
}
async fn search_favorites_counts(&self, text: &str) -> (usize, usize, usize, usize) {
let q = text.to_lowercase();
let albums = self.inner.client.get_favorite_albums().await.unwrap_or_default()
.into_iter().filter(|a| a.title.to_lowercase().contains(&q) || a.artist.name.to_lowercase().contains(&q)).count();
let tracks = self.inner.client.get_favorite_tracks().await.unwrap_or_default()
.into_iter().filter(|t| t.title.to_lowercase().contains(&q) || t.performer.as_ref().map(|p| p.name.to_lowercase().contains(&q)).unwrap_or(false)).count();
let artists = self.inner.client.get_favorite_artists().await.unwrap_or_default()
.into_iter().filter(|a| a.name.to_lowercase().contains(&q)).count();
let playlists = self.inner.client.get_user_playlists().await.unwrap_or_default()
.into_iter().filter(|p| p.name.to_lowercase().contains(&q)).count();
(albums, artists, tracks, playlists)
}
/// Exécute une recherche typée (non-All) et retourne les items/containers directement.
pub(crate) async fn execute_search(&self, query: &SearchQuery) -> Result<BrowseResult> {
use tracing::debug;
debug!(text = %query.text, scope = ?query.scope, media_type = ?query.media_type, "execute_search entry");
let text = &query.text;
let scope_str = match query.scope {
SearchScope::Catalog => "catalog",
SearchScope::UserLibrary => "favorites",
};
let type_str = match query.media_type {
MediaSearchType::Albums => "albums",
MediaSearchType::Artists => "artists",
MediaSearchType::Tracks => "tracks",
MediaSearchType::Playlists => "playlists",
MediaSearchType::All => "all",
};
let parent_id = format!("qobuz:search:{}:{}:{}", scope_str, type_str, query.text);
match (&query.scope, &query.media_type) {
(SearchScope::UserLibrary, MediaSearchType::Albums) => {
let q = text.to_lowercase();
let albums = self.inner.client.get_favorite_albums().await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?
.into_iter().filter(|a| a.title.to_lowercase().contains(&q) || a.artist.name.to_lowercase().contains(&q))
.collect::<Vec<_>>();
let albums = self.cache_album_covers(albums).await;
Ok(BrowseResult::Containers(albums.into_iter().filter_map(|a| a.to_didl_container(&parent_id).ok()).collect()))
}
(SearchScope::UserLibrary, MediaSearchType::Tracks) => {
let q = text.to_lowercase();
let tracks = self.inner.client.get_favorite_tracks().await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?
.into_iter().filter(|t| t.title.to_lowercase().contains(&q) || t.performer.as_ref().map(|p| p.name.to_lowercase().contains(&q)).unwrap_or(false))
.collect::<Vec<_>>();
let tracks = self.cache_track_covers(tracks).await;
Ok(BrowseResult::Items(tracks.into_iter().filter_map(|t| t.to_didl_item(&parent_id).ok()).collect()))
}
(SearchScope::UserLibrary, MediaSearchType::Artists) => {
let q = text.to_lowercase();
let artists = self.inner.client.get_favorite_artists().await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?
.into_iter().filter(|a| a.name.to_lowercase().contains(&q))
.collect::<Vec<_>>();
let artists = self.cache_artist_covers(artists).await;
let containers = artists.into_iter().map(|a| Container {
id: format!("qobuz:artist:{}", a.id),
parent_id: parent_id.clone(),
restricted: Some("1".to_string()),
child_count: None, searchable: Some("1".to_string()),
title: a.name.clone(), class: "object.container.person.musicArtist".to_string(),
artist: Some(a.name.clone()), album_art: a.image_cached,
containers: vec![], items: vec![],
}).collect();
Ok(BrowseResult::Containers(containers))
}
(SearchScope::UserLibrary, MediaSearchType::Playlists) => {
let q = text.to_lowercase();
let playlists = self.inner.client.get_user_playlists().await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?
.into_iter().filter(|p| p.name.to_lowercase().contains(&q))
.collect::<Vec<_>>();
let playlists = self.cache_playlist_covers(playlists).await;
Ok(BrowseResult::Containers(playlists.into_iter().filter_map(|p| p.to_didl_container(&parent_id).ok()).collect()))
}
(SearchScope::Catalog, MediaSearchType::Albums) => {
let result = self.inner.client.search(text, Some("albums"))
.await.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let albums = self.cache_album_covers(result.albums).await;
let containers: Vec<Container> = albums.into_iter()
.filter_map(|a| a.to_didl_container(&parent_id).ok()).collect();
debug!(count = containers.len(), "search albums result");
Ok(BrowseResult::Containers(containers))
}
(SearchScope::Catalog, MediaSearchType::Tracks) => {
let result = self.inner.client.search(text, Some("tracks"))
.await.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let tracks = self.cache_track_covers(result.tracks).await;
let items: Vec<Item> = tracks.into_iter()
.filter_map(|t| t.to_didl_item(&parent_id).ok()).collect();
debug!(count = items.len(), "search tracks result");
Ok(BrowseResult::Items(items))
}
(SearchScope::Catalog, MediaSearchType::Artists) => {
let result = self.inner.client.search(text, Some("artists"))
.await.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let artists = self.cache_artist_covers(result.artists).await;
let containers: Vec<Container> = artists.into_iter().map(|a| Container {
id: format!("qobuz:artist:{}", a.id),
parent_id: parent_id.clone(),
restricted: Some("1".to_string()),
child_count: None, searchable: None,
title: a.name.clone(), class: "object.container.person.musicArtist".to_string(),
artist: Some(a.name.clone()), album_art: a.image_cached,
containers: vec![], items: vec![],
}).collect();
let first_titles: Vec<&str> = containers.iter().take(4).map(|c| c.title.as_str()).collect();
debug!(count = containers.len(), first4 = ?first_titles, "search artists result");
Ok(BrowseResult::Containers(containers))
}
(SearchScope::Catalog, MediaSearchType::Playlists) => {
let result = self.inner.client.search(text, Some("playlists"))
.await.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
let playlists = self.cache_playlist_covers(result.playlists).await;
let containers: Vec<Container> = playlists.into_iter()
.filter_map(|p| p.to_didl_container(&parent_id).ok()).collect();
debug!(count = containers.len(), "search playlists result");
Ok(BrowseResult::Containers(containers))
}
_ => Ok(BrowseResult::Items(vec![])),
}
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -155,21 +155,34 @@ pub enum CacheStatus {
Failed { error: String },
}
/// Search filters for advanced search
#[derive(Debug, Clone, Default)]
pub struct SearchFilters {
/// Filter by artist name
pub artist: Option<String>,
/// Filter by album name
pub album: Option<String>,
/// Filter by genre
pub genre: Option<String>,
/// Minimum year
pub year_min: Option<u32>,
/// Maximum year
pub year_max: Option<u32>,
/// Maximum number of results
pub limit: Option<usize>,
/// Scope of a search operation
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SearchScope {
/// Search the full provider catalog (Qobuz API, etc.)
Catalog,
/// Search only within the user's saved library (favorites, playlists)
UserLibrary,
}
/// Type of media to search for
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MediaSearchType {
/// All types — results grouped into navigable virtual containers
All,
Tracks,
Albums,
Artists,
Playlists,
}
/// Structured search request passed to `MusicSource::search()`
#[derive(Debug, Clone)]
pub struct SearchQuery {
pub text: String,
pub media_type: MediaSearchType,
pub scope: SearchScope,
pub limit: u32,
pub offset: u32,
}
/// Source statistics
@@ -321,7 +334,7 @@ impl BrowseResult {
/// Ok(vec![])
/// }
///
/// async fn search(&self, query: &str) -> Result<BrowseResult> {
/// async fn search(&self, query: &SearchQuery) -> Result<BrowseResult> {
/// Err(pmosource::MusicSourceError::SearchNotSupported)
/// }
/// }
@@ -597,12 +610,11 @@ pub trait MusicSource: Debug + Send + Sync {
/// # Examples
///
/// ```ignore
/// let results = source.search("Pink Floyd").await?;
/// for item in results.items() {
/// println!("Found: {}", item.title);
/// }
/// let q = SearchQuery { text: "Pink Floyd".into(), media_type: MediaSearchType::All,
/// scope: SearchScope::Catalog, limit: 50, offset: 0 };
/// let results = source.search(&q).await?;
/// ```
async fn search(&self, query: &str) -> Result<BrowseResult> {
async fn search(&self, query: &SearchQuery) -> Result<BrowseResult> {
let _ = query;
Err(MusicSourceError::SearchNotSupported)
}
@@ -890,40 +902,6 @@ pub trait MusicSource: Debug + Send + Sync {
self.browse(object_id).await
}
/// Advanced search with filters
///
/// Provides more fine-grained search control than basic `search()`.
///
/// # Arguments
///
/// * `query` - Search query string
/// * `filters` - Additional search filters
///
/// # Returns
///
/// A `BrowseResult` containing matching items/containers.
///
/// # Errors
///
/// Returns `MusicSourceError::SearchNotSupported` if not implemented.
///
/// # Examples
///
/// ```ignore
/// let filters = SearchFilters {
/// artist: Some("Pink Floyd".to_string()),
/// year_min: Some(1970),
/// year_max: Some(1980),
/// ..Default::default()
/// };
/// let results = source.search_advanced("Wall", filters).await?;
/// ```
async fn search_advanced(&self, query: &str, filters: SearchFilters) -> Result<BrowseResult> {
// Default: ignore filters and call basic search
let _ = filters;
self.search(query).await
}
/// Get source statistics
///
/// Returns information about the source such as total items, cache usage, etc.

View File

@@ -1 +1 @@
0.3.51
0.3.52