refactor: rework search implementation and fix navigation bug
Replaces raw string queries with structured `SearchQuery` types across the `MusicSource` trait and UPnP handlers. Adds dedicated paginated endpoints, explicit caching, and type-specific filtering for the Qobuz source. Fixes a frontend navigation bug by aligning state management with UPnP browse semantics and properly routing virtual container IDs. Also updates the Makefile for dynamic `RUST_LOG` configuration, standardizes logging with `tracing`, and adds the dependency lockfile.
This commit is contained in:
380
.kilo/package-lock.json
generated
Normal file
380
.kilo/package-lock.json
generated
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -255,64 +255,98 @@ avant de retenter, au lieu du backoff fixe.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Recherche — **À FAIRE** (priorité haute)
|
## 8. Recherche UPnP contextuelle — **À FAIRE** (priorité haute)
|
||||||
|
|
||||||
pmoqobuz n'expose aucune recherche. qbz montre que Qobuz a un vrai moteur de recherche
|
### 8a. Principe : le ContainerID détermine le scope et le type
|
||||||
multi-catégories.
|
|
||||||
|
|
||||||
### 8a. Endpoints disponibles
|
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)** :
|
||||||
GET /album/search?query=…&limit=…&offset=…[&type=…]
|
|
||||||
GET /track/search?query=…&limit=…&offset=…[&type=…]
|
|
||||||
GET /artist/search?query=…&limit=…&offset=…[&type=…]
|
|
||||||
GET /playlist/search?query=…&limit=…&offset=…
|
|
||||||
GET /catalog/search?query=…&limit=…&offset=… ← combiné (albums + tracks + artists + playlists)
|
|
||||||
```
|
|
||||||
|
|
||||||
Tous non-authentifiés (pas de token requis). Signature pattern :
|
| ContainerID | Scope | Type | Endpoint Qobuz |
|
||||||
`sign_search(method, query, limit, offset, search_type, timestamp, secret)`
|
|---|---|---|---|
|
||||||
où les params signés sont concaténés **dans l'ordre alphabétique** :
|
| `qobuz`, `qobuz:discover`, `qobuz:discover:*`, `qobuz:genres`, `qobuz:genre:*` | Catalog | All | `/catalog/search` → containers groupés |
|
||||||
`limit{L}offset{O}query{Q}[type{T}]`.
|
| `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 |
|
||||||
|
|
||||||
### 8b. Paramètre `type` (filtre sémantique)
|
**SearchCriteria** : extrait le texte brut — `dc:title contains "Pink Floyd"` → `"Pink Floyd"`,
|
||||||
|
`upnp:artist contains "Miles"` → `"Miles"`, chaîne nue ou `*` → passé tel quel.
|
||||||
|
|
||||||
Sur album/track/artist search, le param `type` affine la signification de `query` :
|
### 8b. Types dans `pmosource`
|
||||||
- `MainArtist` — cherche dans le nom de l'artiste principal
|
|
||||||
- `Performer` — cherche dans les performers/interprètes
|
|
||||||
- `Composer` — cherche dans le compositeur
|
|
||||||
- `Label` — cherche dans le nom du label
|
|
||||||
- `ReleaseName` — cherche dans le titre de la release
|
|
||||||
|
|
||||||
Sans `type`, la recherche est full-text sur tous les champs.
|
|
||||||
|
|
||||||
### 8c. Structure de retour
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub struct SearchResultsPage<T> {
|
pub enum SearchScope { Catalog, UserLibrary }
|
||||||
pub items: Vec<T>,
|
|
||||||
pub total: u32,
|
pub enum MediaSearchType { All, Tracks, Albums, Artists, Playlists }
|
||||||
pub offset: u32,
|
|
||||||
|
pub struct SearchQuery {
|
||||||
|
pub text: String,
|
||||||
|
pub media_type: MediaSearchType,
|
||||||
|
pub scope: SearchScope,
|
||||||
pub limit: u32,
|
pub limit: u32,
|
||||||
|
pub offset: u32,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`catalog/search` retourne un objet avec clés `albums`, `tracks`, `artists`, `playlists`,
|
Le trait `MusicSource::search()` passe de `&str` à `&SearchQuery`.
|
||||||
`most_popular` — chacun étant une `SearchResultsPage<T>` — mais qbz le désérialise en `Value`
|
|
||||||
brut (pas de struct dédiée).
|
|
||||||
|
|
||||||
### 8d. Ce qu'il faut implémenter dans pmoqobuz
|
### 8c. Résultats groupés via containers virtuels navigables
|
||||||
|
|
||||||
1. `signing::sign_search(method, query, limit, offset, search_type, ts, secret) -> String`
|
Quand `media_type = All`, `search()` retourne des containers virtuels :
|
||||||
(signature spécifique avec ordre alpha des params)
|
|
||||||
2. `QobuzApi::search_tracks(query, limit, offset, search_type) -> Result<SearchPage<Track>>`
|
|
||||||
3. `QobuzApi::search_albums(query, limit, offset, search_type) -> Result<SearchPage<Album>>`
|
|
||||||
4. `QobuzApi::search_artists(query, limit, offset) -> Result<SearchPage<Artist>>`
|
|
||||||
5. `QobuzApi::catalog_search(query, limit, offset) -> Result<CatalogSearchResult>`
|
|
||||||
avec `CatalogSearchResult { albums, tracks, artists, playlists }`
|
|
||||||
6. Exposer via `QobuzClient` + endpoint REST `/qobuz/search?q=…&type=track|album|artist|all`
|
|
||||||
|
|
||||||
Priorité : **catalog_search** en premier (un seul endpoint couvre tous les cas UI).
|
```
|
||||||
|
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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
108
Blackboard/Todo/search_navigation_bug.md
Normal file
108
Blackboard/Todo/search_navigation_bug.md
Normal 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é.
|
||||||
4
Makefile
4
Makefile
@@ -171,7 +171,9 @@ run: debug
|
|||||||
run-release: release
|
run-release: release
|
||||||
@echo "$(YELLOW)→ Lancement de l'application (release) via Terminal.app...$(NC)"
|
@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)"
|
@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: Affiche la taille du binaire
|
||||||
size:
|
size:
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ const searchInput = ref('');
|
|||||||
|
|
||||||
async function handleSearch() {
|
async function handleSearch() {
|
||||||
if (searchInput.value.trim()) {
|
if (searchInput.value.trim()) {
|
||||||
await searchServer(props.serverId, searchInput.value.trim());
|
const virtualId = await searchServer(props.serverId, searchInput.value.trim(), props.containerId);
|
||||||
|
if (virtualId) {
|
||||||
|
emit("navigate", virtualId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -214,28 +214,23 @@ export function useMediaServers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recherche dans un serveur
|
// Recherche dans un serveur — retourne l'ID du container virtuel de résultats
|
||||||
async function searchServer(serverId: string, query: string) {
|
async function searchServer(serverId: string, query: string, context?: string): Promise<string | null> {
|
||||||
console.log(`[useMediaServers] searchServer called: serverId=${serverId}, query=${query}`);
|
if (!query.trim()) return null
|
||||||
if (!query.trim()) {
|
|
||||||
searchResults.value = null
|
|
||||||
searchQuery.value = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
searchQuery.value = query
|
|
||||||
console.log(`[useMediaServers] Calling API searchServer for server ${serverId}`);
|
|
||||||
|
|
||||||
const data = await api.searchServer(serverId, query)
|
const data = await api.searchServer(serverId, query, context)
|
||||||
console.log(`[useMediaServers] Search returned ${data.entries.length} entries, total=${data.total_count}`);
|
// data.container_id est l'ID virtuel réel (ex: "qobuz:search:catalog:all:camille")
|
||||||
searchResults.value = {
|
const key = browseCacheKey(serverId, data.container_id)
|
||||||
container_id: 'search',
|
browseCache.value.set(key, {
|
||||||
|
container_id: data.container_id,
|
||||||
entries: data.entries,
|
entries: data.entries,
|
||||||
total_count: data.total_count,
|
total_count: data.total_count,
|
||||||
}
|
})
|
||||||
|
return data.container_id
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : 'Erreur recherche'
|
error.value = e instanceof Error ? e.message : 'Erreur recherche'
|
||||||
console.error(`[useMediaServers] Erreur search ${serverId}:`, e)
|
console.error(`[useMediaServers] Erreur search ${serverId}:`, e)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
use pmodidl::{Container, DIDLLite};
|
use pmodidl::{Container, DIDLLite};
|
||||||
use pmosource::api::{get_source as get_source_from_registry, list_all_sources};
|
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 pmodidl::ToXmlElement;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -45,6 +45,82 @@ fn to_didl_lite(containers: &[Container], items: &[pmodidl::Item]) -> Result<Str
|
|||||||
Ok(body)
|
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
|
/// Handler pour le service ContentDirectory
|
||||||
///
|
///
|
||||||
/// Ce handler gère toutes les opérations du ContentDirectory en utilisant
|
/// Ce handler gère toutes les opérations du ContentDirectory en utilisant
|
||||||
@@ -518,13 +594,30 @@ impl ContentHandler {
|
|||||||
"ContentDirectory::Search"
|
"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_containers = Vec::new();
|
||||||
let mut all_items = Vec::new();
|
let mut all_items = Vec::new();
|
||||||
|
|
||||||
// Rechercher dans toutes les sources qui supportent la recherche
|
|
||||||
for source in list_all_sources().await {
|
for source in list_all_sources().await {
|
||||||
if source.capabilities().supports_search {
|
if source.capabilities().supports_search {
|
||||||
if let Ok(result) = source.search(search_criteria).await {
|
if let Ok(result) = source.search(&query).await {
|
||||||
match result {
|
match result {
|
||||||
BrowseResult::Containers(c) => all_containers.extend(c),
|
BrowseResult::Containers(c) => all_containers.extend(c),
|
||||||
BrowseResult::Items(i) => all_items.extend(i),
|
BrowseResult::Items(i) => all_items.extend(i),
|
||||||
@@ -540,7 +633,6 @@ impl ContentHandler {
|
|||||||
let total = (all_containers.len() + all_items.len()) as u32;
|
let total = (all_containers.len() + all_items.len()) as u32;
|
||||||
let didl = to_didl_lite(&all_containers, &all_items)?;
|
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 update_id = if total > 0 {
|
||||||
let sources = list_all_sources().await;
|
let sources = list_all_sources().await;
|
||||||
let mut combined_id = 0u32;
|
let mut combined_id = 0u32;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ use tracing::{debug, error, info};
|
|||||||
pub fn browse_handler() -> ActionHandler {
|
pub fn browse_handler() -> ActionHandler {
|
||||||
action_handler!(|data| {
|
action_handler!(|data| {
|
||||||
let mut data = data;
|
let mut data = data;
|
||||||
debug!("📂 Browse handler called");
|
tracing::warn!("━━━ BROWSE ━━━");
|
||||||
|
|
||||||
let handler = ContentHandler::new();
|
let handler = ContentHandler::new();
|
||||||
|
|
||||||
@@ -100,6 +100,7 @@ pub fn browse_handler() -> ActionHandler {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Définir les arguments de sortie
|
// 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, "Result", didl);
|
||||||
set!(&mut data, "NumberReturned", returned);
|
set!(&mut data, "NumberReturned", returned);
|
||||||
set!(&mut data, "TotalMatches", total);
|
set!(&mut data, "TotalMatches", total);
|
||||||
@@ -139,7 +140,7 @@ pub fn browse_handler() -> ActionHandler {
|
|||||||
pub fn search_handler() -> ActionHandler {
|
pub fn search_handler() -> ActionHandler {
|
||||||
action_handler!(|data| {
|
action_handler!(|data| {
|
||||||
let mut data = data;
|
let mut data = data;
|
||||||
debug!("🔍 Search handler called");
|
tracing::warn!("━━━ SEARCH ━━━");
|
||||||
|
|
||||||
let handler = ContentHandler::new();
|
let handler = ContentHandler::new();
|
||||||
|
|
||||||
|
|||||||
@@ -619,6 +619,16 @@ impl QobuzApi {
|
|||||||
.collect())
|
.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
|
/// Recherche dans le catalogue
|
||||||
pub async fn search(&self, query: &str, type_: Option<&str>) -> Result<SearchResult> {
|
pub async fn search(&self, query: &str, type_: Option<&str>) -> Result<SearchResult> {
|
||||||
debug!("Searching for '{}' (type: {:?})", query, type_);
|
debug!("Searching for '{}' (type: {:?})", query, type_);
|
||||||
@@ -662,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", ¶ms).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", ¶ms).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", ¶ms).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", ¶ms).await?;
|
||||||
|
Ok(resp.playlists.items.into_iter().map(Self::parse_playlist).collect())
|
||||||
|
}
|
||||||
|
|
||||||
// Fonctions de parsing publiques (utilisées aussi par le module user)
|
// Fonctions de parsing publiques (utilisées aussi par le module user)
|
||||||
|
|
||||||
pub(crate) fn parse_album(response: AlbumResponse) -> Album {
|
pub(crate) fn parse_album(response: AlbumResponse) -> Album {
|
||||||
|
|||||||
@@ -827,6 +827,11 @@ impl QobuzClient {
|
|||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
/// * `query` - Termes de recherche
|
/// * `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")
|
/// * `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> {
|
pub async fn search(&self, query: &str, type_: Option<&str>) -> Result<SearchResult> {
|
||||||
// Créer une clé de cache
|
// Créer une clé de cache
|
||||||
@@ -849,28 +854,60 @@ impl QobuzClient {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recherche des albums
|
/// Recherche des albums via `/album/search` (endpoint dédié, paginé)
|
||||||
pub async fn search_albums(&self, query: &str) -> Result<Vec<Album>> {
|
pub async fn search_albums(&self, query: &str, limit: u32, offset: u32) -> Result<Vec<Album>> {
|
||||||
let result = self.search(query, Some("albums")).await?;
|
let cache_key = format!("albums:{}:{}:{}", query, limit, offset);
|
||||||
Ok(result.albums)
|
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
|
/// Recherche des artistes via `/artist/search` (endpoint dédié, paginé)
|
||||||
pub async fn search_artists(&self, query: &str) -> Result<Vec<Artist>> {
|
pub async fn search_artists(&self, query: &str, limit: u32, offset: u32) -> Result<Vec<Artist>> {
|
||||||
let result = self.search(query, Some("artists")).await?;
|
let cache_key = format!("artists:{}:{}:{}", query, limit, offset);
|
||||||
Ok(result.artists)
|
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
|
/// Recherche des tracks via `/track/search` (endpoint dédié, paginé)
|
||||||
pub async fn search_tracks(&self, query: &str) -> Result<Vec<Track>> {
|
pub async fn search_tracks(&self, query: &str, limit: u32, offset: u32) -> Result<Vec<Track>> {
|
||||||
let result = self.search(query, Some("tracks")).await?;
|
let cache_key = format!("tracks:{}:{}:{}", query, limit, offset);
|
||||||
Ok(result.tracks)
|
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
|
/// Recherche des playlists via `/playlist/search` (endpoint dédié, paginé)
|
||||||
pub async fn search_playlists(&self, query: &str) -> Result<Vec<Playlist>> {
|
pub async fn search_playlists(&self, query: &str, limit: u32, offset: u32) -> Result<Vec<Playlist>> {
|
||||||
let result = self.search(query, Some("playlists")).await?;
|
let cache_key = format!("playlists:{}:{}:{}", query, limit, offset);
|
||||||
Ok(result.playlists)
|
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 ============
|
// ============ Favoris ============
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use pmoaudiocache::{AudioMetadata, Cache as AudioCache};
|
|||||||
use pmocovers::Cache as CoverCache;
|
use pmocovers::Cache as CoverCache;
|
||||||
use pmodidl::{Container, Item};
|
use pmodidl::{Container, Item};
|
||||||
use pmosource::SourceCacheManager;
|
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 serde_json::json;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
@@ -1667,10 +1667,31 @@ impl QobuzSource {
|
|||||||
/// - "qobuz:playlist:{id}" → Tracks in playlist
|
/// - "qobuz:playlist:{id}" → Tracks in playlist
|
||||||
/// - etc.
|
/// - etc.
|
||||||
fn parse_object_id(&self, object_id: &str) -> ObjectIdType {
|
fn parse_object_id(&self, object_id: &str) -> ObjectIdType {
|
||||||
|
tracing::debug!(object_id, "parse_object_id");
|
||||||
if object_id == "qobuz" || object_id == "0" {
|
if object_id == "qobuz" || object_id == "0" {
|
||||||
return ObjectIdType::Root;
|
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();
|
let parts: Vec<&str> = object_id.split(':').collect();
|
||||||
match parts.as_slice() {
|
match parts.as_slice() {
|
||||||
// Discover Catalog
|
// Discover Catalog
|
||||||
@@ -1756,6 +1777,10 @@ enum ObjectIdType {
|
|||||||
Artist(String),
|
Artist(String),
|
||||||
Track(String),
|
Track(String),
|
||||||
|
|
||||||
|
// Containers virtuels de résultats de recherche
|
||||||
|
// (scope, media_type, query)
|
||||||
|
SearchResult(SearchScope, MediaSearchType, String),
|
||||||
|
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1872,6 +1897,20 @@ impl MusicSource for QobuzSource {
|
|||||||
Ok(BrowseResult::Containers(containers))
|
Ok(BrowseResult::Containers(containers))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ObjectIdType::SearchResult(scope, media_type, query) => {
|
||||||
|
tracing::debug!(scope = ?scope, media_type = ?media_type, query, "browse → execute_search");
|
||||||
|
let sq = SearchQuery {
|
||||||
|
text: query,
|
||||||
|
media_type,
|
||||||
|
scope,
|
||||||
|
limit: 200,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
let result = self.execute_search(&sq).await;
|
||||||
|
tracing::debug!(ok = result.is_ok(), "execute_search returned");
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
ObjectIdType::Track(_) => {
|
ObjectIdType::Track(_) => {
|
||||||
// Track object_ids ne sont pas browsables, retourner une erreur
|
// Track object_ids ne sont pas browsables, retourner une erreur
|
||||||
Err(MusicSourceError::NotSupported(
|
Err(MusicSourceError::NotSupported(
|
||||||
@@ -2021,86 +2060,14 @@ impl MusicSource for QobuzSource {
|
|||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn search(&self, query: &str) -> Result<BrowseResult> {
|
async fn search(&self, query: &SearchQuery) -> Result<BrowseResult> {
|
||||||
use tracing::debug;
|
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)
|
if query.media_type == MediaSearchType::All {
|
||||||
let results = self
|
self.search_grouped(query).await
|
||||||
.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 })
|
|
||||||
} else {
|
} else {
|
||||||
Ok(BrowseResult::Items(vec![]))
|
self.execute_search(query).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2116,7 +2083,7 @@ impl MusicSource for QobuzSource {
|
|||||||
supports_high_res_audio: true,
|
supports_high_res_audio: true,
|
||||||
max_sample_rate: Some(192_000), // Qobuz supports up to 192kHz
|
max_sample_rate: Some(192_000), // Qobuz supports up to 192kHz
|
||||||
supports_multiple_formats: true,
|
supports_multiple_formats: true,
|
||||||
supports_advanced_search: false, // TODO: Qobuz API supports it, not yet implemented
|
supports_advanced_search: true,
|
||||||
supports_pagination: true,
|
supports_pagination: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2426,6 +2393,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 mk_container = |type_str: &str, title: &str, count: usize| Container {
|
||||||
|
id: format!("qobuz:search:{}:{}:{}", scope_str, type_str, query.text),
|
||||||
|
parent_id: "qobuz:search".to_string(),
|
||||||
|
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 limit = query.limit;
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -155,21 +155,34 @@ pub enum CacheStatus {
|
|||||||
Failed { error: String },
|
Failed { error: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search filters for advanced search
|
/// Scope of a search operation
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct SearchFilters {
|
pub enum SearchScope {
|
||||||
/// Filter by artist name
|
/// Search the full provider catalog (Qobuz API, etc.)
|
||||||
pub artist: Option<String>,
|
Catalog,
|
||||||
/// Filter by album name
|
/// Search only within the user's saved library (favorites, playlists)
|
||||||
pub album: Option<String>,
|
UserLibrary,
|
||||||
/// Filter by genre
|
}
|
||||||
pub genre: Option<String>,
|
|
||||||
/// Minimum year
|
/// Type of media to search for
|
||||||
pub year_min: Option<u32>,
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
/// Maximum year
|
pub enum MediaSearchType {
|
||||||
pub year_max: Option<u32>,
|
/// All types — results grouped into navigable virtual containers
|
||||||
/// Maximum number of results
|
All,
|
||||||
pub limit: Option<usize>,
|
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
|
/// Source statistics
|
||||||
@@ -321,7 +334,7 @@ impl BrowseResult {
|
|||||||
/// Ok(vec![])
|
/// Ok(vec![])
|
||||||
/// }
|
/// }
|
||||||
///
|
///
|
||||||
/// async fn search(&self, query: &str) -> Result<BrowseResult> {
|
/// async fn search(&self, query: &SearchQuery) -> Result<BrowseResult> {
|
||||||
/// Err(pmosource::MusicSourceError::SearchNotSupported)
|
/// Err(pmosource::MusicSourceError::SearchNotSupported)
|
||||||
/// }
|
/// }
|
||||||
/// }
|
/// }
|
||||||
@@ -597,12 +610,11 @@ pub trait MusicSource: Debug + Send + Sync {
|
|||||||
/// # Examples
|
/// # Examples
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// let results = source.search("Pink Floyd").await?;
|
/// let q = SearchQuery { text: "Pink Floyd".into(), media_type: MediaSearchType::All,
|
||||||
/// for item in results.items() {
|
/// scope: SearchScope::Catalog, limit: 50, offset: 0 };
|
||||||
/// println!("Found: {}", item.title);
|
/// let results = source.search(&q).await?;
|
||||||
/// }
|
|
||||||
/// ```
|
/// ```
|
||||||
async fn search(&self, query: &str) -> Result<BrowseResult> {
|
async fn search(&self, query: &SearchQuery) -> Result<BrowseResult> {
|
||||||
let _ = query;
|
let _ = query;
|
||||||
Err(MusicSourceError::SearchNotSupported)
|
Err(MusicSourceError::SearchNotSupported)
|
||||||
}
|
}
|
||||||
@@ -890,40 +902,6 @@ pub trait MusicSource: Debug + Send + Sync {
|
|||||||
self.browse(object_id).await
|
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
|
/// Get source statistics
|
||||||
///
|
///
|
||||||
/// Returns information about the source such as total items, cache usage, etc.
|
/// Returns information about the source such as total items, cache usage, etc.
|
||||||
|
|||||||
Reference in New Issue
Block a user