Compare commits
15 Commits
bcfffa6df8
...
push-torlx
| Author | SHA1 | Date | |
|---|---|---|---|
| cc27e4efca | |||
| b7285e14c4 | |||
| 009545de40 | |||
| bfa6231b1d | |||
| 87bce3edfa | |||
| d49bb604ca | |||
| 429bc0d274 | |||
| d810e99a5b | |||
| 56a9f15cc1 | |||
| 0dab3077cf | |||
| 8179c0e239 | |||
| 319a54ee8a | |||
| 1d3a9379a1 | |||
| a30186485f | |||
| 0d4cf3cdae |
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/cache
|
||||
/project.local.yml
|
||||
133
.serena/project.yml
Normal file
133
.serena/project.yml
Normal file
@@ -0,0 +1,133 @@
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "pmomusic"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al angular ansible bash clojure
|
||||
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran
|
||||
# fsharp go groovy haskell haxe
|
||||
# hlsl html java json julia
|
||||
# kotlin lean4 lua luau markdown
|
||||
# matlab msl nix ocaml pascal
|
||||
# perl php php_phpactor powershell python
|
||||
# python_jedi python_ty r rego ruby
|
||||
# ruby_solargraph rust scala scss solidity
|
||||
# svelte swift systemverilog terraform toml
|
||||
# typescript typescript_vts vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- rust
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||
line_ending:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
ls_specific_settings: {}
|
||||
|
||||
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries.
|
||||
# Currently supported for: TypeScript.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
additional_workspace_folders: []
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude.
|
||||
# This extends the existing exclusions (e.g. from the global configuration)
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
excluded_tools: []
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||
# This extends the existing inclusions (e.g. from the global configuration).
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
||||
# for this project.
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
default_modes:
|
||||
|
||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
added_modes:
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||
# such as docstrings or parameter information.
|
||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||
# If null or missing, use the setting from the global configuration.
|
||||
symbol_info_budget:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
||||
# list of regex patterns for memories to completely ignore.
|
||||
# Matching memories will not appear in list_memories or activate_project output
|
||||
# and cannot be accessed via read_memory or write_memory.
|
||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
# Example: ["_archive/.*", "_episodes/.*"]
|
||||
ignored_memory_patterns: []
|
||||
@@ -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 |
|
||||
|
||||
323
Blackboard/ToDiscuss/play_from_url.md
Normal file
323
Blackboard/ToDiscuss/play_from_url.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# Play From URL — source `UrlSource`
|
||||
|
||||
Inspiré par BubbleUPnP : recevoir n'importe quelle URL (lien de partage Qobuz,
|
||||
flux radio, playlist M3U, page web contenant de l'audio…) et la jouer
|
||||
immédiatement sur le renderer actif.
|
||||
|
||||
---
|
||||
|
||||
## Vision architecturale
|
||||
|
||||
`UrlSource` est une **source musicale ordinaire** qui implémente `MusicSource`,
|
||||
exactement comme Qobuz, RadioFrance ou RadioParadise. Elle apparaît dans le
|
||||
drawer gauche au même titre que les autres sources du serveur PMO.
|
||||
|
||||
Sa particularité : sa **barre de recherche est le champ URL**. L'utilisateur
|
||||
colle ou tape une URL, appuie sur Entrée — la source résout l'URL et retourne
|
||||
le contenu jouable comme un `BrowseResult` normal.
|
||||
|
||||
```
|
||||
Drawer gauche
|
||||
└─ PMO Music Server
|
||||
├─ Qobuz
|
||||
├─ Radio Paradise
|
||||
├─ Radio France
|
||||
└─ URL / Partage ← nouvelle source
|
||||
└─ [barre de recherche = champ URL]
|
||||
└─ coller une URL + Entrée
|
||||
└─ résolution → BrowseResult → queue + play
|
||||
```
|
||||
|
||||
Avantages de cette approche :
|
||||
- **Zéro nouvelle UI** : la barre de recherche existante du drawer gère tout
|
||||
- **Zéro nouvel endpoint REST** : browse/search existants suffisent
|
||||
- **Zéro cas particulier** dans le drawer ou le content directory handler
|
||||
- `browse()` du container racine peut afficher un **historique** des URLs jouées
|
||||
|
||||
---
|
||||
|
||||
## Trait `UrlHandler` (dans `pmosource`)
|
||||
|
||||
Chaque source (et un handler générique) peut revendiquer les URLs qu'elle sait
|
||||
résoudre.
|
||||
|
||||
```rust
|
||||
pub enum ResolvedContent {
|
||||
/// Référence à un container d'une source existante
|
||||
/// → la UrlSource délègue le browse à cette source
|
||||
SourceContainer {
|
||||
source_id: String, // "qobuz", "radiofrance", …
|
||||
container_id: String, // "qobuz:album:l46fxnqnxp5vs"
|
||||
},
|
||||
/// Liste de tracks (M3U, PLS, XSPF, RSS/podcast…)
|
||||
Playlist {
|
||||
title: Option<String>,
|
||||
items: Vec<ResolvedTrack>,
|
||||
},
|
||||
/// Track unique ou flux continu
|
||||
Track {
|
||||
uri: String,
|
||||
metadata: TrackMetadata,
|
||||
},
|
||||
Stream {
|
||||
uri: String,
|
||||
metadata: StreamMetadata,
|
||||
},
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UrlHandler: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
/// Priorité : plus grand = essayé en premier (défaut 50)
|
||||
fn priority(&self) -> u8 { 50 }
|
||||
/// Test rapide sans I/O (regex sur l'URL)
|
||||
fn can_handle(&self, url: &str) -> bool;
|
||||
/// Résolution effective (I/O autorisé)
|
||||
async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Handlers spécifiques aux sources
|
||||
|
||||
### `QobuzUrlHandler` (dans `pmoqobuz`) — priorité 90
|
||||
|
||||
URLs reconnues. Les IDs sont potentiellement alphanumériques pour tous les
|
||||
types (pas seulement les albums) :
|
||||
|
||||
| Forme d'URL | Exemple |
|
||||
|---|---|
|
||||
| `open.qobuz.com/album/<id>` | `https://open.qobuz.com/album/l46fxnqnxp5vs` |
|
||||
| `play.qobuz.com/album/<id>` | `https://play.qobuz.com/album/l46fxnqnxp5vs` |
|
||||
| `open.qobuz.com/track/<id>` | `https://open.qobuz.com/track/48471123` |
|
||||
| `open.qobuz.com/playlist/<id>` | `https://open.qobuz.com/playlist/63246908` |
|
||||
| `open.qobuz.com/artist/<id>` | `https://open.qobuz.com/artist/125709` |
|
||||
|
||||
Regex d'extraction : `[a-zA-Z0-9]+` pour tous les types sans exception.
|
||||
|
||||
Résolution sans appel API — l'ID est directement mappé sur un container_id :
|
||||
|
||||
```
|
||||
open.qobuz.com/album/l46fxnqnxp5vs
|
||||
→ ResolvedContent::SourceContainer {
|
||||
source_id: "qobuz",
|
||||
container_id: "qobuz:album:l46fxnqnxp5vs",
|
||||
}
|
||||
```
|
||||
|
||||
### `RadioFranceUrlHandler` (dans `pmoradiofrance`) — priorité 90
|
||||
|
||||
URLs `radiofrance.fr/*`, `francemusique.fr/*`, `fip.fr/*`, etc.
|
||||
→ `ResolvedContent::Stream`
|
||||
|
||||
### `RadioParadiseUrlHandler` (dans `pmoparadise`) — priorité 90
|
||||
|
||||
URLs `radioparadise.com/*`
|
||||
→ `ResolvedContent::Stream`
|
||||
|
||||
---
|
||||
|
||||
## Handler générique (dans `pmourlresolver`, nouveau crate) — priorité 10
|
||||
|
||||
Dernier recours. Pipeline interne :
|
||||
|
||||
```
|
||||
URL
|
||||
│
|
||||
├─ Garde-fou SSRF : rejeter si IP résolue est privée/locale
|
||||
│ (RFC-1918 : 10/8, 172.16/12, 192.168/16 ; loopback : 127/8, ::1 ;
|
||||
│ link-local : 169.254/16, fe80::/10)
|
||||
│ → aucun cas d'usage légitime pour une URL interne ici
|
||||
│
|
||||
├─ HEAD request → Content-Type audio/* ?
|
||||
│ └─ → ResolvedContent::Stream / Track (URI directe)
|
||||
│
|
||||
├─ Extension ou Content-Type playlist ?
|
||||
│ ├─ .m3u / .m3u8 / application/vnd.apple.mpegurl → parse M3U
|
||||
│ ├─ .pls / audio/x-scpls → parse PLS
|
||||
│ └─ .xspf / application/xspf+xml → parse XSPF
|
||||
│
|
||||
├─ application/rss+xml / application/xml ?
|
||||
│ └─ → parse RSS, extraire les <enclosure> audio → Playlist
|
||||
│
|
||||
└─ text/html ?
|
||||
└─ GET + parse HTML
|
||||
├─ <audio src="...">
|
||||
├─ <link type="application/rss+xml"> → RSS/Podcast
|
||||
├─ og:audio
|
||||
└─ JSON-LD @type MusicRecording / MusicAlbum
|
||||
```
|
||||
|
||||
Pas de yt-dlp ni de dépendance Python externe — hors scope.
|
||||
|
||||
---
|
||||
|
||||
## `UrlSource` — implémentation de `MusicSource`
|
||||
|
||||
```rust
|
||||
pub struct UrlSource {
|
||||
resolver: UrlResolver, // registre des handlers
|
||||
history: Arc<RwLock<VecDeque<HistoryEntry>>>, // dernières URLs
|
||||
}
|
||||
```
|
||||
|
||||
### `name()` / `id()`
|
||||
|
||||
```rust
|
||||
fn name(&self) -> &str { "URL / Partage" }
|
||||
fn id(&self) -> &str { "url" }
|
||||
```
|
||||
|
||||
### `root_container()`
|
||||
|
||||
Retourne un container dont le contenu (`browse("url")`) est l'historique des
|
||||
dernières URLs résolues avec succès (titre, source résolue, date).
|
||||
|
||||
### `search(query)` — cœur de la fonctionnalité
|
||||
|
||||
`query.text` est l'URL collée par l'utilisateur.
|
||||
|
||||
```
|
||||
search(url)
|
||||
│
|
||||
├─ resolver.resolve(url)
|
||||
│
|
||||
└─ match ResolvedContent
|
||||
├─ SourceContainer { source_id, container_id }
|
||||
│ → get_source(source_id) → source.browse(container_id)
|
||||
│ → retourner le BrowseResult tel quel
|
||||
│ → ajouter à l'historique
|
||||
│
|
||||
├─ Playlist { items }
|
||||
│ → construire un BrowseResult::Items depuis les tracks
|
||||
│ → ajouter à l'historique
|
||||
│
|
||||
├─ Track / Stream
|
||||
│ → BrowseResult::Items avec un seul item
|
||||
│ → ajouter à l'historique
|
||||
│
|
||||
└─ Err → BrowseResult vide + log
|
||||
```
|
||||
|
||||
Pour la délégation `SourceContainer`, `UrlSource` accède au `SOURCE_REGISTRY`
|
||||
global (déjà disponible dans `pmosource`). Elle est enregistrée après les autres
|
||||
sources donc elles sont toutes présentes au moment de la résolution.
|
||||
|
||||
### `browse(container_id)`
|
||||
|
||||
- `"url"` → liste de l'historique (containers/items)
|
||||
- `"url:history:<n>"` → détail d'une entrée historique (si Playlist)
|
||||
|
||||
---
|
||||
|
||||
## Initialisation dans `pmomediaserver`
|
||||
|
||||
```rust
|
||||
// Après enregistrement de Qobuz, RadioFrance, RadioParadise…
|
||||
|
||||
let mut resolver = UrlResolver::new();
|
||||
resolver.register(Arc::new(QobuzUrlHandler::new()));
|
||||
resolver.register(Arc::new(RadioFranceUrlHandler::new()));
|
||||
resolver.register(Arc::new(RadioParadiseUrlHandler::new()));
|
||||
resolver.register(Arc::new(GenericUrlHandler::new())); // toujours en dernier
|
||||
|
||||
let url_source = Arc::new(UrlSource::new(resolver));
|
||||
register_source(url_source).await;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Points d'entrée
|
||||
|
||||
Le pipeline de résolution (`UrlResolver`) est le même quel que soit le point
|
||||
d'entrée. Deux modes complémentaires :
|
||||
|
||||
### Mode "pull" — le drawer
|
||||
|
||||
1. L'utilisateur ouvre le drawer gauche → voit "URL / Partage" dans la liste
|
||||
2. Il entre dedans → voit l'historique et la barre avec placeholder "Coller une URL…"
|
||||
3. Il colle `https://open.qobuz.com/album/l46fxnqnxp5vs` + Entrée
|
||||
4. Le drawer affiche les tracks de l'album (délégation Qobuz transparente)
|
||||
5. Il clique ▶ sur un track ou l'album entier → lecture normale
|
||||
|
||||
Aucune modification du drawer nécessaire.
|
||||
|
||||
### Mode "push" — endpoint REST + Web Share Target (Android)
|
||||
|
||||
Endpoint REST dans `pmocontrol` :
|
||||
|
||||
```
|
||||
POST /api/play-url
|
||||
{ "url": "https://open.qobuz.com/album/l46fxnqnxp5vs" }
|
||||
```
|
||||
|
||||
Résout l'URL via `UrlResolver` → ajoute au renderer actif → lecture immédiate.
|
||||
Pas de navigation dans le drawer, pas de clic supplémentaire.
|
||||
|
||||
**Web Share Target (PWA)** — intégration dans le share sheet Android :
|
||||
|
||||
```json
|
||||
// manifest.json
|
||||
"share_target": {
|
||||
"action": "/share",
|
||||
"method": "GET",
|
||||
"params": { "url": "url" }
|
||||
}
|
||||
```
|
||||
|
||||
La page `/share?url=...` appelle l'endpoint REST et se ferme. Depuis n'importe
|
||||
quelle application Android (Qobuz, navigateur, Spotify…) : menu "Partager" →
|
||||
choisir PMOMusic → l'album/track joue immédiatement sur le renderer courant,
|
||||
exactement comme BubbleUPnP.
|
||||
|
||||
Le renderer "courant" est celui qui est sélectionné dans la session active.
|
||||
Pour une PWA installée sur Android, c'est la session de l'utilisateur
|
||||
qui a installé l'app. Si plusieurs renderers sont disponibles, l'endpoint
|
||||
peut prendre un paramètre optionnel `renderer_id` pour cibler explicitement.
|
||||
|
||||
---
|
||||
|
||||
## Plan d'implémentation
|
||||
|
||||
### Étape 1 — Trait + QobuzUrlHandler + UrlSource minimale
|
||||
|
||||
- [ ] Ajouter `UrlHandler`, `ResolvedContent`, `UrlResolver` dans `pmosource`
|
||||
- [ ] Implémenter `QobuzUrlHandler` dans `pmoqobuz` (regex + mapping container_id)
|
||||
- [ ] Implémenter `UrlSource` avec `search()` gérant `SourceContainer`
|
||||
- [ ] Enregistrer dans `pmomediaserver`
|
||||
- [ ] Tester : coller un lien Qobuz → album joue
|
||||
|
||||
### Étape 2 — Formats de playlist directs
|
||||
|
||||
- [ ] Nouveau crate `pmourlresolver` avec `GenericUrlHandler`
|
||||
- [ ] Garde-fou SSRF (`is_safe_url()`)
|
||||
- [ ] Détection Content-Type + parse M3U, PLS, XSPF
|
||||
- [ ] `UrlSource::search()` gère `Playlist` et `Track/Stream`
|
||||
|
||||
### Étape 3 — Scraper HTML + historique
|
||||
|
||||
- [ ] Parse HTML : `<audio>`, og:audio, JSON-LD, RSS
|
||||
- [ ] Historique dans `UrlSource::browse()`
|
||||
- [ ] Placeholder adapté dans la barre de recherche du drawer
|
||||
|
||||
---
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
**Q1 — Barre de recherche : placeholder contextuel**
|
||||
Quand l'utilisateur est dans "URL / Partage", le placeholder devrait afficher
|
||||
"Coller une URL…" plutôt que "Rechercher…". Le drawer peut-il adapter le
|
||||
placeholder selon la source active ? À voir si c'est utile en pratique (le
|
||||
titre de la source dans le header est déjà indicatif).
|
||||
|
||||
**Q2 — Redirections**
|
||||
Les liens de partage mobiles Qobuz peuvent être des URLs raccourcies. Suivre
|
||||
les redirections automatiquement (reqwest le fait avec
|
||||
`redirect::Policy::limited(5)`).
|
||||
|
||||
**Q3 — Validation Qobuz**
|
||||
Le `QobuzUrlHandler` retourne un `SourceContainer` sans vérifier que l'album
|
||||
existe ou est accessible. L'erreur éventuelle sera levée au moment du browse
|
||||
délégué à `QobuzSource`. C'est acceptable : l'erreur arrivera rapidement avec
|
||||
un message clair.
|
||||
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é.
|
||||
130
Cargo.lock
generated
130
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.51"
|
||||
version = "0.3.55"
|
||||
dependencies = [
|
||||
"axum 0.8.7",
|
||||
"console-subscriber",
|
||||
@@ -188,6 +188,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.13.3"
|
||||
@@ -963,6 +975,23 @@ dependencies = [
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
|
||||
dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -1876,8 +1905,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1887,9 +1918,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2184,6 +2217,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2751,6 +2785,12 @@ dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -3969,6 +4009,7 @@ dependencies = [
|
||||
"pmoserver",
|
||||
"pmosource",
|
||||
"pmoupnp",
|
||||
"pmourlsource",
|
||||
"pmoutils",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
@@ -4072,6 +4113,7 @@ dependencies = [
|
||||
"cbc",
|
||||
"chrono",
|
||||
"ctr",
|
||||
"futures",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"indexmap 2.12.0",
|
||||
@@ -4229,6 +4271,21 @@ dependencies = [
|
||||
"xmltree 0.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pmourlsource"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures",
|
||||
"pmodidl",
|
||||
"pmosource",
|
||||
"reqwest",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pmoutils"
|
||||
version = "0.1.2"
|
||||
@@ -4528,6 +4585,61 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.17",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.42"
|
||||
@@ -4747,6 +4859,7 @@ version = "0.12.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
@@ -4769,6 +4882,8 @@ dependencies = [
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4776,6 +4891,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower 0.5.2",
|
||||
"tower-http",
|
||||
@@ -4785,6 +4901,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4957,6 +5074,7 @@ version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -6640,6 +6758,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webp"
|
||||
version = "0.3.1"
|
||||
|
||||
@@ -23,6 +23,7 @@ members = [
|
||||
"pmoflac",
|
||||
"pmometadata",
|
||||
"pmocontrol",
|
||||
"pmourlsource",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -57,6 +57,7 @@ COPY pmoplaylist/ ./pmoplaylist/
|
||||
COPY pmoflac/ ./pmoflac/
|
||||
COPY pmometadata/ ./pmometadata/
|
||||
COPY pmocontrol/ ./pmocontrol/
|
||||
COPY pmourlsource/ ./pmourlsource/
|
||||
COPY pmoaudio-ext/ ./pmoaudio-ext/
|
||||
COPY pmoapp/ ./pmoapp/
|
||||
|
||||
|
||||
4
Makefile
4
Makefile
@@ -171,7 +171,9 @@ run: debug
|
||||
run-release: release
|
||||
@echo "$(YELLOW)→ Lancement de l'application (release) via Terminal.app...$(NC)"
|
||||
@echo "$(BLUE) (Terminal.app est nécessaire pour le multicast sur macOS Sequoia+)$(NC)"
|
||||
@osascript -e 'tell application "Terminal" to do script "cd \"$(CURDIR)\" && ./$(RUST_TARGET)/$(BINARY_NAME) 2>&1 | tee pmomusic.log; exit"'
|
||||
@RUST_LOG_PREFIX=""; \
|
||||
if [ -n "$(RUST_LOG)" ]; then RUST_LOG_PREFIX="export RUST_LOG='$(RUST_LOG)' && "; fi; \
|
||||
osascript -e "tell application \"Terminal\" to do script \"cd '$(CURDIR)' && $${RUST_LOG_PREFIX}./$(RUST_TARGET)/$(BINARY_NAME) 2>&1 | tee pmomusic.log; exit\""
|
||||
|
||||
## size: Affiche la taille du binaire
|
||||
size:
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.51"
|
||||
version = "0.3.55"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
pmoupnp = { path = "../pmoupnp"}
|
||||
pmomediarenderer = { path = "../pmomediarenderer" }
|
||||
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "radiofrance", "api"] }
|
||||
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "radiofrance", "urlsource", "api"] }
|
||||
pmosource = { path = "../pmosource", features = ["server"] }
|
||||
pmoserver = { path = "../pmoserver" }
|
||||
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
|
||||
|
||||
@@ -60,6 +60,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing::warn!("⚠️ Failed to register Radio France source: {}", e);
|
||||
}
|
||||
|
||||
// Enregistrer la source URL / Partage
|
||||
info!("🔗 Registering URL source...");
|
||||
if let Err(e) = server.write().await.register_urlsource().await {
|
||||
tracing::warn!("⚠️ Failed to register URL source: {}", e);
|
||||
}
|
||||
|
||||
// Lister toutes les sources enregistrées
|
||||
let sources = server.read().await.list_music_sources().await;
|
||||
info!("✅ {} music source(s) registered", sources.len());
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -41,24 +41,45 @@ 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() {
|
||||
async function handleClearSearch() {
|
||||
searchInput.value = '';
|
||||
clearSearch();
|
||||
if (!currentServer.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await browseContainer(currentServer.value.id, "0");
|
||||
currentContainerId.value = "0";
|
||||
setPath([{ id: "0", title: currentServer.value.friendly_name }]);
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Erreur navigation racine après clear search:", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } =
|
||||
@@ -166,6 +187,7 @@ watch(
|
||||
clearPath();
|
||||
closeMenu();
|
||||
imageStates.clear();
|
||||
searchInput.value = '';
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -216,6 +238,7 @@ async function handleServerClick(server: MediaServerSummary) {
|
||||
|
||||
// Commencer la navigation dans ce serveur
|
||||
currentServer.value = server;
|
||||
searchInput.value = '';
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
@@ -235,6 +258,7 @@ function goBack() {
|
||||
currentServer.value = null;
|
||||
currentContainerId.value = null;
|
||||
clearPath();
|
||||
searchInput.value = '';
|
||||
}
|
||||
|
||||
async function handleContainerClick(item: ContainerEntry) {
|
||||
@@ -473,7 +497,7 @@ function handleSettingsClick() {
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<button
|
||||
v-if="searchInput || isSearchMode"
|
||||
v-if="searchInput"
|
||||
class="search-clear-btn"
|
||||
@click="handleClearSearch"
|
||||
title="Effacer"
|
||||
@@ -578,53 +602,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,25 @@ struct SearchQuery {
|
||||
q: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn search_result_container_id(entries: &[ContainerEntry]) -> String {
|
||||
entries
|
||||
.iter()
|
||||
.find(|entry| entry.is_container)
|
||||
.map(|entry| {
|
||||
let parts: Vec<&str> = entry.id.splitn(5, ':').collect();
|
||||
if parts.len() == 5 && parts[0] == "qobuz" && parts[1] == "search" {
|
||||
// Résultat de recherche Qobuz : reconstruire le container virtuel parent
|
||||
// ex. "qobuz:search:catalog:albums:Beethoven" → "qobuz:search:catalog:all:Beethoven"
|
||||
format!("qobuz:search:{}:all:{}", parts[2], parts[4])
|
||||
} else {
|
||||
// Résultat d'une autre source (ex. UrlSource) : retourner l'ID tel quel
|
||||
entry.id.clone()
|
||||
}
|
||||
})
|
||||
.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 +2524,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,
|
||||
|
||||
@@ -26,6 +26,7 @@ utoipa = { version = "5.3", optional = true }
|
||||
pmoqobuz = { path = "../pmoqobuz", optional = true }
|
||||
pmoparadise = { path = "../pmoparadise", optional = true }
|
||||
pmoradiofrance = { path = "../pmoradiofrance", optional = true }
|
||||
pmourlsource = { path = "../pmourlsource", optional = true }
|
||||
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
anyhow = { version = "1.0", optional = true }
|
||||
pmoaudiocache = { path = "../pmoaudiocache", optional = true }
|
||||
@@ -61,3 +62,5 @@ radiofrance = [
|
||||
"pmoradiofrance/logging",
|
||||
"dep:pmoconfig"
|
||||
]
|
||||
# Feature pour activer la source URL / Partage
|
||||
urlsource = ["api", "dep:pmourlsource"]
|
||||
|
||||
@@ -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
|
||||
@@ -197,11 +273,32 @@ impl ContentHandler {
|
||||
}
|
||||
}
|
||||
BrowseResult::Items(items) => {
|
||||
if let Some(item) = items.first() {
|
||||
let didl = to_didl_lite(&[], &[item.clone()])?;
|
||||
let update_id = source.update_id().await.max(1);
|
||||
return Ok((didl, 1, 1, update_id));
|
||||
}
|
||||
// object_id is a container (album, playlist…) whose
|
||||
// browse() returns its children as Items. BrowseMetadata
|
||||
// must return the container itself, not the first child.
|
||||
let title = items
|
||||
.first()
|
||||
.and_then(|i| i.album.as_deref())
|
||||
.unwrap_or(object_id)
|
||||
.to_string();
|
||||
let album_art =
|
||||
items.first().and_then(|i| i.album_art.clone());
|
||||
let container = Container {
|
||||
id: object_id.to_string(),
|
||||
parent_id: "0".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: Some(items.len().to_string()),
|
||||
searchable: Some("0".to_string()),
|
||||
title,
|
||||
class: "object.container".to_string(),
|
||||
artist: None,
|
||||
album_art,
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
};
|
||||
let didl = to_didl_lite(&[container], &[])?;
|
||||
let update_id = source.update_id().await.max(1);
|
||||
return Ok((didl, 1, 1, update_id));
|
||||
}
|
||||
BrowseResult::Mixed { containers, items } => {
|
||||
if let Some(container) = containers.first() {
|
||||
@@ -518,20 +615,47 @@ 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
|
||||
// Si le texte ressemble à une URL, seules les sources qui gèrent les URLs
|
||||
// sont interrogées — les autres (Qobuz, etc.) interpréteraient l'URL comme
|
||||
// du texte libre et renverraient des résultats parasites.
|
||||
let is_url_query = text.starts_with("http://") || text.starts_with("https://");
|
||||
|
||||
for source in list_all_sources().await {
|
||||
if source.capabilities().supports_search {
|
||||
if let Ok(result) = source.search(search_criteria).await {
|
||||
match result {
|
||||
BrowseResult::Containers(c) => all_containers.extend(c),
|
||||
BrowseResult::Items(i) => all_items.extend(i),
|
||||
BrowseResult::Mixed { containers, items } => {
|
||||
all_containers.extend(containers);
|
||||
all_items.extend(items);
|
||||
}
|
||||
let caps = source.capabilities();
|
||||
if !caps.supports_search {
|
||||
continue;
|
||||
}
|
||||
if is_url_query && !caps.handles_url_input {
|
||||
continue;
|
||||
}
|
||||
if let Ok(result) = source.search(&query).await {
|
||||
match result {
|
||||
BrowseResult::Containers(c) => all_containers.extend(c),
|
||||
BrowseResult::Items(i) => all_items.extend(i),
|
||||
BrowseResult::Mixed { containers, items } => {
|
||||
all_containers.extend(containers);
|
||||
all_items.extend(items);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -540,7 +664,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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ pub enum SourceInitError {
|
||||
#[error("Failed to initialize Radio France: {0}")]
|
||||
RadioFranceError(String),
|
||||
|
||||
#[cfg(feature = "urlsource")]
|
||||
#[error("Failed to initialize URL source: {0}")]
|
||||
UrlSourceError(String),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
@@ -141,6 +145,14 @@ pub trait SourcesExt {
|
||||
/// ```
|
||||
#[cfg(feature = "radiofrance")]
|
||||
async fn register_radiofrance(&mut self) -> Result<()>;
|
||||
|
||||
/// Enregistre la source URL / Partage
|
||||
///
|
||||
/// Cette source permet de coller n'importe quelle URL (lien de partage Qobuz,
|
||||
/// flux audio, playlist M3U…) dans la barre de recherche et de lancer la lecture
|
||||
/// directement. Aucune authentification requise.
|
||||
#[cfg(feature = "urlsource")]
|
||||
async fn register_urlsource(&mut self) -> Result<()>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -281,6 +293,34 @@ impl SourcesExt for Server {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "urlsource")]
|
||||
async fn register_urlsource(&mut self) -> Result<()> {
|
||||
use pmourlsource::{GenericUrlHandler, QobuzUrlHandler, RadioFranceUrlHandler, UrlResolver, UrlSource};
|
||||
|
||||
tracing::info!("Initializing URL source...");
|
||||
|
||||
let mut resolver = UrlResolver::new();
|
||||
// Handlers spécialisés (priorité haute) — résolution sans I/O ou API dédiée
|
||||
resolver.register(Box::new(QobuzUrlHandler::new()));
|
||||
match RadioFranceUrlHandler::new() {
|
||||
Ok(h) => resolver.register(Box::new(h)),
|
||||
Err(e) => tracing::warn!("Failed to build RadioFranceUrlHandler HTTP client: {}", e),
|
||||
}
|
||||
// Handler générique (priorité basse) — HTTP GET + scraping HTML/RSS
|
||||
match GenericUrlHandler::new() {
|
||||
Ok(h) => resolver.register(Box::new(h)),
|
||||
Err(e) => tracing::warn!("Failed to build GenericUrlHandler HTTP client: {}", e),
|
||||
}
|
||||
|
||||
let base_url = self.base_url().to_string();
|
||||
let source = Arc::new(UrlSource::new(resolver, base_url));
|
||||
self.register_music_source(source).await;
|
||||
|
||||
tracing::info!("✅ URL source registered successfully");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -816,6 +816,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
supports_multiple_formats: true,
|
||||
supports_advanced_search: false,
|
||||
supports_pagination: false,
|
||||
handles_url_input: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ reqwest = { version = "0.12", features = ["json", "cookies"] }
|
||||
|
||||
# Gestion asynchrone
|
||||
tokio = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
# Sérialisation/Désérialisation JSON
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -413,46 +413,100 @@ impl QobuzApi {
|
||||
|
||||
/// Récupère les tracks d'une playlist.
|
||||
///
|
||||
/// Phase 1 : pagination de `/playlist/get?extra=tracks` pour collecter les IDs
|
||||
/// et les données de base.
|
||||
/// Phase 2 (si secret disponible) : enrichissement via `track/getList` pour
|
||||
/// obtenir les métadonnées complètes (performer, sample_rate, bit_depth, channels).
|
||||
/// Phase 1 — pagination concurrente :
|
||||
/// - Page 1 séquentielle pour obtenir `total`
|
||||
/// - Pages 2..N lancées en parallèle (semaphore 3) dès que `total` est connu
|
||||
/// - Résultats triés par offset avant fusion
|
||||
///
|
||||
/// Phase 2 — enrichissement via `track/getList` pour métadonnées complètes
|
||||
/// (performer, sample_rate, bit_depth, channels).
|
||||
pub async fn get_playlist_tracks(&self, playlist_id: &str) -> Result<Vec<Track>> {
|
||||
use futures::future::try_join_all;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
const PAGE_SIZE: u32 = 500;
|
||||
const LIMIT_STR: &str = "500";
|
||||
// Configurable via accounts.qobuz.page_concurrency (défaut 3).
|
||||
let max_concurrent_pages = self.page_concurrency;
|
||||
|
||||
debug!("Fetching tracks for playlist {}", playlist_id);
|
||||
const PAGE_SIZE: u32 = 50;
|
||||
let mut ordered_ids: Vec<String> = Vec::new();
|
||||
let mut fallback_tracks: Vec<Track> = Vec::new();
|
||||
let mut offset = 0u32;
|
||||
|
||||
// Phase 1 : pagination pour collecter les IDs et les tracks de base
|
||||
loop {
|
||||
let offset_str = offset.to_string();
|
||||
let limit_str = PAGE_SIZE.to_string();
|
||||
let params = [
|
||||
("playlist_id", playlist_id),
|
||||
("extra", "tracks"),
|
||||
("offset", offset_str.as_str()),
|
||||
("limit", limit_str.as_str()),
|
||||
];
|
||||
let response: PlaylistResponse = self.get("/playlist/get", ¶ms).await?;
|
||||
// Page 1 — séquentielle : récupère les IDs + total
|
||||
let first_response: PlaylistResponse = self
|
||||
.get(
|
||||
"/playlist/get",
|
||||
&[
|
||||
("playlist_id", playlist_id),
|
||||
("extra", "tracks"),
|
||||
("offset", "0"),
|
||||
("limit", LIMIT_STR),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(tracks) = response.tracks {
|
||||
let total = tracks.total.unwrap_or(0);
|
||||
let count = tracks.items.len() as u32;
|
||||
for t in tracks.items {
|
||||
ordered_ids.push(t.id.clone());
|
||||
fallback_tracks.push(Self::parse_track(t, None));
|
||||
}
|
||||
offset += count;
|
||||
if count == 0 || offset >= total {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
let first_page = match first_response.tracks {
|
||||
Some(t) => t,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let total = first_page.total.unwrap_or(0);
|
||||
if total == 0 || first_page.items.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
debug!("Fetched {} track IDs for playlist {}", ordered_ids.len(), playlist_id);
|
||||
// Offsets des pages restantes : 500, 1000, 1500, ...
|
||||
let remaining_offsets: Vec<u32> = (PAGE_SIZE..total)
|
||||
.step_by(PAGE_SIZE as usize)
|
||||
.collect();
|
||||
|
||||
let n_pages = 1 + remaining_offsets.len();
|
||||
|
||||
// Pages 2..N — concurrentes
|
||||
let mut pages: Vec<(u32, Vec<TrackResponse>)> =
|
||||
Vec::with_capacity(n_pages);
|
||||
pages.push((0, first_page.items));
|
||||
|
||||
if !remaining_offsets.is_empty() {
|
||||
let sem = Arc::new(Semaphore::new(max_concurrent_pages));
|
||||
|
||||
let futs = remaining_offsets.iter().map(|&off| {
|
||||
let sem = sem.clone();
|
||||
async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
let offset_str = off.to_string();
|
||||
let response: PlaylistResponse = self
|
||||
.get(
|
||||
"/playlist/get",
|
||||
&[
|
||||
("playlist_id", playlist_id),
|
||||
("extra", "tracks"),
|
||||
("offset", offset_str.as_str()),
|
||||
("limit", LIMIT_STR),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let items = response.tracks.map(|t| t.items).unwrap_or_default();
|
||||
Ok::<(u32, Vec<TrackResponse>), QobuzError>((off, items))
|
||||
}
|
||||
});
|
||||
|
||||
let mut extra = try_join_all(futs).await?;
|
||||
pages.append(&mut extra);
|
||||
}
|
||||
|
||||
// Tri par offset pour garantir l'ordre de la playlist
|
||||
pages.sort_unstable_by_key(|(off, _)| *off);
|
||||
|
||||
let ordered_ids: Vec<String> = pages
|
||||
.into_iter()
|
||||
.flat_map(|(_, items)| items.into_iter().map(|t| t.id))
|
||||
.collect();
|
||||
|
||||
debug!(
|
||||
"Fetched {} track IDs for playlist {} ({} pages)",
|
||||
ordered_ids.len(), playlist_id, n_pages
|
||||
);
|
||||
|
||||
if ordered_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -467,7 +521,10 @@ impl QobuzApi {
|
||||
.iter()
|
||||
.filter_map(|id| track_map.remove(id.as_str()))
|
||||
.collect();
|
||||
debug!("Fetched {} tracks for playlist {} via track/getList", enriched.len(), playlist_id);
|
||||
debug!(
|
||||
"Fetched {} tracks for playlist {} via track/getList",
|
||||
enriched.len(), playlist_id
|
||||
);
|
||||
Ok(enriched)
|
||||
}
|
||||
|
||||
@@ -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", ¶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)
|
||||
|
||||
pub(crate) fn parse_album(response: AlbumResponse) -> Album {
|
||||
|
||||
@@ -99,6 +99,8 @@ pub struct QobuzApi {
|
||||
format_id: AudioFormat,
|
||||
/// Gestionnaire de session CMAF (renouvellement automatique thread-safe)
|
||||
pub(crate) cmaf_session: CmafSessionManager,
|
||||
/// Nombre de pages de playlist chargées en parallèle (configurable)
|
||||
pub(crate) page_concurrency: usize,
|
||||
}
|
||||
|
||||
impl QobuzApi {
|
||||
@@ -119,6 +121,7 @@ impl QobuzApi {
|
||||
user_id: RwLock::new(None),
|
||||
format_id: AudioFormat::default(),
|
||||
cmaf_session: CmafSessionManager::new(),
|
||||
page_concurrency: 3,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -211,6 +214,11 @@ impl QobuzApi {
|
||||
*self.user_id.write().unwrap() = None;
|
||||
}
|
||||
|
||||
/// Définit le nombre de pages de playlist chargées en parallèle
|
||||
pub fn set_page_concurrency(&mut self, n: usize) {
|
||||
self.page_concurrency = n.max(1);
|
||||
}
|
||||
|
||||
/// Définit le format audio par défaut
|
||||
pub fn set_format(&mut self, format: AudioFormat) {
|
||||
self.format_id = format;
|
||||
|
||||
@@ -176,6 +176,8 @@ impl QobuzClient {
|
||||
}
|
||||
};
|
||||
|
||||
api.set_page_concurrency(config.get_qobuz_page_concurrency());
|
||||
|
||||
if config.is_qobuz_auth_valid() {
|
||||
match (config.get_qobuz_auth_token(), config.get_qobuz_user_id()) {
|
||||
(Ok(Some(token)), Ok(Some(user_id)))
|
||||
@@ -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 ============
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -1876,6 +1925,69 @@ impl MusicSource for QobuzSource {
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_container(&self, object_id: &str) -> Result<Option<pmodidl::Container>> {
|
||||
use crate::didl::ToDIDL;
|
||||
match self.parse_object_id(object_id) {
|
||||
ObjectIdType::Album(album_id) => {
|
||||
let album = self
|
||||
.inner
|
||||
.client
|
||||
.get_album(&album_id)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
let album = self.cache_album_covers(vec![album]).await.into_iter().next().unwrap();
|
||||
let container = album
|
||||
.to_didl_container("qobuz")
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
Ok(Some(container))
|
||||
}
|
||||
|
||||
ObjectIdType::Playlist(playlist_id) => {
|
||||
let playlist = self
|
||||
.inner
|
||||
.client
|
||||
.get_playlist(&playlist_id)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
let container = playlist
|
||||
.to_didl_container("qobuz")
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
Ok(Some(container))
|
||||
}
|
||||
|
||||
ObjectIdType::Artist(artist_id) => {
|
||||
// Pas d'endpoint artist direct — on tire le nom/image depuis les albums
|
||||
let albums = self
|
||||
.inner
|
||||
.client
|
||||
.get_artist_albums(&artist_id)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
let first = albums.first();
|
||||
let artist_name = first
|
||||
.map(|a| a.artist.name.clone())
|
||||
.unwrap_or_else(|| format!("Artiste {}", artist_id));
|
||||
let album_art = first.and_then(|a| a.image_cached.clone().or_else(|| a.image.clone()));
|
||||
let container = pmodidl::Container {
|
||||
id: object_id.to_string(),
|
||||
parent_id: "qobuz".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: Some(albums.len().to_string()),
|
||||
searchable: Some("1".to_string()),
|
||||
title: artist_name.clone(),
|
||||
class: "object.container.person.musicArtist".to_string(),
|
||||
artist: Some(artist_name),
|
||||
album_art,
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
};
|
||||
Ok(Some(container))
|
||||
}
|
||||
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
|
||||
// Try cache manager first
|
||||
if let Ok(uri) = self.inner.cache_manager.resolve_uri(object_id).await {
|
||||
@@ -2014,86 +2126,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,8 +2149,9 @@ 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,
|
||||
handles_url_input: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2419,6 +2460,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::*;
|
||||
|
||||
@@ -207,6 +207,7 @@ impl MusicSource for RadioFranceSource {
|
||||
supports_multiple_formats: false,
|
||||
supports_advanced_search: false,
|
||||
supports_pagination: false,
|
||||
handles_url_input: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,9 @@ pub struct SourceCapabilities {
|
||||
pub supports_advanced_search: bool,
|
||||
/// Supports pagination in browse operations
|
||||
pub supports_pagination: bool,
|
||||
/// Handles URL input (http/https) instead of plain text search queries.
|
||||
/// When true, this source is called exclusively for URL-like search inputs.
|
||||
pub handles_url_input: bool,
|
||||
}
|
||||
|
||||
/// Audio format information
|
||||
@@ -155,21 +158,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 +337,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)
|
||||
/// }
|
||||
/// }
|
||||
@@ -451,6 +467,18 @@ pub trait MusicSource: Debug + Send + Sync {
|
||||
))
|
||||
}
|
||||
|
||||
/// Retourne les métadonnées d'un container (titre, artiste, cover, child_count)
|
||||
/// SANS charger ses enfants — un seul appel API léger.
|
||||
///
|
||||
/// Utilisé par UrlSource pour afficher un album/playlist en résultat de recherche
|
||||
/// avec les bonnes métadonnées et le bon `class` UPnP, avant que l'utilisateur
|
||||
/// ne navigue dedans ou ne lance la lecture.
|
||||
///
|
||||
/// L'implémentation par défaut retourne None (non supporté).
|
||||
async fn get_container(&self, _object_id: &str) -> Result<Option<Container>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Resolve the actual URI for a track
|
||||
///
|
||||
/// This method should return the URI that can be used to stream/download
|
||||
@@ -597,12 +625,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)
|
||||
}
|
||||
@@ -638,6 +665,7 @@ pub trait MusicSource: Debug + Send + Sync {
|
||||
supports_multiple_formats: false,
|
||||
supports_advanced_search: false,
|
||||
supports_pagination: false,
|
||||
handles_url_input: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -890,40 +918,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.
|
||||
|
||||
15
pmourlsource/Cargo.toml
Normal file
15
pmourlsource/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "pmourlsource"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
pmosource = { path = "../pmosource", features = ["server"] }
|
||||
pmodidl = { path = "../pmodidl" }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip"] }
|
||||
url = "2"
|
||||
BIN
pmourlsource/assets/url-source.webp
Normal file
BIN
pmourlsource/assets/url-source.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
122
pmourlsource/src/handler.rs
Normal file
122
pmourlsource/src/handler.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum UrlResolverError {
|
||||
#[error("URL non reconnue : {0}")]
|
||||
NotSupported(String),
|
||||
#[error("Résolution échouée : {0}")]
|
||||
ResolutionFailed(String),
|
||||
#[error("URL bloquée (réseau privé/local)")]
|
||||
SsrfBlocked,
|
||||
}
|
||||
|
||||
/// Un track résolu depuis une source externe (RSS enclosure, audio direct…)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedTrack {
|
||||
/// URL directe de l'audio (jouable par le renderer)
|
||||
pub uri: String,
|
||||
pub title: String,
|
||||
pub artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
pub duration: Option<String>, // format "H:MM:SS.mmm" UPnP
|
||||
pub album_art: Option<String>,
|
||||
pub mime_type: String, // ex. "audio/mpeg", "audio/aac"
|
||||
}
|
||||
|
||||
impl ResolvedTrack {
|
||||
pub fn new(uri: impl Into<String>, title: impl Into<String>) -> Self {
|
||||
Self {
|
||||
uri: uri.into(),
|
||||
title: title.into(),
|
||||
artist: None,
|
||||
album: None,
|
||||
duration: None,
|
||||
album_art: None,
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Contenu résolu depuis une URL externe
|
||||
#[derive(Debug)]
|
||||
pub enum ResolvedContent {
|
||||
/// Référence à un container d'une source existante.
|
||||
/// La UrlSource retourne un stub container avec cet ID ; le content directory
|
||||
/// le route naturellement vers la source propriétaire lors du browse.
|
||||
SourceContainer {
|
||||
source_id: String,
|
||||
container_id: String,
|
||||
},
|
||||
|
||||
/// Liste ordonnée de tracks (RSS/podcast, M3U, PLS, XSPF…)
|
||||
Playlist {
|
||||
title: Option<String>,
|
||||
items: Vec<ResolvedTrack>,
|
||||
},
|
||||
|
||||
/// Flux continu (radio, stream live)
|
||||
Stream {
|
||||
uri: String,
|
||||
title: String,
|
||||
mime_type: String,
|
||||
},
|
||||
|
||||
/// Track unique identifié directement
|
||||
Track(ResolvedTrack),
|
||||
}
|
||||
|
||||
/// Trait implémenté par chaque handler spécialisé (Qobuz, RadioFrance…)
|
||||
/// et par le handler générique de dernier recours.
|
||||
#[async_trait]
|
||||
pub trait UrlHandler: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
/// Priorité : plus grand = essayé en premier. Défaut : 50.
|
||||
fn priority(&self) -> u8 {
|
||||
50
|
||||
}
|
||||
/// Filtre rapide sans I/O — simple test regex/contains sur l'URL.
|
||||
fn can_handle(&self, url: &str) -> bool;
|
||||
/// Résolution effective (I/O autorisé).
|
||||
async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError>;
|
||||
}
|
||||
|
||||
/// Registre ordonné de handlers. Les handlers sont triés par priorité décroissante.
|
||||
pub struct UrlResolver {
|
||||
handlers: Vec<Box<dyn UrlHandler>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for UrlResolver {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("UrlResolver")
|
||||
.field("handlers", &format!("{} handlers", self.handlers.len()))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl UrlResolver {
|
||||
pub fn new() -> Self {
|
||||
Self { handlers: vec![] }
|
||||
}
|
||||
|
||||
pub fn register(&mut self, handler: Box<dyn UrlHandler>) {
|
||||
self.handlers.push(handler);
|
||||
self.handlers
|
||||
.sort_by(|a, b| b.priority().cmp(&a.priority()));
|
||||
}
|
||||
|
||||
pub async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
for handler in &self.handlers {
|
||||
if handler.can_handle(url) {
|
||||
return handler.resolve(url).await;
|
||||
}
|
||||
}
|
||||
Err(UrlResolverError::NotSupported(url.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UrlResolver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
573
pmourlsource/src/handlers/generic.rs
Normal file
573
pmourlsource/src/handlers/generic.rs
Normal file
@@ -0,0 +1,573 @@
|
||||
use crate::handler::{ResolvedContent, ResolvedTrack, UrlHandler, UrlResolverError};
|
||||
use async_trait::async_trait;
|
||||
use reqwest::{redirect, Client};
|
||||
|
||||
/// Handler générique de dernier recours — priorité 10.
|
||||
///
|
||||
/// Pipeline :
|
||||
/// 1. Garde-fou SSRF (rejette les IPs privées/locales)
|
||||
/// 2. GET avec suivi de redirections (max 5)
|
||||
/// 3. Détection par Content-Type :
|
||||
/// - audio/* → Stream direct
|
||||
/// - application/rss+xml, … → parse RSS/Atom → Playlist
|
||||
/// - .m3u / .pls / .xspf → parse playlist → Playlist
|
||||
/// 4. text/html → cherche :
|
||||
/// - <link type="application/rss+xml"> → fetch RSS → Playlist
|
||||
/// - <audio src="…">
|
||||
/// - og:audio / og:url audio
|
||||
pub struct GenericUrlHandler {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl GenericUrlHandler {
|
||||
pub fn new() -> Result<Self, reqwest::Error> {
|
||||
let client = Client::builder()
|
||||
.redirect(redirect::Policy::limited(5))
|
||||
.user_agent("PMOMusic/1.0")
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
/// Rejette les URLs ciblant des réseaux privés/locaux (SSRF).
|
||||
fn is_safe_url(url: &str) -> bool {
|
||||
let Ok(parsed) = url::Url::parse(url) else {
|
||||
return false;
|
||||
};
|
||||
let Some(host) = parsed.host_str() else {
|
||||
return false;
|
||||
};
|
||||
// Rejeter loopback, link-local, et RFC-1918
|
||||
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
|
||||
return false;
|
||||
}
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
return !ip.is_loopback() && !ip.is_unspecified() && is_public_ip(ip);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
async fn fetch_and_resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(e.to_string()))?;
|
||||
|
||||
let final_url = resp.url().to_string();
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(e.to_string()))?;
|
||||
|
||||
// Audio direct
|
||||
if content_type.starts_with("audio/") {
|
||||
let mime = content_type.split(';').next().unwrap_or("audio/mpeg").trim().to_string();
|
||||
let title = title_from_url(&final_url);
|
||||
return Ok(ResolvedContent::Stream {
|
||||
uri: final_url,
|
||||
title,
|
||||
mime_type: mime,
|
||||
});
|
||||
}
|
||||
|
||||
// Playlist M3U
|
||||
if content_type.contains("mpegurl") || final_url.ends_with(".m3u") || final_url.ends_with(".m3u8") {
|
||||
return parse_m3u(&body, &final_url);
|
||||
}
|
||||
|
||||
// Playlist PLS
|
||||
if content_type.contains("scpls") || final_url.ends_with(".pls") {
|
||||
return parse_pls(&body, &final_url);
|
||||
}
|
||||
|
||||
// RSS / Atom / podcast
|
||||
if is_rss_content_type(&content_type) || final_url.ends_with(".xml") {
|
||||
return parse_rss(&body, &final_url);
|
||||
}
|
||||
|
||||
// HTML — chercher RSS link puis audio elements
|
||||
if content_type.starts_with("text/html") || content_type.is_empty() {
|
||||
return self.scrape_html(&body, &final_url).await;
|
||||
}
|
||||
|
||||
Err(UrlResolverError::NotSupported(format!(
|
||||
"Content-Type non géré : {}",
|
||||
content_type
|
||||
)))
|
||||
}
|
||||
|
||||
async fn scrape_html(&self, html: &str, base_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
// 1. Chercher un lien RSS (<link type="application/rss+xml" href="...">)
|
||||
if let Some(rss_url) = extract_rss_link(html, base_url) {
|
||||
tracing::debug!(rss_url = %rss_url, "HTML scraper found RSS feed");
|
||||
if Self::is_safe_url(&rss_url) {
|
||||
if let Ok(resp) = self.client.get(&rss_url).send().await {
|
||||
if let Ok(body) = resp.text().await {
|
||||
if let Ok(result) = parse_rss(&body, &rss_url) {
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Chercher <audio src="...">
|
||||
if let Some(audio_url) = extract_audio_src(html, base_url) {
|
||||
tracing::debug!(audio_url = %audio_url, "HTML scraper found <audio>");
|
||||
let title = extract_og_title(html)
|
||||
.or_else(|| extract_title_tag(html))
|
||||
.unwrap_or_else(|| title_from_url(base_url));
|
||||
return Ok(ResolvedContent::Track(ResolvedTrack {
|
||||
uri: audio_url,
|
||||
title,
|
||||
artist: None,
|
||||
album: None,
|
||||
duration: None,
|
||||
album_art: extract_og_image(html),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. og:audio
|
||||
if let Some(audio_url) = extract_og_audio(html) {
|
||||
tracing::debug!(audio_url = %audio_url, "HTML scraper found og:audio");
|
||||
let title = extract_og_title(html)
|
||||
.or_else(|| extract_title_tag(html))
|
||||
.unwrap_or_else(|| title_from_url(base_url));
|
||||
return Ok(ResolvedContent::Track(ResolvedTrack {
|
||||
uri: audio_url,
|
||||
title,
|
||||
artist: None,
|
||||
album: None,
|
||||
duration: None,
|
||||
album_art: extract_og_image(html),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
Err(UrlResolverError::NotSupported(format!(
|
||||
"Aucun contenu audio trouvé dans la page : {}",
|
||||
base_url
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GenericUrlHandler {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("Failed to build HTTP client")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UrlHandler for GenericUrlHandler {
|
||||
fn name(&self) -> &str {
|
||||
"GenericUrlHandler"
|
||||
}
|
||||
|
||||
fn priority(&self) -> u8 {
|
||||
10
|
||||
}
|
||||
|
||||
fn can_handle(&self, url: &str) -> bool {
|
||||
url.starts_with("http://") || url.starts_with("https://")
|
||||
}
|
||||
|
||||
async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
if !Self::is_safe_url(url) {
|
||||
return Err(UrlResolverError::SsrfBlocked);
|
||||
}
|
||||
self.fetch_and_resolve(url).await
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parseurs ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_rss(body: &str, source_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let mut items: Vec<ResolvedTrack> = Vec::new();
|
||||
let mut feed_title: Option<String> = None;
|
||||
let mut feed_image: Option<String> = None;
|
||||
let mut current_title: Option<String> = None;
|
||||
let mut current_uri: Option<String> = None;
|
||||
let mut current_duration: Option<String> = None;
|
||||
let mut current_date: Option<String> = None;
|
||||
let mut current_image: Option<String> = None;
|
||||
let mut in_item = false;
|
||||
|
||||
// Parsing XML ligne par ligne — quick_xml non disponible ici,
|
||||
// on utilise une approche par extraction de patterns XML simples.
|
||||
for line in body.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if !in_item {
|
||||
if trimmed.starts_with("<title") && feed_title.is_none() {
|
||||
feed_title = extract_xml_text(trimmed, "title");
|
||||
}
|
||||
if trimmed.contains("<itunes:image") || trimmed.contains("<image>") {
|
||||
if let Some(href) = extract_attr(trimmed, "href") {
|
||||
feed_image = Some(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if trimmed == "<item>" || trimmed.starts_with("<item ") {
|
||||
in_item = true;
|
||||
current_title = None;
|
||||
current_uri = None;
|
||||
current_duration = None;
|
||||
current_date = None;
|
||||
current_image = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed == "</item>" {
|
||||
if let (Some(uri), Some(title)) = (current_uri.take(), current_title.take()) {
|
||||
items.push(ResolvedTrack {
|
||||
uri,
|
||||
title,
|
||||
artist: None,
|
||||
album: feed_title.clone(),
|
||||
duration: current_duration.take().map(itunes_duration_to_upnp),
|
||||
album_art: current_image.take().or_else(|| feed_image.clone()),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
});
|
||||
}
|
||||
in_item = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !in_item {
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed.starts_with("<title") && current_title.is_none() {
|
||||
current_title = extract_xml_text(trimmed, "title");
|
||||
} else if trimmed.starts_with("<enclosure") {
|
||||
if let Some(url) = extract_attr(trimmed, "url") {
|
||||
// Vérifier que c'est bien de l'audio
|
||||
let type_ = extract_attr(trimmed, "type").unwrap_or_default();
|
||||
if type_.starts_with("audio/") || type_.is_empty() {
|
||||
current_uri = Some(url);
|
||||
}
|
||||
}
|
||||
} else if trimmed.starts_with("<itunes:duration") {
|
||||
current_duration = extract_xml_text(trimmed, "itunes:duration");
|
||||
} else if trimmed.starts_with("<pubDate") {
|
||||
current_date = extract_xml_text(trimmed, "pubDate");
|
||||
} else if trimmed.starts_with("<itunes:image") {
|
||||
if let Some(href) = extract_attr(trimmed, "href") {
|
||||
current_image = Some(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
return Err(UrlResolverError::NotSupported(format!(
|
||||
"Aucun épisode audio dans le feed RSS : {}",
|
||||
source_url
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Playlist {
|
||||
title: feed_title,
|
||||
items,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_m3u(body: &str, _source_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let mut items: Vec<ResolvedTrack> = Vec::new();
|
||||
let mut pending_title: Option<String> = None;
|
||||
|
||||
for line in body.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line == "#EXTM3U" {
|
||||
continue;
|
||||
}
|
||||
if let Some(info) = line.strip_prefix("#EXTINF:") {
|
||||
// #EXTINF:<duration>,<title>
|
||||
let title = info.splitn(2, ',').nth(1).unwrap_or("").trim().to_string();
|
||||
if !title.is_empty() {
|
||||
pending_title = Some(title);
|
||||
}
|
||||
} else if !line.starts_with('#') {
|
||||
let title = pending_title.take().unwrap_or_else(|| title_from_url(line));
|
||||
items.push(ResolvedTrack::new(line, title));
|
||||
}
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
return Err(UrlResolverError::NotSupported("M3U vide".to_string()));
|
||||
}
|
||||
|
||||
if items.len() == 1 {
|
||||
return Ok(ResolvedContent::Stream {
|
||||
uri: items.remove(0).uri,
|
||||
title: items.first().map(|t| t.title.clone()).unwrap_or_default(),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Playlist { title: None, items })
|
||||
}
|
||||
|
||||
fn parse_pls(body: &str, _source_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let mut uris: Vec<String> = Vec::new();
|
||||
let mut titles: Vec<String> = Vec::new();
|
||||
|
||||
for line in body.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(rest) = line.to_lowercase().strip_prefix("file") {
|
||||
if let Some(url) = rest.splitn(2, '=').nth(1) {
|
||||
uris.push(url.trim().to_string());
|
||||
}
|
||||
} else if let Some(rest) = line.to_lowercase().strip_prefix("title") {
|
||||
if let Some(t) = rest.splitn(2, '=').nth(1) {
|
||||
titles.push(t.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if uris.is_empty() {
|
||||
return Err(UrlResolverError::NotSupported("PLS vide".to_string()));
|
||||
}
|
||||
|
||||
let items: Vec<ResolvedTrack> = uris
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, uri)| {
|
||||
let title = titles.get(i).cloned().unwrap_or_else(|| title_from_url(&uri));
|
||||
ResolvedTrack::new(uri, title)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if items.len() == 1 {
|
||||
let item = items.into_iter().next().unwrap();
|
||||
return Ok(ResolvedContent::Stream {
|
||||
uri: item.uri,
|
||||
title: item.title,
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Playlist { title: None, items })
|
||||
}
|
||||
|
||||
// ── Utilitaires d'extraction HTML/XML ───────────────────────────────────────
|
||||
|
||||
fn extract_rss_link(html: &str, base_url: &str) -> Option<String> {
|
||||
// <link ... type="application/rss+xml" ... href="URL" ...>
|
||||
// ou <link ... href="URL" ... type="application/rss+xml" ...>
|
||||
let lower = html.to_lowercase();
|
||||
let mut pos = 0;
|
||||
while let Some(start) = lower[pos..].find("<link") {
|
||||
let start = pos + start;
|
||||
let end = html[start..].find('>').map(|e| start + e + 1).unwrap_or(html.len());
|
||||
let tag = &html[start..end];
|
||||
let tag_lower = &lower[start..end];
|
||||
|
||||
if tag_lower.contains("application/rss+xml") || tag_lower.contains("application/atom+xml") {
|
||||
if let Some(href) = extract_attr(tag, "href") {
|
||||
return Some(resolve_url(base_url, &href));
|
||||
}
|
||||
}
|
||||
pos = end;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_audio_src(html: &str, base_url: &str) -> Option<String> {
|
||||
let lower = html.to_lowercase();
|
||||
if let Some(start) = lower.find("<audio") {
|
||||
let end = html[start..].find('>').map(|e| start + e + 1).unwrap_or(html.len());
|
||||
let tag = &html[start..end];
|
||||
if let Some(src) = extract_attr(tag, "src") {
|
||||
return Some(resolve_url(base_url, &src));
|
||||
}
|
||||
// <source src="..."> inside <audio>
|
||||
let after = &html[end..];
|
||||
let lower_after = after.to_lowercase();
|
||||
if let Some(src_start) = lower_after.find("<source") {
|
||||
let src_end = after[src_start..].find('>').map(|e| src_start + e + 1).unwrap_or(after.len());
|
||||
let src_tag = &after[src_start..src_end];
|
||||
if let Some(src) = extract_attr(src_tag, "src") {
|
||||
return Some(resolve_url(base_url, &src));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_og_audio(html: &str) -> Option<String> {
|
||||
extract_meta_property(html, "og:audio")
|
||||
}
|
||||
|
||||
fn extract_og_title(html: &str) -> Option<String> {
|
||||
extract_meta_property(html, "og:title")
|
||||
}
|
||||
|
||||
fn extract_og_image(html: &str) -> Option<String> {
|
||||
extract_meta_property(html, "og:image")
|
||||
}
|
||||
|
||||
fn extract_title_tag(html: &str) -> Option<String> {
|
||||
let lower = html.to_lowercase();
|
||||
let start = lower.find("<title")? + 6;
|
||||
let start = html[start..].find('>')? + start + 1;
|
||||
let end = start + html[start..].to_lowercase().find("</title>")?;
|
||||
Some(html[start..end].trim().to_string())
|
||||
}
|
||||
|
||||
fn extract_meta_property(html: &str, property: &str) -> Option<String> {
|
||||
let lower = html.to_lowercase();
|
||||
let prop_lower = property.to_lowercase();
|
||||
let mut pos = 0;
|
||||
while let Some(tag_start) = lower[pos..].find("<meta") {
|
||||
let tag_start = pos + tag_start;
|
||||
let tag_end = html[tag_start..].find('>').map(|e| tag_start + e + 1).unwrap_or(html.len());
|
||||
let tag = &html[tag_start..tag_end];
|
||||
let tag_lower = &lower[tag_start..tag_end];
|
||||
|
||||
if tag_lower.contains(&prop_lower) {
|
||||
if let Some(content) = extract_attr(tag, "content") {
|
||||
return Some(content);
|
||||
}
|
||||
}
|
||||
pos = tag_end;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extrait la valeur d'un attribut HTML/XML depuis une balise.
|
||||
/// Gère les guillemets simples, doubles et sans guillemets.
|
||||
fn extract_attr(tag: &str, attr: &str) -> Option<String> {
|
||||
let tag_lower = tag.to_lowercase();
|
||||
let attr_lower = attr.to_lowercase();
|
||||
let needle = format!("{}=", attr_lower);
|
||||
let pos = tag_lower.find(&needle)? + needle.len();
|
||||
let rest = &tag[pos..];
|
||||
|
||||
if rest.starts_with('"') {
|
||||
let end = rest[1..].find('"')? + 1;
|
||||
Some(rest[1..end].to_string())
|
||||
} else if rest.starts_with('\'') {
|
||||
let end = rest[1..].find('\'')? + 1;
|
||||
Some(rest[1..end].to_string())
|
||||
} else {
|
||||
let end = rest.find(|c: char| c.is_whitespace() || c == '>' || c == '/').unwrap_or(rest.len());
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrait le contenu texte d'un élément XML simple sur une seule ligne.
|
||||
fn extract_xml_text(line: &str, tag: &str) -> Option<String> {
|
||||
// Chercher <tag> ou <tag ...>
|
||||
let open_plain = format!("<{}>", tag);
|
||||
let open_with_attrs = format!("<{} ", tag);
|
||||
let close = format!("</{}>", tag);
|
||||
|
||||
let content_start = if let Some(p) = line.find(&open_plain) {
|
||||
p + open_plain.len()
|
||||
} else if let Some(p) = line.find(&open_with_attrs) {
|
||||
// Avancer jusqu'à la fermeture de la balise ouvrante
|
||||
let after = &line[p..];
|
||||
let gt = after.find('>')?;
|
||||
p + gt + 1
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let content_end = line[content_start..].find(&close)? + content_start;
|
||||
let text = line[content_start..content_end]
|
||||
.trim()
|
||||
.replace("<![CDATA[", "")
|
||||
.replace("]]>", "");
|
||||
if text.is_empty() { None } else { Some(text) }
|
||||
}
|
||||
|
||||
/// Résout une URL relative par rapport à une base.
|
||||
fn resolve_url(base: &str, target: &str) -> String {
|
||||
if target.starts_with("http://") || target.starts_with("https://") {
|
||||
return target.to_string();
|
||||
}
|
||||
if target.starts_with("//") {
|
||||
let scheme = if base.starts_with("https") { "https" } else { "http" };
|
||||
return format!("{}:{}", scheme, target);
|
||||
}
|
||||
if let Ok(base_url) = url::Url::parse(base) {
|
||||
if let Ok(resolved) = base_url.join(target) {
|
||||
return resolved.to_string();
|
||||
}
|
||||
}
|
||||
target.to_string()
|
||||
}
|
||||
|
||||
/// Extrait un titre lisible depuis une URL.
|
||||
fn title_from_url(url: &str) -> String {
|
||||
url.rsplit('/')
|
||||
.find(|s| !s.is_empty())
|
||||
.unwrap_or(url)
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or(url)
|
||||
.replace(['-', '_'], " ")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Détermine si le Content-Type est RSS/Atom.
|
||||
fn is_rss_content_type(ct: &str) -> bool {
|
||||
ct.contains("rss") || ct.contains("atom") || ct.contains("xml")
|
||||
}
|
||||
|
||||
/// Convertit une durée iTunes ("HH:MM:SS" ou "MM:SS" ou secondes) en format UPnP ("H:MM:SS.000").
|
||||
fn itunes_duration_to_upnp(d: String) -> String {
|
||||
let parts: Vec<&str> = d.trim().split(':').collect();
|
||||
match parts.len() {
|
||||
1 => {
|
||||
// Secondes brutes
|
||||
if let Ok(secs) = parts[0].parse::<u64>() {
|
||||
let h = secs / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
return format!("{}:{:02}:{:02}.000", h, m, s);
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
return format!("0:{}.000", d);
|
||||
}
|
||||
3 => {
|
||||
return format!("{}.000", d);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Vérifie qu'une IP est publique (non privée, non loopback, non link-local).
|
||||
fn is_public_ip(ip: std::net::IpAddr) -> bool {
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
!v4.is_private()
|
||||
&& !v4.is_loopback()
|
||||
&& !v4.is_link_local()
|
||||
&& !v4.is_broadcast()
|
||||
&& !v4.is_documentation()
|
||||
&& !v4.is_unspecified()
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
!v6.is_loopback() && !v6.is_unspecified() && !is_v6_link_local(v6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_v6_link_local(ip: std::net::Ipv6Addr) -> bool {
|
||||
// fe80::/10
|
||||
ip.segments()[0] & 0xffc0 == 0xfe80
|
||||
}
|
||||
3
pmourlsource/src/handlers/mod.rs
Normal file
3
pmourlsource/src/handlers/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod generic;
|
||||
pub mod qobuz;
|
||||
pub mod radiofrance;
|
||||
138
pmourlsource/src/handlers/qobuz.rs
Normal file
138
pmourlsource/src/handlers/qobuz.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use crate::handler::{ResolvedContent, UrlHandler, UrlResolverError};
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Résout les URLs de partage Qobuz vers des container_ids natifs.
|
||||
///
|
||||
/// Supporte open.qobuz.com et play.qobuz.com.
|
||||
/// Les IDs peuvent être alphanumériques pour tous les types (album, track, playlist, artist).
|
||||
///
|
||||
/// Exemples :
|
||||
/// https://open.qobuz.com/album/l46fxnqnxp5vs → qobuz:album:l46fxnqnxp5vs
|
||||
/// https://open.qobuz.com/track/48471123 → qobuz:track:48471123
|
||||
/// https://open.qobuz.com/playlist/63246908 → qobuz:playlist:63246908
|
||||
/// https://open.qobuz.com/artist/125709 → qobuz:artist:125709
|
||||
pub struct QobuzUrlHandler;
|
||||
|
||||
impl QobuzUrlHandler {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
fn parse(&self, url: &str) -> Option<(String, String)> {
|
||||
// Localiser "qobuz.com/" dans l'URL
|
||||
let after_domain = url.find("qobuz.com/").map(|i| &url[i + "qobuz.com".len()..])?;
|
||||
|
||||
// after_domain commence par "/"
|
||||
let path = after_domain.trim_start_matches('/');
|
||||
let mut parts = path.splitn(3, '/');
|
||||
|
||||
let type_ = parts.next().unwrap_or("");
|
||||
let id_raw = parts.next().unwrap_or("");
|
||||
// Supprimer les query params éventuels (#, ?)
|
||||
let id = id_raw.split('?').next().unwrap_or(id_raw);
|
||||
let id = id.split('#').next().unwrap_or(id);
|
||||
|
||||
match type_ {
|
||||
"album" | "track" | "playlist" | "artist" => {
|
||||
if !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
Some((type_.to_string(), id.to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for QobuzUrlHandler {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UrlHandler for QobuzUrlHandler {
|
||||
fn name(&self) -> &str {
|
||||
"QobuzUrlHandler"
|
||||
}
|
||||
|
||||
fn priority(&self) -> u8 {
|
||||
90
|
||||
}
|
||||
|
||||
fn can_handle(&self, url: &str) -> bool {
|
||||
url.contains("qobuz.com/")
|
||||
}
|
||||
|
||||
async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let (type_, id) = self
|
||||
.parse(url)
|
||||
.ok_or_else(|| UrlResolverError::NotSupported(url.to_string()))?;
|
||||
|
||||
let container_id = format!("qobuz:{}:{}", type_, id);
|
||||
|
||||
tracing::debug!(
|
||||
url = %url,
|
||||
container_id = %container_id,
|
||||
"QobuzUrlHandler resolved"
|
||||
);
|
||||
|
||||
Ok(ResolvedContent::SourceContainer {
|
||||
source_id: "qobuz".to_string(),
|
||||
container_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_album_alphanumeric_id() {
|
||||
let h = QobuzUrlHandler::new();
|
||||
let r = h
|
||||
.resolve("https://open.qobuz.com/album/l46fxnqnxp5vs")
|
||||
.await
|
||||
.unwrap();
|
||||
let ResolvedContent::SourceContainer { container_id, .. } = r else { panic!("unexpected variant") };
|
||||
assert_eq!(container_id, "qobuz:album:l46fxnqnxp5vs");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_track_numeric_id() {
|
||||
let h = QobuzUrlHandler::new();
|
||||
let r = h
|
||||
.resolve("https://open.qobuz.com/track/48471123")
|
||||
.await
|
||||
.unwrap();
|
||||
let ResolvedContent::SourceContainer { container_id, .. } = r else { panic!("unexpected variant") };
|
||||
assert_eq!(container_id, "qobuz:track:48471123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_play_subdomain() {
|
||||
let h = QobuzUrlHandler::new();
|
||||
let r = h
|
||||
.resolve("https://play.qobuz.com/album/l46fxnqnxp5vs")
|
||||
.await
|
||||
.unwrap();
|
||||
let ResolvedContent::SourceContainer { container_id, .. } = r else { panic!("unexpected variant") };
|
||||
assert_eq!(container_id, "qobuz:album:l46fxnqnxp5vs");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unknown_type_rejected() {
|
||||
let h = QobuzUrlHandler::new();
|
||||
let r = h.resolve("https://open.qobuz.com/label/123").await;
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_handle() {
|
||||
let h = QobuzUrlHandler::new();
|
||||
assert!(h.can_handle("https://open.qobuz.com/album/abc"));
|
||||
assert!(!h.can_handle("https://www.spotify.com/album/abc"));
|
||||
}
|
||||
}
|
||||
445
pmourlsource/src/handlers/radiofrance.rs
Normal file
445
pmourlsource/src/handlers/radiofrance.rs
Normal file
@@ -0,0 +1,445 @@
|
||||
use crate::handler::{ResolvedContent, ResolvedTrack, UrlHandler, UrlResolverError};
|
||||
use async_trait::async_trait;
|
||||
use futures::future::join_all;
|
||||
use reqwest::{redirect, Client};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Handler dédié aux URLs radiofrance.fr — priorité 80.
|
||||
///
|
||||
/// RadioFrance utilise SvelteKit (SSR).
|
||||
///
|
||||
/// Stratégie selon le type d'URL :
|
||||
///
|
||||
/// 1. **Page podcast** (`/podcasts/{slug}`)
|
||||
/// → `rssFeed:"https://..."` inline → fetch + parse RSS
|
||||
///
|
||||
/// 2. **Page série** (`/podcasts/serie-{slug}`)
|
||||
/// → JSON-LD `ItemList` → extraire les URLs d'épisodes → fetch concurrent
|
||||
/// (RadioFrance limite leur RSS à 2 éléments ; scraping direct donne tous les épisodes)
|
||||
///
|
||||
/// 3. **Page épisode** (`/podcasts/{podcast}/{episode}-{id}`)
|
||||
/// → URL MP3 `media.radiofrance-podcast.net` inline
|
||||
pub struct RadioFranceUrlHandler {
|
||||
client: Arc<Client>,
|
||||
}
|
||||
|
||||
impl RadioFranceUrlHandler {
|
||||
pub fn new() -> Result<Self, reqwest::Error> {
|
||||
let client = Client::builder()
|
||||
.redirect(redirect::Policy::limited(5))
|
||||
.user_agent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36")
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.build()?;
|
||||
Ok(Self { client: Arc::new(client) })
|
||||
}
|
||||
|
||||
async fn resolve_inner(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let html = self.fetch_html(url).await?;
|
||||
|
||||
// --- Cas 1 : page podcast → rssFeed non vide ---
|
||||
if let Some(rss_url) = extract_rss_feed_key(&html) {
|
||||
tracing::debug!(rss_url = %rss_url, "RadioFrance: rssFeed trouvé");
|
||||
return self.fetch_rss(&rss_url).await;
|
||||
}
|
||||
|
||||
// --- Cas 2 : page série → fetch concurrent des pages épisodes ---
|
||||
let episode_urls = extract_episode_urls_from_series(&html, url);
|
||||
if !episode_urls.is_empty() {
|
||||
tracing::debug!(
|
||||
count = episode_urls.len(),
|
||||
"RadioFrance: série — fetch concurrent des épisodes"
|
||||
);
|
||||
let feed_title = extract_og_title(&html);
|
||||
let feed_image = extract_og_image(&html);
|
||||
let album = feed_title.clone().or_else(|| extract_title_tag(&html));
|
||||
|
||||
let client = self.client.clone();
|
||||
let fetches: Vec<_> = episode_urls
|
||||
.into_iter()
|
||||
.map(|ep_url| {
|
||||
let client = client.clone();
|
||||
let album = album.clone();
|
||||
let feed_image = feed_image.clone();
|
||||
async move {
|
||||
match fetch_html_with_client(&client, &ep_url).await {
|
||||
Ok(ep_html) => episode_to_track(&ep_html, &ep_url, album.as_deref(), feed_image.as_deref()),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tracks: Vec<ResolvedTrack> = join_all(fetches).await.into_iter().flatten().collect();
|
||||
|
||||
if !tracks.is_empty() {
|
||||
return Ok(ResolvedContent::Playlist {
|
||||
title: feed_title,
|
||||
items: tracks,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cas 3 : page épisode → MP3 direct ---
|
||||
if let Some(mp3_url) = extract_mp3_url(&html) {
|
||||
tracing::debug!(mp3_url = %mp3_url, "RadioFrance: MP3 direct trouvé");
|
||||
let title = extract_og_title(&html)
|
||||
.or_else(|| extract_title_tag(&html))
|
||||
.unwrap_or_else(|| url.to_string());
|
||||
return Ok(ResolvedContent::Track(ResolvedTrack {
|
||||
uri: mp3_url,
|
||||
title,
|
||||
artist: None,
|
||||
album: None,
|
||||
duration: None,
|
||||
album_art: extract_og_image(&html),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
Err(UrlResolverError::NotSupported(format!(
|
||||
"Aucun podcast/épisode trouvé sur la page RadioFrance : {}",
|
||||
url
|
||||
)))
|
||||
}
|
||||
|
||||
async fn fetch_html(&self, url: &str) -> Result<String, UrlResolverError> {
|
||||
fetch_html_with_client(&self.client, url).await
|
||||
}
|
||||
|
||||
async fn fetch_rss(&self, rss_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let body = self
|
||||
.client
|
||||
.get(rss_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(format!("RSS fetch : {}", e)))?
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(e.to_string()))?;
|
||||
|
||||
parse_rss(&body, rss_url)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RadioFranceUrlHandler {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("Failed to build HTTP client for RadioFranceUrlHandler")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UrlHandler for RadioFranceUrlHandler {
|
||||
fn name(&self) -> &str {
|
||||
"RadioFranceUrlHandler"
|
||||
}
|
||||
|
||||
fn priority(&self) -> u8 {
|
||||
80
|
||||
}
|
||||
|
||||
fn can_handle(&self, url: &str) -> bool {
|
||||
url.contains("radiofrance.fr")
|
||||
}
|
||||
|
||||
async fn resolve(&self, url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
self.resolve_inner(url).await
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async fn fetch_html_with_client(client: &Client, url: &str) -> Result<String, UrlResolverError> {
|
||||
client
|
||||
.get(url)
|
||||
.header("Accept", "text/html,application/xhtml+xml")
|
||||
.header("Accept-Language", "fr-FR,fr;q=0.9")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(e.to_string()))?
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| UrlResolverError::ResolutionFailed(e.to_string()))
|
||||
}
|
||||
|
||||
// ── Extraction SvelteKit ─────────────────────────────────────────────────────
|
||||
|
||||
/// Cherche `rssFeed:"https://..."` dans le JS SvelteKit inline.
|
||||
/// Retourne None si le champ est absent ou vide.
|
||||
fn extract_rss_feed_key(html: &str) -> Option<String> {
|
||||
let needle = "rssFeed:\"https://";
|
||||
let pos = html.find(needle)?;
|
||||
let start = pos + "rssFeed:\"".len();
|
||||
let end = html[start..].find('"')? + start;
|
||||
let url = html[start..end].replace("\\/", "/");
|
||||
if url.is_empty() || !url.starts_with("http") {
|
||||
None
|
||||
} else {
|
||||
Some(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrait toutes les URLs d'épisodes depuis le JSON-LD `ItemList` d'une page série.
|
||||
///
|
||||
/// Filtre les URLs non-épisodes (série elle-même, images, domaine seul…).
|
||||
/// Une URL d'épisode a exactement 4 segments de path :
|
||||
/// `/{station}/podcasts/{podcast-slug}/{episode-slug}`
|
||||
fn extract_episode_urls_from_series(html: &str, series_url: &str) -> Vec<String> {
|
||||
let item_marker = "\"@type\":\"ItemList\"";
|
||||
let list_pos = match html.find(item_marker) {
|
||||
Some(p) => p,
|
||||
None => return vec![],
|
||||
};
|
||||
|
||||
let url_prefix = "\"url\":\"https://www.radiofrance.fr/";
|
||||
let after_list = &html[list_pos..];
|
||||
let mut urls = Vec::new();
|
||||
let mut search_from = 0;
|
||||
|
||||
while let Some(rel_pos) = after_list[search_from..].find(url_prefix) {
|
||||
let rel_pos = search_from + rel_pos;
|
||||
let from = list_pos + rel_pos + "\"url\":\"".len();
|
||||
let Some(end_rel) = html[from..].find('"') else { break };
|
||||
let candidate = &html[from..from + end_rel];
|
||||
|
||||
if is_episode_url(candidate, series_url) {
|
||||
urls.push(candidate.to_string());
|
||||
}
|
||||
search_from = rel_pos + url_prefix.len();
|
||||
}
|
||||
urls
|
||||
}
|
||||
|
||||
/// Retourne true si l'URL est bien une page d'épisode (≥4 segments de path).
|
||||
fn is_episode_url(url: &str, series_url: &str) -> bool {
|
||||
if url.trim_end_matches('/') == series_url.trim_end_matches('/') {
|
||||
return false;
|
||||
}
|
||||
if !url.contains("/podcasts/") {
|
||||
return false;
|
||||
}
|
||||
// Exclure fichiers statiques (images…)
|
||||
let last = url.rsplit('/').next().unwrap_or("");
|
||||
if last.contains('.') {
|
||||
return false;
|
||||
}
|
||||
// Doit avoir ≥ 4 segments après le domaine : /station/podcasts/podcast/episode
|
||||
let path_segments: usize = url
|
||||
.splitn(4, "radiofrance.fr")
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.count();
|
||||
path_segments >= 4
|
||||
}
|
||||
|
||||
/// Extrait un `ResolvedTrack` depuis la page HTML d'un épisode RadioFrance.
|
||||
fn episode_to_track(html: &str, url: &str, album: Option<&str>, feed_image: Option<&str>) -> Option<ResolvedTrack> {
|
||||
let mp3_url = extract_mp3_url(html)?;
|
||||
let title = extract_og_title(html)
|
||||
.or_else(|| extract_title_tag(html))
|
||||
.unwrap_or_else(|| url.to_string());
|
||||
let album_art = extract_og_image(html).or_else(|| feed_image.map(|s| s.to_string()));
|
||||
Some(ResolvedTrack {
|
||||
uri: mp3_url,
|
||||
title,
|
||||
artist: None,
|
||||
album: album.map(|s| s.to_string()),
|
||||
duration: None,
|
||||
album_art,
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Extrait l'URL du premier fichier MP3 hébergé sur media.radiofrance-podcast.net.
|
||||
fn extract_mp3_url(html: &str) -> Option<String> {
|
||||
let needle = "https://media.radiofrance-podcast.net/";
|
||||
let pos = html.find(needle)?;
|
||||
let end = html[pos..].find(|c: char| c == '"' || c == '\'' || c.is_whitespace())? + pos;
|
||||
let url = html[pos..end].to_string();
|
||||
if url.ends_with(".mp3") || url.contains(".mp3?") || url.contains("ITEMA_") {
|
||||
Some(url)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ── RSS parser ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_rss(body: &str, source_url: &str) -> Result<ResolvedContent, UrlResolverError> {
|
||||
let mut items: Vec<ResolvedTrack> = Vec::new();
|
||||
let mut feed_title: Option<String> = None;
|
||||
let mut feed_image: Option<String> = None;
|
||||
let mut current_title: Option<String> = None;
|
||||
let mut current_uri: Option<String> = None;
|
||||
let mut current_duration: Option<String> = None;
|
||||
let mut current_image: Option<String> = None;
|
||||
let mut in_item = false;
|
||||
|
||||
for line in body.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if !in_item {
|
||||
if trimmed.starts_with("<title") && feed_title.is_none() {
|
||||
feed_title = extract_xml_text(trimmed, "title");
|
||||
}
|
||||
if trimmed.contains("<itunes:image") || trimmed.contains("<image>") {
|
||||
if let Some(href) = extract_attr(trimmed, "href") {
|
||||
feed_image = Some(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if trimmed == "<item>" || trimmed.starts_with("<item ") {
|
||||
in_item = true;
|
||||
current_title = None;
|
||||
current_uri = None;
|
||||
current_duration = None;
|
||||
current_image = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed == "</item>" {
|
||||
if let (Some(uri), Some(title)) = (current_uri.take(), current_title.take()) {
|
||||
items.push(ResolvedTrack {
|
||||
uri,
|
||||
title,
|
||||
artist: None,
|
||||
album: feed_title.clone(),
|
||||
duration: current_duration.take().map(itunes_duration_to_upnp),
|
||||
album_art: current_image.take().or_else(|| feed_image.clone()),
|
||||
mime_type: "audio/mpeg".to_string(),
|
||||
});
|
||||
}
|
||||
in_item = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !in_item {
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed.starts_with("<title") && current_title.is_none() {
|
||||
current_title = extract_xml_text(trimmed, "title");
|
||||
} else if trimmed.starts_with("<enclosure") {
|
||||
if let Some(url) = extract_attr(trimmed, "url") {
|
||||
let type_ = extract_attr(trimmed, "type").unwrap_or_default();
|
||||
if type_.starts_with("audio/") || type_.is_empty() {
|
||||
current_uri = Some(url);
|
||||
}
|
||||
}
|
||||
} else if trimmed.starts_with("<itunes:duration") {
|
||||
current_duration = extract_xml_text(trimmed, "itunes:duration");
|
||||
} else if trimmed.starts_with("<itunes:image") {
|
||||
if let Some(href) = extract_attr(trimmed, "href") {
|
||||
current_image = Some(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
return Err(UrlResolverError::NotSupported(format!(
|
||||
"Aucun épisode dans le feed RSS RadioFrance : {}",
|
||||
source_url
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Playlist {
|
||||
title: feed_title,
|
||||
items,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Utilitaires HTML ─────────────────────────────────────────────────────────
|
||||
|
||||
fn extract_attr(tag: &str, attr: &str) -> Option<String> {
|
||||
let tag_lower = tag.to_lowercase();
|
||||
let attr_lower = attr.to_lowercase();
|
||||
let needle = format!("{}=", attr_lower);
|
||||
let pos = tag_lower.find(&needle)? + needle.len();
|
||||
let rest = &tag[pos..];
|
||||
if rest.starts_with('"') {
|
||||
let end = rest[1..].find('"')? + 1;
|
||||
Some(rest[1..end].to_string())
|
||||
} else if rest.starts_with('\'') {
|
||||
let end = rest[1..].find('\'')? + 1;
|
||||
Some(rest[1..end].to_string())
|
||||
} else {
|
||||
let end = rest.find(|c: char| c.is_whitespace() || c == '>' || c == '/').unwrap_or(rest.len());
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_xml_text(line: &str, tag: &str) -> Option<String> {
|
||||
let open_plain = format!("<{}>", tag);
|
||||
let open_with_attrs = format!("<{} ", tag);
|
||||
let close = format!("</{}>", tag);
|
||||
let content_start = if let Some(p) = line.find(&open_plain) {
|
||||
p + open_plain.len()
|
||||
} else if let Some(p) = line.find(&open_with_attrs) {
|
||||
let after = &line[p..];
|
||||
let gt = after.find('>')?;
|
||||
p + gt + 1
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let content_end = line[content_start..].find(&close)? + content_start;
|
||||
let text = line[content_start..content_end]
|
||||
.trim()
|
||||
.replace("<![CDATA[", "")
|
||||
.replace("]]>", "");
|
||||
if text.is_empty() { None } else { Some(text) }
|
||||
}
|
||||
|
||||
fn extract_og_title(html: &str) -> Option<String> {
|
||||
extract_meta_property(html, "og:title")
|
||||
}
|
||||
|
||||
fn extract_og_image(html: &str) -> Option<String> {
|
||||
extract_meta_property(html, "og:image")
|
||||
}
|
||||
|
||||
fn extract_title_tag(html: &str) -> Option<String> {
|
||||
let lower = html.to_lowercase();
|
||||
let start = lower.find("<title")? + 6;
|
||||
let start = html[start..].find('>')? + start + 1;
|
||||
let end = start + html[start..].to_lowercase().find("</title>")?;
|
||||
Some(html[start..end].trim().to_string())
|
||||
}
|
||||
|
||||
fn extract_meta_property(html: &str, property: &str) -> Option<String> {
|
||||
let lower = html.to_lowercase();
|
||||
let prop_lower = property.to_lowercase();
|
||||
let mut pos = 0;
|
||||
while let Some(tag_start) = lower[pos..].find("<meta") {
|
||||
let tag_start = pos + tag_start;
|
||||
let tag_end = html[tag_start..].find('>').map(|e| tag_start + e + 1).unwrap_or(html.len());
|
||||
let tag = &html[tag_start..tag_end];
|
||||
let tag_lower = &lower[tag_start..tag_end];
|
||||
if tag_lower.contains(&prop_lower) {
|
||||
if let Some(content) = extract_attr(tag, "content") {
|
||||
return Some(content);
|
||||
}
|
||||
}
|
||||
pos = tag_end;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn itunes_duration_to_upnp(d: String) -> String {
|
||||
let parts: Vec<&str> = d.trim().split(':').collect();
|
||||
match parts.len() {
|
||||
1 => {
|
||||
if let Ok(secs) = parts[0].parse::<u64>() {
|
||||
let h = secs / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
return format!("{}:{:02}:{:02}.000", h, m, s);
|
||||
}
|
||||
}
|
||||
2 => return format!("0:{}.000", d),
|
||||
3 => return format!("{}.000", d),
|
||||
_ => {}
|
||||
}
|
||||
d
|
||||
}
|
||||
9
pmourlsource/src/lib.rs
Normal file
9
pmourlsource/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod handler;
|
||||
pub mod handlers;
|
||||
pub mod source;
|
||||
|
||||
pub use handler::{ResolvedContent, ResolvedTrack, UrlHandler, UrlResolver, UrlResolverError};
|
||||
pub use handlers::generic::GenericUrlHandler;
|
||||
pub use handlers::qobuz::QobuzUrlHandler;
|
||||
pub use handlers::radiofrance::RadioFranceUrlHandler;
|
||||
pub use source::UrlSource;
|
||||
340
pmourlsource/src/source.rs
Normal file
340
pmourlsource/src/source.rs
Normal file
@@ -0,0 +1,340 @@
|
||||
use crate::handler::{ResolvedContent, ResolvedTrack, UrlResolver, UrlResolverError};
|
||||
use async_trait::async_trait;
|
||||
use pmodidl::{Container, Item, Resource};
|
||||
use pmosource::api::get_source as get_source_from_registry;
|
||||
use pmosource::{BrowseResult, MusicSource, MusicSourceError, SearchQuery, SourceCapabilities};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::RwLock;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/url-source.webp");
|
||||
|
||||
/// Store éphémère pour les playlists URL : playlist_id → (container, items)
|
||||
type PlaylistStore = Arc<RwLock<HashMap<String, (Container, Vec<Item>)>>>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UrlSource {
|
||||
resolver: UrlResolver,
|
||||
base_url: String,
|
||||
playlists: PlaylistStore,
|
||||
}
|
||||
|
||||
impl UrlSource {
|
||||
pub fn new(resolver: UrlResolver, base_url: String) -> Self {
|
||||
Self {
|
||||
resolver,
|
||||
base_url,
|
||||
playlists: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MusicSource for UrlSource {
|
||||
fn name(&self) -> &str {
|
||||
"URL / Partage"
|
||||
}
|
||||
|
||||
fn id(&self) -> &str {
|
||||
"url"
|
||||
}
|
||||
|
||||
fn default_image(&self) -> &[u8] {
|
||||
DEFAULT_IMAGE
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> SourceCapabilities {
|
||||
SourceCapabilities {
|
||||
supports_search: true,
|
||||
handles_url_input: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn root_container(&self) -> pmosource::Result<Container> {
|
||||
Ok(Container {
|
||||
id: "url".to_string(),
|
||||
parent_id: "0".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: None,
|
||||
searchable: Some("1".to_string()),
|
||||
title: "URL / Partage".to_string(),
|
||||
class: "object.container".to_string(),
|
||||
artist: None,
|
||||
album_art: None,
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
async fn browse(&self, object_id: &str) -> pmosource::Result<BrowseResult> {
|
||||
match object_id {
|
||||
"url" => Ok(BrowseResult::Containers(vec![])),
|
||||
// Playlist éphémère créée par build_url_playlist
|
||||
_ if object_id.starts_with("urlsource-") => {
|
||||
let store = self.playlists.read().map_err(|_| {
|
||||
MusicSourceError::BrowseError("playlist store lock poisoned".to_string())
|
||||
})?;
|
||||
match store.get(object_id) {
|
||||
Some((_, items)) => Ok(BrowseResult::Items(items.clone())),
|
||||
None => Err(MusicSourceError::ObjectNotFound(object_id.to_string())),
|
||||
}
|
||||
}
|
||||
// Court-circuiter les IDs "url:*" pour éviter des erreurs dans les logs
|
||||
// des autres sources (items éphémères non persistables par ID).
|
||||
_ if object_id.starts_with("url:") => {
|
||||
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
|
||||
}
|
||||
_ => Err(MusicSourceError::ObjectNotFound(object_id.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn search(&self, query: &SearchQuery) -> pmosource::Result<BrowseResult> {
|
||||
let url = query.text.trim();
|
||||
|
||||
if url.is_empty() {
|
||||
return Ok(BrowseResult::Containers(vec![]));
|
||||
}
|
||||
|
||||
match self.resolver.resolve(url).await {
|
||||
Ok(ResolvedContent::SourceContainer {
|
||||
source_id,
|
||||
container_id,
|
||||
}) => {
|
||||
if let Some(source) = get_source_from_registry(&source_id).await {
|
||||
match source.get_container(&container_id).await {
|
||||
Ok(Some(mut container)) => {
|
||||
container.parent_id = source_id;
|
||||
return Ok(BrowseResult::Containers(vec![container]));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
source_id = %source_id,
|
||||
container_id = %container_id,
|
||||
error = %e,
|
||||
"UrlSource: get_container échoué"
|
||||
);
|
||||
}
|
||||
}
|
||||
match source.get_item(&container_id).await {
|
||||
Ok(item) => return Ok(BrowseResult::Items(vec![item])),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
let title = display_title_for_url(url);
|
||||
let container = Container {
|
||||
id: container_id,
|
||||
parent_id: source_id,
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: Some("1".to_string()),
|
||||
searchable: Some("1".to_string()),
|
||||
title,
|
||||
class: "object.container".to_string(),
|
||||
artist: None,
|
||||
album_art: None,
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
};
|
||||
Ok(BrowseResult::Containers(vec![container]))
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Playlist { title: playlist_title, items }) => {
|
||||
let title = playlist_title.unwrap_or_else(|| display_title_for_url(url));
|
||||
let container = self.build_url_playlist(url, title, items);
|
||||
Ok(BrowseResult::Containers(vec![container]))
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Track(t)) => {
|
||||
// Pour un épisode unique, créer une playlist avec 1 item.
|
||||
// Titre de la playlist = nom du podcast (album) ou titre de l'épisode.
|
||||
let title = t.album.clone()
|
||||
.or_else(|| Some(t.title.clone()))
|
||||
.unwrap_or_else(|| display_title_for_url(url));
|
||||
let container = self.build_url_playlist(url, title, vec![t]);
|
||||
Ok(BrowseResult::Containers(vec![container]))
|
||||
}
|
||||
|
||||
Ok(ResolvedContent::Stream { uri, title, mime_type }) => {
|
||||
let item = stream_to_item(uri, title, mime_type);
|
||||
Ok(BrowseResult::Items(vec![item]))
|
||||
}
|
||||
|
||||
Err(UrlResolverError::NotSupported(_)) => {
|
||||
Ok(BrowseResult::Containers(vec![]))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(url = %url, error = %e, "UrlSource: résolution échouée");
|
||||
Err(MusicSourceError::BrowseError(format!(
|
||||
"Résolution URL échouée : {}",
|
||||
e
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_uri(&self, object_id: &str) -> pmosource::Result<String> {
|
||||
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
|
||||
}
|
||||
|
||||
fn supports_fifo(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn append_track(&self, _track: Item) -> pmosource::Result<()> {
|
||||
Err(MusicSourceError::FifoNotSupported)
|
||||
}
|
||||
|
||||
async fn remove_oldest(&self) -> pmosource::Result<Option<Item>> {
|
||||
Err(MusicSourceError::FifoNotSupported)
|
||||
}
|
||||
|
||||
async fn update_id(&self) -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
async fn last_change(&self) -> Option<SystemTime> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn get_items(&self, _offset: usize, _count: usize) -> pmosource::Result<Vec<Item>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
impl UrlSource {
|
||||
/// Crée un container playlist éphémère en mémoire depuis des tracks résolus.
|
||||
///
|
||||
/// Les items gardent leurs URLs directes (RadioFrance, etc.) et leur MIME type
|
||||
/// d'origine — pas de proxy via pmoaudiocache, donc pas de conversion FLAC
|
||||
/// et pas de problème avec les formats M4A/AAC.
|
||||
fn build_url_playlist(&self, url: &str, title: String, tracks: Vec<ResolvedTrack>) -> Container {
|
||||
let playlist_id = format!("urlsource-{:016x}", url_hash(url));
|
||||
let n = tracks.len();
|
||||
|
||||
// Cover = album_art du premier épisode
|
||||
let album_art = tracks.first().and_then(|t| t.album_art.clone());
|
||||
|
||||
let items: Vec<Item> = tracks
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let protocol_info = format!("http-get:*:{}:*", t.mime_type);
|
||||
Item {
|
||||
id: format!("{}:{}", playlist_id, i),
|
||||
parent_id: playlist_id.clone(),
|
||||
restricted: Some("1".to_string()),
|
||||
title: t.title,
|
||||
creator: t.artist.clone(),
|
||||
class: "object.item.audioItem.musicTrack".to_string(),
|
||||
artist: t.artist,
|
||||
album: t.album.or_else(|| Some(title.clone())),
|
||||
genre: None,
|
||||
album_art: t.album_art,
|
||||
album_art_pk: None,
|
||||
date: None,
|
||||
original_track_number: Some(format!("{}", i + 1)),
|
||||
resources: vec![Resource {
|
||||
protocol_info,
|
||||
bits_per_sample: None,
|
||||
sample_frequency: None,
|
||||
nr_audio_channels: None,
|
||||
duration: t.duration,
|
||||
url: t.uri,
|
||||
}],
|
||||
descriptions: vec![],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let container = Container {
|
||||
id: playlist_id.clone(),
|
||||
parent_id: "url".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: Some(n.to_string()),
|
||||
searchable: Some("0".to_string()),
|
||||
title: title.clone(),
|
||||
class: "object.container.playlistContainer".to_string(),
|
||||
artist: None,
|
||||
album_art,
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
};
|
||||
|
||||
// Stocker dans le store éphémère (écrase toute entrée précédente)
|
||||
if let Ok(mut store) = self.playlists.write() {
|
||||
store.insert(playlist_id, (container.clone(), items));
|
||||
}
|
||||
|
||||
container
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit un flux continu en `pmodidl::Item`.
|
||||
fn stream_to_item(uri: String, title: String, mime_type: String) -> Item {
|
||||
let protocol_info = format!("http-get:*:{}:*", mime_type);
|
||||
Item {
|
||||
id: "url:item:0".to_string(),
|
||||
parent_id: "url".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
title,
|
||||
creator: None,
|
||||
class: "object.item.audioItem.audioBroadcast".to_string(),
|
||||
artist: None,
|
||||
album: None,
|
||||
genre: None,
|
||||
album_art: None,
|
||||
album_art_pk: None,
|
||||
date: None,
|
||||
original_track_number: None,
|
||||
resources: vec![Resource {
|
||||
protocol_info,
|
||||
bits_per_sample: None,
|
||||
sample_frequency: None,
|
||||
nr_audio_channels: None,
|
||||
duration: None,
|
||||
url: uri,
|
||||
}],
|
||||
descriptions: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Extrait un titre lisible depuis une URL.
|
||||
fn display_title_for_url(url: &str) -> String {
|
||||
let host = url
|
||||
.find("://")
|
||||
.and_then(|i| {
|
||||
let after = &url[i + 3..];
|
||||
let end = after.find('/').unwrap_or(after.len());
|
||||
Some(&after[..end])
|
||||
})
|
||||
.unwrap_or("");
|
||||
|
||||
let type_label = if url.contains("/album/") {
|
||||
"Album"
|
||||
} else if url.contains("/track/") {
|
||||
"Titre"
|
||||
} else if url.contains("/playlist/") {
|
||||
"Playlist"
|
||||
} else if url.contains("/artist/") {
|
||||
"Artiste"
|
||||
} else {
|
||||
"Contenu"
|
||||
};
|
||||
|
||||
if host.is_empty() {
|
||||
type_label.to_string()
|
||||
} else {
|
||||
format!("{} ({})", type_label, host)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash stable d'une URL pour construire un ID de playlist déterministe.
|
||||
fn url_hash(url: &str) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
url.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
0.3.51
|
||||
0.3.55
|
||||
|
||||
Reference in New Issue
Block a user