Compare commits
66 Commits
4c70a939cd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ec56e479ba | |||
| fc49c79b34 | |||
| 884b092af8 | |||
| 472211012b | |||
| ce9c779bb2 | |||
| 070ca8f3e0 | |||
| 01f49670e9 | |||
| ed9b8046e4 | |||
| f5d9e2590e | |||
| ad45e40900 | |||
| cc27e4efca | |||
| b7285e14c4 | |||
| da4d9008ad | |||
| f757e10734 | |||
| 009545de40 | |||
| bfa6231b1d | |||
| 87bce3edfa | |||
| d49bb604ca | |||
| 429bc0d274 | |||
| d810e99a5b | |||
| 56a9f15cc1 | |||
| 0dab3077cf | |||
| 8179c0e239 | |||
| 319a54ee8a | |||
| 1d3a9379a1 | |||
| a30186485f | |||
| 0d4cf3cdae | |||
| bcfffa6df8 | |||
| e670e8e0d8 | |||
| c7f67e7c7c | |||
| a97bfe6148 | |||
| 7c10b22204 | |||
| 46e269a290 | |||
| 7bce0ffe19 | |||
| ba40d07f73 | |||
| 8bc0670de4 | |||
| 037435af20 | |||
| 2b3fae7778 | |||
| 06de4feb5e | |||
| 9b5a028f3f | |||
| 9ad7d7454d | |||
| 39d7c56a99 | |||
| 82197c8103 | |||
| 8ddee7d94f | |||
| 4b793cec59 | |||
| 9e447023a8 | |||
| 6285c8ccc8 | |||
| 3401ee839c | |||
| e8e33414f0 | |||
| b0e24c3b3c | |||
| b5b6becb25 | |||
| 353e54af76 | |||
| f2be1b9bc1 | |||
| 6e65558ba9 | |||
| 4c7c99f0a4 | |||
| 5c6e2d43ac | |||
| 6fd11ab450 | |||
| 57c2552247 | |||
| 9935a5e5af | |||
| 3979d346a5 | |||
| 6240179397 | |||
| 934bbaf4b8 | |||
| 35e2e037f0 | |||
| 9dee193947 | |||
| c128120697 | |||
| 4b7c482bf3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -50,3 +50,4 @@ RF_old.json
|
||||
.claude/
|
||||
.claude.old
|
||||
Kilo-session.md
|
||||
pmomusic_logs.txt
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
225
.kilo/plans/1775631632458-happy-cactus.md
Normal file
225
.kilo/plans/1775631632458-happy-cactus.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Audit PMO Control - Optimisation Playlist OpenHome
|
||||
|
||||
## Résumé Exécutif
|
||||
|
||||
L'utilisateur rapporte des lenteurs significatives lors de la manipulation de playlists de ~1000 titres avec les renderers OpenHome. Les renderers Chromecast et UPnP (avec queue interne) ne sont pas affectés.
|
||||
|
||||
## État des Optimisations Deja Implémentées
|
||||
|
||||
Le precedent plan dans `Blackboard/Todo/enorme_playlist.md` a deja été partiellement implémenté:
|
||||
|
||||
| optimisation | Statut | Emplacement |
|
||||
|-------------|-------|------------|
|
||||
| MAX_BATCH = 256 pour ReadList | ✅ FAIT | `openhome.rs:1015` |
|
||||
| Élimination double queue_snapshot() | ✅ FAIT | `replace_queue_with_pivot()` et `replace_queue_standard_lcs()` |
|
||||
| LCS préfixe/suffixe (lcs_flags_optimized) | ✅ FAIT | `openhome.rs:869` |
|
||||
| Polling adaptatif (is_active) | ✅ FAIT | `musicrenderer.rs:325-374` |
|
||||
| Consolidation invalidation caches | ✅ FAIT | `openhome.rs:222-235` |
|
||||
|
||||
## Contraintes Protocolaires Découvertes
|
||||
|
||||
L'action `Insert` **ne supporte PAS l'insertion par lot** - chaque appel prend un seul Uri/Metadata.
|
||||
|
||||
## Nouvelles Optimisations (Contre-propositions Utilisateur)
|
||||
|
||||
### OPT-1: Fast Path pour 99% des cas de sync_queue
|
||||
|
||||
**Observation**: 99% des changements de playlist sont:
|
||||
- Insertion de nouvelles tracks en **fin de queue**
|
||||
- Délétion de tracks en **début de queue**
|
||||
- Rarement des changements nécessitant un vrai alignement LCS
|
||||
|
||||
**Solution**: Ajouter une détection de pattern avant d'appeler LCS:
|
||||
|
||||
```rust
|
||||
fn smart_sync(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
let current_ids = self.track_ids()?;
|
||||
|
||||
// Cas 1: Append only (insertion en fin)
|
||||
if items.starts_with(¤t_ids) {
|
||||
// Fast path: juste ajouter les nouveaux items
|
||||
return self.append_only(items.skip(current_ids.len()));
|
||||
}
|
||||
|
||||
// Cas 2: Delete from beginning
|
||||
if current_ids.starts_with(&items) {
|
||||
// Fast path: supprimer de la fin
|
||||
return self.delete_from_beginning(current_ids.len() - items.len());
|
||||
}
|
||||
|
||||
// Cas 3: Full LCS only for complex reorderings
|
||||
return self.replace_queue_standard_lcs(items);
|
||||
}
|
||||
```
|
||||
|
||||
**Impact**: 99% des sync_queue passent de O(N²) à O(N)
|
||||
|
||||
---
|
||||
|
||||
### OPT-2: Queue FIFO pour Opérations OpenHome (Thread Background)
|
||||
|
||||
**Concept**: Une file d'attente FIFO des opérations SOAP exécutée dans un thread dédié.
|
||||
|
||||
```rust
|
||||
pub struct OpenHomeOpQueue {
|
||||
queue: Arc<Mutex<Vec<OpenHomeOp>>>,
|
||||
worker_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
pub enum OpenHomeOp {
|
||||
Insert { uri: String, metadata: String, after_id: u32 },
|
||||
Delete { track_id: u32 },
|
||||
DeleteAll,
|
||||
SeekId { id: u32 },
|
||||
Play,
|
||||
Pause,
|
||||
Stop,
|
||||
SetVolume { volume: u16 },
|
||||
// Meta operations
|
||||
UpdateMetadata { track_id: u32, metadata: String },
|
||||
}
|
||||
|
||||
impl OpenHomeOpQueue {
|
||||
/// Push operation to the FIFO queue
|
||||
pub fn push(&self, op: OpenHomeOp) {
|
||||
self.queue.lock().unwrap().push(op);
|
||||
}
|
||||
|
||||
/// Push with priority (volume, play, stop - need fast response)
|
||||
pub fn push_first(&self, op: OpenHomeOp) {
|
||||
self.queue.lock().unwrap().push_front(op);
|
||||
}
|
||||
|
||||
/// Clear all pending operations (client can flush)
|
||||
pub fn clear(&self) {
|
||||
self.queue.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Worker thread consumes operations
|
||||
fn worker_loop(&self) {
|
||||
loop {
|
||||
let op = self.queue.lock().unwrap().pop_front();
|
||||
match op {
|
||||
Some(op) => self.execute(op),
|
||||
None => thread::sleep(Duration::from_millis(10)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- UI non-bloquante (les operations sont lancées et exec en background)
|
||||
- Batching naturel (plusieurs operations sont executes en sequence)
|
||||
- Priorité via `push_first()` pour play/stop/volume
|
||||
- `clear()` permet d'annuler les operations en attente (ex: playlist changée)
|
||||
|
||||
**Implémentation suggérée**:
|
||||
1. Créer `src/queue/openhome_op_queue.rs` avec la structure
|
||||
2. Intégrer dans `OpenHomeQueue` ou `OpenHomeRenderer`
|
||||
3. Thread de worker lancé au démarrage du control point
|
||||
|
||||
---
|
||||
|
||||
### OPT-3: Métadonnées en Tâche de Fond
|
||||
|
||||
**Observation**: L'appli utilise-t-elle vraiment les métadonnées de la queue OpenHome, ou un cache local?
|
||||
|
||||
Si le control-point maintient son propre cache (plus probable):
|
||||
- Les mises à jour de métadonnées peuvent être traitées en background
|
||||
- Pas besoin de sync immédiate des métadonnées
|
||||
|
||||
**Solution**: Queue séparée pour les operations de métadonnées:
|
||||
|
||||
```rust
|
||||
// Haute priorité (opérations critiques)
|
||||
let high_priority_queue: OpenHomeOpQueue;
|
||||
|
||||
// Basse priorité (métadonnées)
|
||||
let metadata_queue: OpenHomeOpQueue;
|
||||
```
|
||||
|
||||
**Implémentation**:
|
||||
1. Séparer les operations critiques (play/stop/seek/volume) de metadata
|
||||
2. Metadata update traités en background avec délais
|
||||
3. Le cache local du control-point est mis à jour indépendamment
|
||||
|
||||
---
|
||||
|
||||
### OPT-4: Connection Pooling HTTP
|
||||
|
||||
Chaque appel SOAP crée une nouvelle connexion. Avec 1000 insertions:
|
||||
- Overhead TCP: ~10-50ms par appel
|
||||
- Total: 10-50 secondes overhead réseau
|
||||
|
||||
**Solution**: Agent HTTP static avec connection reuse:
|
||||
|
||||
```rust
|
||||
// soap_client.rs
|
||||
static HTTP_AGENT: Lazy<ureq::Agent> = Lazy::new(|| {
|
||||
Agent::config_builder()
|
||||
.timeout_global(Some(Duration::from_secs(30)))
|
||||
.build()
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plan d'Implémentation Proposé
|
||||
|
||||
### Phase 1: Fast Path LCS (Priorité Haute)
|
||||
|
||||
1. Ajouter `detect_sync_pattern()` dans `openhome.rs`
|
||||
2. Implémenter `append_only()` et `delete_from_beginning()`
|
||||
3. Tester avec playlists réelles
|
||||
|
||||
### Phase 2: Queue FIFO Opérations (Priorité Haute)
|
||||
|
||||
1. Créer `src/queue/openhome_op_queue.rs`
|
||||
2. Implémenter `push()`, `push_first()`, `clear()`
|
||||
3. Thread worker avec loop de consommation
|
||||
4. Intégrer dans `OpenHomeRenderer`
|
||||
|
||||
### Phase 3: Séparation Métadonnées (Priorité Moyenne)
|
||||
|
||||
1. Créer queue séparée pour metadata
|
||||
2. Implémenter batch processing
|
||||
|
||||
### Phase 4: Connection Pooling (Priorité Basse)
|
||||
|
||||
1. Modifier `soap_client.rs` pour agent static
|
||||
|
||||
---
|
||||
|
||||
## Questions pour Clarification
|
||||
|
||||
1. **Cache Métadonnées**: Le control-point utilise-t-il vraiment les métadonnées de la queue OpenHome, ou maintient-il son propre cache qui est alimenté indépendamment?
|
||||
|
||||
2. **Priorité des Opérations**: Pour `push_first()`, quelles opérations nécessitent une réponse rapide?
|
||||
- Volume (immédiat)
|
||||
- Play/Pause/Stop (immédiat)
|
||||
- Seek (rapide)
|
||||
- Insert (peut être différé)
|
||||
|
||||
3. **Comportement en cas de conflit**: Si le client fait `clear()` et que le worker est en train d'exécuter une opération:
|
||||
- Annuler l'opération en cours? ( risky - peut laisser le renderer dans un état inconsistent)
|
||||
- Laisser finir l'opération en cours? (plus sur)
|
||||
|
||||
---
|
||||
|
||||
## Tests Recommandés
|
||||
|
||||
```bash
|
||||
# Compiler
|
||||
cargo build -p pmocontrol
|
||||
|
||||
# Benchmark LCS fast paths
|
||||
# - Cas: append 100 tracks to 900 = O(N)
|
||||
# - Cas: delete 100 from 900 = O(N)
|
||||
# - Cas: reorder = O(N²) avec LCS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Plan mis à jour avec contre-propositions utilisateur*
|
||||
*Date: 2026-04-08*
|
||||
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: []
|
||||
439
Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md
Normal file
439
Blackboard/Architecture/pmoqobuz_ameliorations_qbz.md
Normal file
@@ -0,0 +1,439 @@
|
||||
# Améliorations pmoqobuz inspirées de qbz
|
||||
|
||||
Analyse comparative avec le projet [qbz](../../../qbz) (`crates/qbz-qobuz/`), client Qobuz Rust plus avancé.
|
||||
Les items sont classés par priorité et état d'avancement.
|
||||
|
||||
---
|
||||
|
||||
## 1. Streaming CMAF — **FAIT**
|
||||
|
||||
**Problème** : l'endpoint legacy `/track/getFileUrl` est en cours de dépréciation. Qobuz bascule vers
|
||||
CMAF (Common Media Application Format) : segments AES-CTR chiffrés sur CDN Akamai.
|
||||
|
||||
**Implémentation réalisée** :
|
||||
- `pmoqobuz::cmaf` — pipeline complet : dérivation HKDF, dérobage AES-CBC, déchiffrement AES-CTR par frame
|
||||
- `pmoqobuz::retry` — retry exponentiel avec classification Transient/Terminal
|
||||
- `QobuzClient::open_cmaf_stream()` — `AsyncRead` progressif via `tokio::io::duplex`, 3 segments en vol
|
||||
- `GET /qobuz/tracks/:id/flac` — endpoint REST local qui expose le flux ; `LazyProvider::get_url()`
|
||||
retourne cette URL locale (via `PMO_SERVER_URL`) → le progressive caching de pmocache est préservé sans
|
||||
modification
|
||||
|
||||
**Référence qbz** : `crates/qbz-qobuz/src/cmaf.rs`
|
||||
|
||||
---
|
||||
|
||||
## 2. Extraction du bundle Qobuz — **Fait**
|
||||
|
||||
**Problème** : `pmoqobuz` utilise un `app_id` et un `configvalue` statiques, hardcodés ou configurés
|
||||
manuellement. Qobuz peut les invalider à tout moment en changeant son bundle JS.
|
||||
|
||||
**Ce que fait qbz** (`bundle.rs`) :
|
||||
- Télécharge la page `https://play.qobuz.com/login`, extrait l'URL du bundle JS
|
||||
- Parse le bundle (~7 MB) avec des regex pour en extraire `app_id`, les secrets, et la `private_key` OAuth
|
||||
- Met en cache les tokens sur disque avec un hash de version du bundle (`bundle_version`)
|
||||
- Revalide automatiquement si la version change (rotation silencieuse de Qobuz)
|
||||
- Timeout de 45 s sur le fetch, 2 retry sur extraction
|
||||
|
||||
**Impact pour pmoqobuz** :
|
||||
- Le `Spoofer` actuel fait un fetch similaire mais sans cache disque ni détection de version
|
||||
- Ajouter `CachedBundle` (version + tokens + timestamp) dans `pmoconfig` ou dans le répertoire de données
|
||||
- Relire le cache au démarrage, re-extraire seulement si la version du bundle a changé
|
||||
|
||||
**Avantage** : ne jamais tomber en panne quand Qobuz rotate ses secrets sans préavis.
|
||||
|
||||
---
|
||||
|
||||
## 3. Chargement batch de tracks — `track/getList` — **FAIT**
|
||||
|
||||
**Problème** : les tracks retournées par `/playlist/get?extra=tracks` et
|
||||
`/favorite/getUserFavorites` ont des métadonnées incomplètes (parfois sans `performer`,
|
||||
jamais de `sample_rate`/`bit_depth`/`channels`). Cela déclenchait des appels individuels
|
||||
`get_track` lazy à la lecture.
|
||||
|
||||
**Implémentation réalisée** :
|
||||
- `signing::sign_track_get_list(ids_csv, timestamp, secret)` — signature MD5 pour `track/getList`
|
||||
- `QobuzApi::post_json_with_query` — POST avec auth headers + query sig + JSON body
|
||||
- `QobuzApi::get_tracks_batch(&[&str])` — fenêtres de 50 IDs, appels en série
|
||||
- `QobuzClient::get_tracks_batch` — wrapper avec cache (skip les IDs déjà en cache)
|
||||
- `get_playlist_tracks` : phase 1 pagination existante, phase 2 enrichissement si secret disponible
|
||||
- `get_favorite_tracks` : même enrichissement en phase 2
|
||||
- Fallback gracieux si le secret est absent ou si `track/getList` échoue
|
||||
|
||||
**Ce que fait qbz** (`get_tracks_batch`, l.1323) :
|
||||
```
|
||||
POST /track/getList
|
||||
{ "tracks_id": [id1, id2, ..., id50] }
|
||||
→ { "tracks": { "total": N, "items": [...Track] } }
|
||||
```
|
||||
- Fenêtre de 50 IDs max par appel (limite API Qobuz)
|
||||
- Les fenêtres supérieures à 50 sont découpées et appelées en série (respecte les quotas)
|
||||
|
||||
---
|
||||
|
||||
## 4. Pagination concurrente des playlists — **FAIT**
|
||||
|
||||
**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é
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## 5. Endpoint `release_watch` — **À FAIRE** (priorité basse)
|
||||
|
||||
**Problème** : pmoqobuz ne supporte pas les nouvelles sorties d'artistes suivis.
|
||||
|
||||
**Ce que fait qbz** (`get_release_watch`, l.809) :
|
||||
```
|
||||
GET /favorite/getNewReleases?type=album&limit=50&offset=0
|
||||
→ { has_more: bool, items: [...Album] }
|
||||
```
|
||||
- Types disponibles : `album`, `live`, `ep_single`
|
||||
- Pas de champ `total` dans la réponse — pagination via `has_more`
|
||||
|
||||
**Impact pour pmoqobuz** : permettre un "quoi de neuf" dans l'interface — albums des artistes favoris
|
||||
sortis récemment. Utile pour le catalogue de la webapp.
|
||||
|
||||
---
|
||||
|
||||
## 6. Chargement `playlist/get?extra=track_ids` — **À FAIRE** (priorité basse)
|
||||
|
||||
**Ce que fait qbz** (`get_playlist_track_ids`, l.1296) :
|
||||
- Variante légère de `playlist/get` qui retourne uniquement les IDs (pas les objets Track complets)
|
||||
- Utile pour vérifier si une playlist a changé sans tout recharger
|
||||
- Combiné avec `get_tracks_batch` pour un chargement optimal en deux passes :
|
||||
1. `playlist/get?extra=track_ids` → liste d'IDs
|
||||
2. `track/getList` par fenêtres de 50 → objets Track complets
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|---|---|---|---|---|
|
||||
| 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 | **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.
|
||||
235
Blackboard/Todo/enorme_playlist_step2.md
Normal file
235
Blackboard/Todo/enorme_playlist_step2.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Optimisation Playlist OpenHome — Étape 2
|
||||
|
||||
**Contexte**: Suite de `enorme_playlist.md`. Les optimisations de base (MAX_BATCH=256, LCS prefixe/suffixe, polling adaptatif, caches TTL) sont faites. Les lenteurs persistent sur les grandes playlists (~1000 titres). Les renderers Chromecast et UPnP sont moins affectés mais bénéficieront également de certaines optimisations.
|
||||
|
||||
**Contrainte architecturale fondamentale**: La queue OpenHome est la **source de vérité unique**. Un miroir local persistent a été tenté et abandonné — impossible à maintenir en sync quand d'autres control points (BubbleUPnP, Linn, etc.) modifient la queue. Toute optimisation doit respecter cette contrainte.
|
||||
|
||||
**Note sur le LCS**: `lcs_flags_optimized` (openhome.rs:869) gère déjà le cas dominant (préfixe/suffixe communs). Pour un append de 100 tracks à 900 existants, le LCS est O(1) — ce n'est pas le goulot. Le coût réel est les appels SOAP : connexions TCP × (RTT + handshake) et les `ReadList` pour reconstruire le snapshot.
|
||||
|
||||
---
|
||||
|
||||
## Analyse des Goulots Réels
|
||||
|
||||
Pour un `sync_queue` "append 100 tracks à 900 existants" aujourd'hui :
|
||||
|
||||
| Étape | Appels SOAP | Coût estimé |
|
||||
|-------|------------|-------------|
|
||||
| `id_array()` | 1 | ~5ms |
|
||||
| `read_list()` — 4 batches × 256 | 4 | ~20ms |
|
||||
| 100 × `insert()` | 100 | ~100 × (RTT + **TCP handshake**) |
|
||||
| **Total TCP handshakes** | 105 | **105 × 10-50ms = 1-5 secondes** |
|
||||
|
||||
Les deux leviers : (1) éliminer les handshakes TCP, (2) éliminer les `ReadList` quand inutiles.
|
||||
|
||||
---
|
||||
|
||||
## Plan d'Implémentation
|
||||
|
||||
### Phase 1 — Connection Pooling (1-2h, PRIORITÉ MAXIMALE)
|
||||
|
||||
**Fichier**: `pmocontrol/src/soap_client.rs`
|
||||
|
||||
**Problème**: lignes 65-72 créent un nouvel `ureq::Agent` à chaque appel SOAP = nouvelle connexion TCP à chaque fois.
|
||||
|
||||
**Solution**: Agent statique partagé via `OnceLock`.
|
||||
|
||||
```rust
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static SOAP_AGENT: OnceLock<ureq::Agent> = OnceLock::new();
|
||||
|
||||
fn get_soap_agent() -> &'static ureq::Agent {
|
||||
SOAP_AGENT.get_or_init(|| {
|
||||
ureq::Agent::config_builder()
|
||||
.http_status_as_error(false)
|
||||
.timeout_global(Some(Duration::from_secs(30)))
|
||||
.build()
|
||||
.into()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Dans `invoke_upnp_action_with_timeout()`, remplacer la construction de l'agent par :
|
||||
|
||||
```rust
|
||||
// Cas normal : réutiliser l'agent partagé (keep-alive, connection pooling)
|
||||
// Cas custom timeout : agent dédié (rare — timeout global suffit en pratique)
|
||||
let agent_owned;
|
||||
let agent: &ureq::Agent = if timeout.is_some() {
|
||||
agent_owned = ureq::Agent::config_builder()
|
||||
.http_status_as_error(false)
|
||||
.timeout_global(timeout)
|
||||
.build()
|
||||
.into();
|
||||
&agent_owned
|
||||
} else {
|
||||
get_soap_agent()
|
||||
};
|
||||
```
|
||||
|
||||
**Vérifier** que `ureq::Agent` maintient bien un pool de connexions HTTP/1.1 keep-alive entre les appels (comportement documenté de ureq v3 — l'agent est conçu pour être réutilisé).
|
||||
|
||||
**Impact**: Élimine les TCP handshakes répétés pour tous les renderers (OpenHome, UPnP, Chromecast). Pour 100 inserts : 99 handshakes économisés × 10-50ms = **1-5 secondes récupérées**.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Fast Path Session (2-3h, PRIORITÉ HAUTE)
|
||||
|
||||
**Concept**: Sans miroir persistant (abandonné), on peut quand même éviter les `ReadList` dans le cas dominant en maintenant un état **éphémère de session** — valable uniquement entre deux `sync_queue` consécutifs, et invalidé dès qu'on détecte une incohérence.
|
||||
|
||||
**Principe**: Après chaque `sync_queue`, mémoriser :
|
||||
- la liste d'IDs résultante (déjà dans `track_ids_cache` TTL 1s)
|
||||
- le `after_id` du dernier insert (pour pouvoir appender sans `id_array`)
|
||||
|
||||
Au prochain `sync_queue`, tenter de détecter le pattern sans `ReadList` :
|
||||
|
||||
```rust
|
||||
fn try_fast_path(&self, new_items: &[PlaybackItem]) -> FastPathResult {
|
||||
// Récupérer les IDs actuels (cache ou 1 appel id_array)
|
||||
let current_ids = self.track_ids()?;
|
||||
let current_len = current_ids.len();
|
||||
let new_len = new_items.len();
|
||||
|
||||
// Fast path 1: append only
|
||||
// Condition: new_items a plus d'items, et les current_len premiers de new_items
|
||||
// ont les mêmes didl_id que les items actuels (vérifiable depuis id_array + metadata_cache local)
|
||||
if new_len > current_len {
|
||||
let prefix_matches = self.check_prefix_matches(¤t_ids, &new_items[..current_len]);
|
||||
if prefix_matches {
|
||||
return FastPathResult::AppendOnly { items: &new_items[current_len..] };
|
||||
}
|
||||
}
|
||||
|
||||
// Fast path 2: delete from end
|
||||
if new_len < current_len {
|
||||
let prefix_matches = self.check_prefix_matches(¤t_ids[..new_len], new_items);
|
||||
if prefix_matches {
|
||||
let to_delete = ¤t_ids[new_len..];
|
||||
return FastPathResult::DeleteFromEnd { ids: to_delete };
|
||||
}
|
||||
}
|
||||
|
||||
// Cas général: fallback ReadList + LCS
|
||||
FastPathResult::NeedFullSync
|
||||
}
|
||||
```
|
||||
|
||||
**`check_prefix_matches`** : compare `current_ids[i]` avec `new_items[i]` en utilisant le `metadata_cache` local (déjà en mémoire) pour résoudre les URIs/didl_ids des IDs connus. Si un ID n'est pas en cache → fast path impossible → fallback.
|
||||
|
||||
**Clé**: Cette vérification utilise uniquement le `metadata_cache` local (HashMap en mémoire, nano-secondes) et `id_array()` (déjà caché TTL 1s). Zéro appel `ReadList` dans le cas heureux. Si la vérification échoue (incohérence détectée, cache manquant) → fallback propre vers `ReadList` + LCS, source de vérité OpenHome préservée.
|
||||
|
||||
**Fichier**: `pmocontrol/src/queue/openhome.rs`, ajouter `try_fast_path()` et l'intégrer en début de `sync_queue()`.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Queue FIFO Async (8-12h, PRIORITÉ MOYENNE)
|
||||
|
||||
**Prérequis**: Phases 1 et 2 complétées.
|
||||
|
||||
**Concept**: Exécuter les opérations SOAP dans un thread dédié pour rendre `sync_queue()` non-bloquant du point de vue de l'appelant.
|
||||
|
||||
**Contrainte technique**: Les `insert()` sont chaînés — chaque appel retourne un `new_id` utilisé comme `after_id` du suivant. Le worker doit maintenir cet état interne.
|
||||
|
||||
**Fichier à créer**: `pmocontrol/src/queue/openhome_op_queue.rs`
|
||||
|
||||
```rust
|
||||
pub enum OpenHomeOp {
|
||||
/// Insert séquentiel — after_id géré en interne (last_inserted_id)
|
||||
InsertAtEnd { uri: String, metadata: String, didl_id: String },
|
||||
/// Insert après un ID connu (ex: après le pivot)
|
||||
InsertAfter { after_id: u32, uri: String, metadata: String, didl_id: String },
|
||||
DeleteId { track_id: u32 },
|
||||
DeleteAll,
|
||||
SeekId { id: u32 },
|
||||
Play,
|
||||
Pause,
|
||||
Stop,
|
||||
SetVolume { volume: u16 },
|
||||
}
|
||||
|
||||
pub struct OpenHomeOpQueue {
|
||||
sender: mpsc::Sender<OpenHomeOp>,
|
||||
last_error: Arc<Mutex<Option<ControlPointError>>>,
|
||||
completion: Arc<(Mutex<bool>, Condvar)>,
|
||||
}
|
||||
|
||||
impl OpenHomeOpQueue {
|
||||
pub fn push(&self, op: OpenHomeOp) { ... }
|
||||
/// Opérations critiques passent devant (play/stop/volume)
|
||||
pub fn push_priority(&self, op: OpenHomeOp) { ... }
|
||||
/// Vider la file d'attente (ex: nouvelle playlist demandée avant fin de sync)
|
||||
pub fn clear_pending(&self) { ... }
|
||||
/// Attendre que toutes les opérations soient exécutées
|
||||
pub fn wait_completion(&self) { ... }
|
||||
/// Récupérer la dernière erreur (non-bloquant)
|
||||
pub fn take_error(&self) -> Option<ControlPointError> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Comportement en cas d'erreur**: vider la file d'attente, signaler l'erreur via `last_error`, invalider les caches OpenHome (forcer re-sync depuis source de vérité au prochain appel).
|
||||
|
||||
**Comportement en cas de `clear_pending()` pendant exécution**: laisser l'opération en cours se terminer (plus sûr — évite de laisser le renderer dans un état inconsistant), vider le reste.
|
||||
|
||||
**Intégration dans `OpenHomeQueue`**: remplacer les appels directs `playlist_client.insert()` / `playlist_client.delete_id()` par des `op_queue.push()`. Les opérations qui ont besoin d'une réponse synchrone (ex: `current_track()`, `queue_snapshot()`) continuent d'appeler directement le `playlist_client` — mais doivent d'abord attendre la complétion de la file (`wait_completion()`).
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Throttle des replace_item (2-3h, PRIORITÉ BASSE)
|
||||
|
||||
**Contexte**: `replace_item()` (openhome.rs:1315) fait `delete_id` + `insert` pour mettre à jour une piste. Sur un stream radio qui change de morceau, la durée est mise à jour fréquemment → paires SOAP inutiles car le `metadata_cache` local est déjà la source pour l'UI.
|
||||
|
||||
**Solution**: Ne pas envoyer le `delete_id` + `insert` OpenHome si une mise à jour pour ce `track_id` a déjà été envoyée dans les N dernières secondes. Le `metadata_cache` local est mis à jour immédiatement (pour l'UI), et l'opération OpenHome est différée ou ignorée.
|
||||
|
||||
```rust
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
let track_id = self.track_ids()?[index];
|
||||
|
||||
// Toujours mettre à jour le cache local immédiatement (pour l'UI)
|
||||
self.cache_metadata(track_id, item.metadata.clone());
|
||||
|
||||
// Throttle: si replace OpenHome récent pour ce track, sauter l'opération SOAP
|
||||
if self.is_recent_replace(track_id, Duration::from_secs(5)) {
|
||||
return Ok(());
|
||||
}
|
||||
self.mark_replace_time(track_id);
|
||||
|
||||
// Opération SOAP (delete + insert)
|
||||
// ... code existant ...
|
||||
}
|
||||
```
|
||||
|
||||
Ajouter `last_replace_times: Mutex<HashMap<u32, SystemTime>>` dans `OpenHomeQueue`.
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'Implémentation
|
||||
|
||||
```
|
||||
Phase 1 → mesurer gain TCP → Phase 2 → mesurer gain ReadList → Phase 3 → Phase 4
|
||||
```
|
||||
|
||||
Tester chaque phase avec des playlists réelles (~1000 tracks) avant de poursuivre. Ne pas combiner les phases pour pouvoir isoler les régressions.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# Phase 1 — vérifier connexions TCP réutilisées
|
||||
# tcpdump -i lo port 60000 -c 200 (ou port du renderer)
|
||||
# Avant: SYN à chaque appel SOAP
|
||||
# Après: SYN unique, flux keep-alive
|
||||
|
||||
# Phase 2 — vérifier fast path activé
|
||||
# RUST_LOG=debug cargo run ... 2>&1 | grep "fast path"
|
||||
# Cas append 100 → "fast path: AppendOnly, 100 inserts"
|
||||
# Cas delete 100 → "fast path: DeleteFromEnd, 100 deletes"
|
||||
# Cas reorder → "fast path: NeedFullSync, falling back to ReadList+LCS"
|
||||
|
||||
# Phase 3 — vérifier non-blocage
|
||||
# sync_queue() doit retourner en <1ms (les 100 inserts continuent en background)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Contrainte architecturale intégrée: miroir local abandonné (désynchronisation avec autres control points). Toutes les phases respectent OpenHome comme source de vérité unique.*
|
||||
|
||||
*Date: 2026-04-08*
|
||||
642
Blackboard/Todo/enorme_playlist_step3.md
Normal file
642
Blackboard/Todo/enorme_playlist_step3.md
Normal file
@@ -0,0 +1,642 @@
|
||||
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
|
||||
|
||||
# Async Queue Refresh — Étape 3
|
||||
|
||||
**Contexte**: `refresh_attached_queue_for()` dans `control_point.rs` est appelé de 3 endroits
|
||||
et bloque son thread pendant toute la synchronisation (browse media server + 100+ opérations
|
||||
SOAP/mémoire). L'objectif est de factoriser le mécanisme async au niveau de la couche queue
|
||||
(`MusicQueue`), qui est déjà l'abstraction agnostique du backend. `control_point.rs` ne doit
|
||||
plus connaître les threads ni les tokens d'annulation.
|
||||
|
||||
**Principe architectural**: la couche queue sait *comment* syncer (mémoire ou SOAP) et donc
|
||||
aussi *comment* annuler et *quand* signaler que la lecture peut démarrer. `control_point.rs`
|
||||
sait seulement *quoi* syncer (browse + conversion PlaybackItem). Les deux responsabilités
|
||||
restent séparées.
|
||||
|
||||
---
|
||||
|
||||
## Vue d'ensemble des changements
|
||||
|
||||
```
|
||||
AVANT
|
||||
control_point.rs
|
||||
refresh_attached_queue_for()
|
||||
→ browse()
|
||||
→ sync_queue(items) ← bloquant 1-5s
|
||||
|
||||
APRÈS
|
||||
control_point.rs
|
||||
do_queue_refresh_work() ← interne, fait le browse + conversion
|
||||
MusicQueue (couche queue)
|
||||
schedule_sync(items, callbacks) ← non-bloquant, retourne immédiatement
|
||||
→ thread "queue-sync-{renderer_id}"
|
||||
→ QueueBackend::sync_queue(items, cancel_token, on_ready)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fichiers à modifier / créer
|
||||
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `pmocontrol/src/errors.rs` | Ajouter variante `SyncCancelled` |
|
||||
| `pmocontrol/src/queue/backend.rs` | Modifier signature `sync_queue()` |
|
||||
| `pmocontrol/src/queue/interne.rs` | Adapter signature `sync_queue()` |
|
||||
| `pmocontrol/src/queue/openhome.rs` | Adapter + points de vérification cancel + on_ready |
|
||||
| `pmocontrol/src/queue/music_queue.rs` | Ajouter champs async + méthode `schedule_sync()` |
|
||||
| `pmocontrol/src/queue/mod.rs` | Exporter `SyncScheduleOutcome` |
|
||||
| `pmocontrol/src/model.rs` | Ajouter événements `QueueReadyToPlay`, `QueueSyncCancelled` |
|
||||
| `pmocontrol/src/sse.rs` | Sérialiser les deux nouveaux événements |
|
||||
| `pmocontrol/src/control_point.rs` | Remplacer les 3 call sites bloquants |
|
||||
|
||||
---
|
||||
|
||||
## Étape 1 — Nouvelle variante d'erreur (`errors.rs`)
|
||||
|
||||
**Fichier**: `pmocontrol/src/errors.rs`
|
||||
|
||||
Ajouter après la ligne 51 (`ControlPoint`) :
|
||||
|
||||
```rust
|
||||
#[error("Queue sync cancelled (superseded by a newer request)")]
|
||||
SyncCancelled,
|
||||
```
|
||||
|
||||
Cette variante est retournée par `sync_queue()` quand le `cancel_token` passe à `true`.
|
||||
Elle est **non-fatale** — le coordinator la traite comme un comportement normal, pas une
|
||||
erreur à logger en `warn!`.
|
||||
|
||||
---
|
||||
|
||||
## Étape 2 — Modifier la signature de `sync_queue()` dans le trait (`backend.rs`)
|
||||
|
||||
**Fichier**: `pmocontrol/src/queue/backend.rs`, ligne 110
|
||||
|
||||
```rust
|
||||
// AVANT
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError>;
|
||||
|
||||
// APRÈS
|
||||
use std::sync::{Arc, atomic::AtomicBool};
|
||||
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError>;
|
||||
```
|
||||
|
||||
**Sémantique des paramètres** :
|
||||
- `cancel_token` : si `true` au moment d'une opération, retourner `Err(SyncCancelled)` immédiatement
|
||||
- `on_ready` : callback one-shot appelé quand la lecture peut démarrer (voir logique ci-dessous)
|
||||
|
||||
---
|
||||
|
||||
## Étape 3 — Adapter `InternalQueue::sync_queue()` (`interne.rs`)
|
||||
|
||||
**Fichier**: `pmocontrol/src/queue/interne.rs`
|
||||
|
||||
Trouver la méthode `sync_queue()` et adapter la signature. Le corps reste identique,
|
||||
avec deux ajouts :
|
||||
|
||||
**1. Vérification du pivot (early start)** : si une piste est en cours de lecture
|
||||
(un `current_index` est défini dans le snapshot courant), appeler `on_ready` immédiatement
|
||||
avant toute opération — la piste courante sera préservée.
|
||||
|
||||
**2. Si pas de pivot** (queue vide ou aucune piste en cours) : appeler `on_ready` après
|
||||
avoir inséré le premier item.
|
||||
|
||||
**3. Vérification cancel** : après chaque item inséré/supprimé (en pratique `InternalQueue`
|
||||
est rapide mais le principe doit être cohérent) :
|
||||
|
||||
```rust
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
// Early start si pivot présent
|
||||
let has_current = self.current_index()?.is_some();
|
||||
if has_current {
|
||||
if let Some(f) = on_ready.take() { f(); }
|
||||
}
|
||||
|
||||
// ... logique existante de sync_queue() ...
|
||||
// Dans la boucle d'insertions, après le 1er insert :
|
||||
if on_ready.is_some() {
|
||||
if let Some(f) = on_ready.take() { f(); }
|
||||
}
|
||||
// Après chaque opération :
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 4 — Adapter `OpenHomeQueue::sync_queue()` (`openhome.rs`)
|
||||
|
||||
**Fichier**: `pmocontrol/src/queue/openhome.rs`
|
||||
|
||||
### 4.1 Signature (ligne ~1277)
|
||||
|
||||
```rust
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError>
|
||||
```
|
||||
|
||||
### 4.2 Early start — logique pivot
|
||||
|
||||
Au début de `sync_queue()`, **avant** toute opération SOAP, détecter si un pivot est présent :
|
||||
|
||||
```rust
|
||||
// Après la récupération du snapshot (ligne ~1323), avant les branches if/else :
|
||||
let has_pivot = playing_info.is_some();
|
||||
if has_pivot {
|
||||
// Le pivot sera préservé — on peut démarrer la lecture immédiatement
|
||||
if let Some(f) = on_ready.take() { f(); }
|
||||
}
|
||||
```
|
||||
|
||||
Si pas de pivot (nouvelle playlist via `delete_all` + inserts depuis 0) : appeler `on_ready`
|
||||
après le **1er insert réussi** dans `replace_queue()` et dans `replace_queue_standard_lcs()`.
|
||||
|
||||
### 4.3 Points de vérification cancel
|
||||
|
||||
Ajouter `if cancel_token.load(SeqCst) { return Err(SyncCancelled); }` aux endroits suivants :
|
||||
|
||||
- Dans `delete_marked_items()` (ligne ~458) : après chaque `delete_id_if_exists()`
|
||||
- Dans `rebuild_playlist_section()` (ligne ~494) : après chaque `insert()`
|
||||
- Dans `replace_queue_preserve_current()` (ligne ~419) : après chaque `delete_id_if_exists()` et `insert()`
|
||||
- Dans `replace_queue_standard_lcs()` (ligne ~693-745) : après chaque delete et insert
|
||||
- Dans `replace_queue()` (ligne ~1118) : après chaque insert dans la boucle
|
||||
- Dans le fast path `AppendOnly` (ligne ~1318) : après chaque insert
|
||||
- Dans le fast path `DeleteFromEnd` (ligne ~1336) : après chaque delete
|
||||
|
||||
### 4.4 Propagation du cancel_token aux helpers
|
||||
|
||||
Les méthodes helper privées qui font des boucles doivent recevoir le token :
|
||||
|
||||
```rust
|
||||
fn delete_marked_items(
|
||||
&mut self,
|
||||
old_ids: &[u32],
|
||||
keep_flags: &[bool],
|
||||
position_label: &str,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
) -> Result<(), ControlPointError>
|
||||
|
||||
fn rebuild_playlist_section(
|
||||
&mut self,
|
||||
// ... params existants ...
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<u32, ControlPointError>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 5 — Adapter `MusicQueue` (dispatch enum) (`music_queue.rs`)
|
||||
|
||||
**Fichier**: `pmocontrol/src/queue/music_queue.rs`
|
||||
|
||||
### 5.1 Adapter le dispatch `sync_queue()` (ligne ~98)
|
||||
|
||||
```rust
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.sync_queue(items, cancel_token, on_ready),
|
||||
MusicQueue::OpenHome(q) => q.sync_queue(items, cancel_token, on_ready),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Ajouter l'état async dans `MusicQueue`
|
||||
|
||||
`MusicQueue` passe de simple enum de dispatch à une struct qui **encapsule** l'enum backend
|
||||
et l'état de synchronisation async :
|
||||
|
||||
```rust
|
||||
// AVANT
|
||||
pub enum MusicQueue {
|
||||
Internal(InternalQueue),
|
||||
OpenHome(OpenHomeQueue),
|
||||
}
|
||||
|
||||
// APRÈS
|
||||
pub struct MusicQueue {
|
||||
backend: MusicQueueBackend,
|
||||
// État async de synchronisation
|
||||
sync_in_progress: Arc<AtomicBool>,
|
||||
sync_pending: Arc<AtomicBool>,
|
||||
sync_cancel_token: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
// L'enum devient privée
|
||||
enum MusicQueueBackend {
|
||||
Internal(InternalQueue),
|
||||
OpenHome(OpenHomeQueue),
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: le changement de `enum` en `struct` implique de mettre à jour toutes les
|
||||
utilisations de `MusicQueue::Internal(...)` et `MusicQueue::OpenHome(...)` dans le reste
|
||||
du code (essentiellement `music_queue.rs` lui-même et `mod.rs`). Les callers externes
|
||||
utilisent `MusicQueue` via `QueueBackend` et `QueueFromRendererInfo` — ils ne sont pas
|
||||
impactés si l'API publique est préservée.
|
||||
|
||||
### 5.3 Enum résultat et méthode `schedule_sync()`
|
||||
|
||||
```rust
|
||||
pub enum SyncScheduleOutcome {
|
||||
/// Thread spawné, sync en cours.
|
||||
Scheduled,
|
||||
/// Sync déjà en cours — annulée et nouvelle sync programmée en pending.
|
||||
AlreadyRunning,
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
impl MusicQueue {
|
||||
/// Lance une synchronisation asynchrone de la queue.
|
||||
///
|
||||
/// - Si aucune sync n'est en cours : spawne un thread, retourne `Scheduled`.
|
||||
/// - Si une sync est en cours : l'annule, note une sync pending, retourne `AlreadyRunning`.
|
||||
/// Le thread en cours finira l'opération courante, détectera le cancel, puis
|
||||
/// relancera la sync avec les nouveaux items via `pending_items_fn`.
|
||||
///
|
||||
/// `pending_items_fn` : closure appelée dans le worker pour re-fetcher les items
|
||||
/// en cas de pending. Elle doit être Send + 'static car elle s'exécute dans un thread.
|
||||
///
|
||||
/// `on_ready` : appelé dès que la lecture peut démarrer (pivot préservé ou 1er insert).
|
||||
///
|
||||
/// `renderer_id` : utilisé uniquement pour nommer le thread de travail.
|
||||
pub fn schedule_sync(
|
||||
&self, // &self car l'état async est derrière Arc<AtomicBool>
|
||||
renderer_id: &str,
|
||||
items: Vec<PlaybackItem>,
|
||||
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||
) -> SyncScheduleOutcome
|
||||
```
|
||||
|
||||
**Problème de `&mut self` vs `&self`** : `sync_queue()` dans le trait prend `&mut self`
|
||||
car les backends mutent leur état. Mais `schedule_sync()` veut spawner un thread qui détient
|
||||
le backend. Solution : le backend est déjà derrière `Arc<Mutex<MusicQueue>>` dans
|
||||
`MusicRenderer` (ligne 124 de musicrenderer.rs — c'est le `queue` field). Le thread worker
|
||||
clone cet `Arc` et acquiert le lock pour appeler `sync_queue()`.
|
||||
|
||||
**Signature révisée** :
|
||||
|
||||
```rust
|
||||
/// Doit être appelé avec un Arc<Mutex<Self>> pour permettre le spawn du thread worker.
|
||||
pub fn schedule_sync(
|
||||
queue_arc: &Arc<Mutex<MusicQueue>>,
|
||||
renderer_id: &str,
|
||||
items: Vec<PlaybackItem>,
|
||||
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||
) -> SyncScheduleOutcome {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
let (sync_in_progress, sync_pending, sync_cancel_token) = {
|
||||
let q = queue_arc.lock().unwrap();
|
||||
(
|
||||
Arc::clone(&q.sync_in_progress),
|
||||
Arc::clone(&q.sync_pending),
|
||||
Arc::clone(&q.sync_cancel_token),
|
||||
)
|
||||
};
|
||||
|
||||
if sync_in_progress.swap(true, SeqCst) {
|
||||
// Sync en cours : annuler et noter pending
|
||||
sync_cancel_token.store(true, SeqCst);
|
||||
sync_pending.store(true, SeqCst);
|
||||
return SyncScheduleOutcome::AlreadyRunning;
|
||||
}
|
||||
|
||||
// Pas de sync en cours : initialiser et spawner
|
||||
sync_cancel_token.store(false, SeqCst);
|
||||
sync_pending.store(false, SeqCst);
|
||||
|
||||
let queue_arc = Arc::clone(queue_arc);
|
||||
let thread_name = format!("queue-sync-{}", renderer_id);
|
||||
|
||||
thread::Builder::new()
|
||||
.name(thread_name)
|
||||
.spawn(move || {
|
||||
// Guard: libère in_progress à la sortie même en cas de panic
|
||||
struct Guard(Arc<AtomicBool>);
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) { self.0.store(false, SeqCst); }
|
||||
}
|
||||
let _guard = Guard(Arc::clone(&sync_in_progress));
|
||||
|
||||
let mut current_items = items;
|
||||
let mut current_on_ready = Some(on_ready);
|
||||
|
||||
loop {
|
||||
sync_pending.store(false, SeqCst);
|
||||
sync_cancel_token.store(false, SeqCst);
|
||||
|
||||
let result = {
|
||||
let mut q = queue_arc.lock().unwrap();
|
||||
q.backend.sync_queue(
|
||||
current_items,
|
||||
&sync_cancel_token,
|
||||
current_on_ready.take().flatten(),
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Err(ControlPointError::SyncCancelled) => {
|
||||
// Annulé normalement — vérifier si pending
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("queue-sync error: {}", e);
|
||||
}
|
||||
Ok(()) => {}
|
||||
}
|
||||
|
||||
if !sync_pending.load(SeqCst) {
|
||||
break; // Pas de nouvelle sync en attente → terminer
|
||||
}
|
||||
|
||||
// Nouvelle sync demandée pendant l'exécution → re-fetcher et relancer
|
||||
match pending_items_fn() {
|
||||
Ok(new_items) => {
|
||||
current_items = new_items;
|
||||
current_on_ready = Some(None); // pas de on_ready pour les re-syncs
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("queue-sync pending re-fetch error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// _guard libère sync_in_progress = false
|
||||
})
|
||||
.expect("Failed to spawn queue-sync thread");
|
||||
|
||||
SyncScheduleOutcome::Scheduled
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 6 — Nouveaux événements SSE
|
||||
|
||||
### 6.1 `model.rs`
|
||||
|
||||
Localiser l'enum `RendererEvent` et ajouter :
|
||||
|
||||
```rust
|
||||
/// Émis dès que la queue peut être lue (pivot préservé ou 1er track inséré).
|
||||
QueueReadyToPlay {
|
||||
id: DeviceId,
|
||||
},
|
||||
/// Émis quand une sync est annulée car une nouvelle a été demandée.
|
||||
QueueSyncCancelled {
|
||||
id: DeviceId,
|
||||
},
|
||||
```
|
||||
|
||||
### 6.2 `sse.rs`
|
||||
|
||||
Localiser le match de sérialisation des `RendererEvent` et ajouter les deux variantes.
|
||||
Suivre le pattern existant de `QueueRefreshing` (ligne ~152) :
|
||||
|
||||
```rust
|
||||
RendererEvent::QueueReadyToPlay { id } => {
|
||||
// sérialiser avec type = "queue_ready_to_play"
|
||||
}
|
||||
RendererEvent::QueueSyncCancelled { id } => {
|
||||
// sérialiser avec type = "queue_sync_cancelled"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 7 — Modifier `control_point.rs`
|
||||
|
||||
### 7.1 Extraire la logique de browse
|
||||
|
||||
Renommer `refresh_attached_queue_for()` en deux fonctions :
|
||||
|
||||
**`fetch_queue_items_for()`** (nouvelle, interne) : fait le browse + conversion, retourne
|
||||
`Vec<PlaybackItem>`. C'est la `pending_items_fn` passée à `schedule_sync()`.
|
||||
|
||||
**`schedule_queue_refresh_for()`** (remplace l'ancienne) : appelle `fetch_queue_items_for()`,
|
||||
puis `MusicQueue::schedule_sync()`.
|
||||
|
||||
```rust
|
||||
fn fetch_queue_items_for(
|
||||
registry: &Arc<RwLock<DeviceRegistry>>,
|
||||
renderer_id: &DeviceId,
|
||||
) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||
// Browse media server + conversion PlaybackItem
|
||||
// (logique actuellement dans refresh_attached_queue_for() lignes ~1593-1670)
|
||||
}
|
||||
|
||||
fn schedule_queue_refresh_for(
|
||||
registry: &Arc<RwLock<DeviceRegistry>>,
|
||||
renderer_id: &DeviceId,
|
||||
event_bus: &RendererEventBus,
|
||||
auto_play_cb: Option<Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>>,
|
||||
) -> SyncScheduleOutcome {
|
||||
let items = match fetch_queue_items_for(registry, renderer_id) {
|
||||
Ok(items) => items,
|
||||
Err(e) => { warn!(...); return SyncScheduleOutcome::Scheduled; /* ou erreur */ }
|
||||
};
|
||||
|
||||
// on_ready : déclenche auto_play si demandé + émet QueueReadyToPlay SSE
|
||||
let rid = renderer_id.clone();
|
||||
let bus = event_bus.clone();
|
||||
let on_ready: Option<Box<dyn FnOnce() + Send + 'static>> = Some(Box::new(move || {
|
||||
bus.broadcast(RendererEvent::QueueReadyToPlay { id: rid.clone() });
|
||||
if let Some(cb) = auto_play_cb {
|
||||
if let Err(e) = cb(&rid) {
|
||||
warn!("auto-play callback failed: {}", e);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// pending_items_fn : re-fetcher depuis le media server si pending
|
||||
let registry2 = Arc::clone(registry);
|
||||
let rid2 = renderer_id.clone();
|
||||
let pending_fn = Box::new(move || fetch_queue_items_for(®istry2, &rid2));
|
||||
|
||||
// Récupérer l'Arc<Mutex<MusicQueue>> du renderer depuis le registry
|
||||
let queue_arc = {
|
||||
let reg = registry.read().unwrap();
|
||||
reg.get_renderer_queue_arc(renderer_id)? // méthode à ajouter dans DeviceRegistry
|
||||
};
|
||||
|
||||
// Émettre QueueRefreshing avant de lancer
|
||||
event_bus.broadcast(RendererEvent::QueueRefreshing { id: renderer_id.clone() });
|
||||
|
||||
let outcome = MusicQueue::schedule_sync(&queue_arc, &renderer_id.0, items, pending_fn, on_ready);
|
||||
|
||||
if matches!(outcome, SyncScheduleOutcome::AlreadyRunning) {
|
||||
event_bus.broadcast(RendererEvent::QueueSyncCancelled { id: renderer_id.clone() });
|
||||
}
|
||||
|
||||
outcome
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Call site 1 — thread "cp-media-server-event-worker" (l.270)
|
||||
|
||||
```rust
|
||||
// AVANT
|
||||
let _ = refresh_attached_queue_for(®istry, &renderer_id, &event_bus, None);
|
||||
// APRÈS
|
||||
schedule_queue_refresh_for(®istry, &renderer_id, &event_bus, None);
|
||||
```
|
||||
|
||||
### 7.3 Call site 2 — thread "cp-playlist-periodic-refresh" (l.340)
|
||||
|
||||
```rust
|
||||
// AVANT
|
||||
let _ = refresh_attached_queue_for(®istry_for_periodic, &renderer_id, &event_bus_for_periodic, None);
|
||||
// APRÈS
|
||||
schedule_queue_refresh_for(®istry_for_periodic, &renderer_id, &event_bus_for_periodic, None);
|
||||
```
|
||||
|
||||
### 7.4 Call site 3 — `attach_queue_to_playlist_async()` (l.1264)
|
||||
|
||||
```rust
|
||||
pub async fn attach_queue_to_playlist_async(
|
||||
&self, renderer_id: &DeviceId, server_id: &DeviceId, container_id: &str, auto_play: bool,
|
||||
) -> Result<(), ControlPointError> {
|
||||
// 1. Enregistrer la liaison (synchrone, ~1ms)
|
||||
self.registry.write().unwrap()
|
||||
.set_playlist_binding(renderer_id, server_id, container_id);
|
||||
|
||||
// 2. Construire le callback auto-play si demandé
|
||||
let cp = self.clone();
|
||||
let rid = renderer_id.clone();
|
||||
let auto_play_cb: Option<Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>> =
|
||||
if auto_play {
|
||||
Some(Box::new(move |id| cp.play_current_from_queue(id)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 3. Lancer le refresh async — retourne immédiatement
|
||||
schedule_queue_refresh_for(&self.registry, renderer_id, &self.event_bus, auto_play_cb);
|
||||
|
||||
Ok(()) // La webapp sera notifiée via SSE (QueueRefreshing → QueueReadyToPlay → QueueUpdated)
|
||||
}
|
||||
```
|
||||
|
||||
### 7.5 Fin de sync — émettre `QueueUpdated`
|
||||
|
||||
L'événement `QueueUpdated` (avec `queue_length`) est actuellement émis à la ligne ~1718
|
||||
dans `refresh_attached_queue_for()`. Il doit être émis à la fin du worker thread dans
|
||||
`MusicQueue::schedule_sync()`, après le `sync_queue()` réussi.
|
||||
|
||||
Passer un `on_complete` callback à `schedule_sync()` (en plus de `on_ready`) :
|
||||
|
||||
```rust
|
||||
pub fn schedule_sync(
|
||||
queue_arc: &Arc<Mutex<MusicQueue>>,
|
||||
renderer_id: &str,
|
||||
items: Vec<PlaybackItem>,
|
||||
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||
on_complete: Box<dyn Fn(usize) + Send + 'static>, // NOUVEAU — reçoit queue_length
|
||||
) -> SyncScheduleOutcome
|
||||
```
|
||||
|
||||
Dans le worker, après `Ok(())` du `sync_queue()` :
|
||||
|
||||
```rust
|
||||
Ok(()) => {
|
||||
let queue_len = queue_arc.lock().unwrap().len().unwrap_or(0);
|
||||
on_complete(queue_len);
|
||||
}
|
||||
```
|
||||
|
||||
Dans `schedule_queue_refresh_for()` :
|
||||
|
||||
```rust
|
||||
let bus3 = event_bus.clone();
|
||||
let rid3 = renderer_id.clone();
|
||||
let on_complete = Box::new(move |queue_len: usize| {
|
||||
bus3.broadcast(RendererEvent::QueueUpdated {
|
||||
id: rid3.clone(),
|
||||
queue_length: queue_len,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 8 — Accès à `Arc<Mutex<MusicQueue>>` depuis le registry
|
||||
|
||||
`schedule_queue_refresh_for()` a besoin d'accéder à l'`Arc<Mutex<MusicQueue>>` du renderer.
|
||||
Localiser dans `DeviceRegistry` comment les renderers et leurs queues sont stockés, et ajouter
|
||||
une méthode :
|
||||
|
||||
```rust
|
||||
pub fn get_renderer_queue_arc(
|
||||
&self,
|
||||
renderer_id: &DeviceId,
|
||||
) -> Result<Arc<Mutex<MusicQueue>>, ControlPointError>
|
||||
```
|
||||
|
||||
(Ou équivalent selon la structure réelle du registry.)
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'implémentation
|
||||
|
||||
```
|
||||
Étape 1 (errors.rs) ← 5 min
|
||||
Étape 2 (backend.rs) ← 10 min, casse la compilation → à faire avant les autres
|
||||
Étape 3 (interne.rs) ← 30 min
|
||||
Étape 4 (openhome.rs) ← 1-2h (nombreux points de vérification cancel)
|
||||
Étape 5 (music_queue.rs) ← 2-3h (changement struct + schedule_sync)
|
||||
Étape 6 (model.rs + sse.rs) ← 30 min
|
||||
Étape 7 (control_point.rs) ← 1h
|
||||
Étape 8 (registry) ← 30 min selon la structure
|
||||
```
|
||||
|
||||
Après l'étape 2, `cargo build` cassera jusqu'à l'étape 5 incluse — c'est attendu.
|
||||
Faire les étapes 3, 4, 5 dans la même session sans interrompre.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cargo build -p pmocontrol
|
||||
|
||||
# Vérifier les scénarios :
|
||||
# 1. Attach playlist → QueueRefreshing SSE immédiat, QueueReadyToPlay après 1er insert,
|
||||
# QueueUpdated après fin complète
|
||||
# 2. Attach 2e playlist pendant sync en cours → QueueSyncCancelled + nouveau refresh repart
|
||||
# 3. Piste en cours de lecture pendant sync → QueueReadyToPlay immédiat (pivot préservé)
|
||||
# 4. Refresh périodique (60s) ne bloque plus son thread
|
||||
|
||||
RUST_LOG=debug cargo run 2>&1 | grep -E "queue.sync|SyncCancelled|Scheduled|AlreadyRunning|on_ready|on_complete"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Date: 2026-04-09*
|
||||
306
Blackboard/Todo/mdns_sd_migration.md
Normal file
306
Blackboard/Todo/mdns_sd_migration.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# Migration `mdns` → `mdns-sd`
|
||||
|
||||
## Contexte
|
||||
|
||||
La crate `mdns 3.0.0` (dernière version, 2021, abandonnée) s'appuie sur `dns-parser 0.8.0`
|
||||
qui rejette les labels DNS non-ASCII avec `LabelIsNotAscii`. Les appareils Apple utilisent
|
||||
U+2019 (RIGHT SINGLE QUOTATION MARK) dans leurs noms (ex : "Sophie's MacBook Air"), ce qui
|
||||
spamme les logs en WARN à chaque paquet mDNS reçu.
|
||||
|
||||
`mdns-sd 0.19` (avril 2026, activement maintenu) gère l'UTF-8 correctement, fournit une API
|
||||
de plus haut niveau (service pré-assemblé), et ne nécessite pas `async-std`.
|
||||
|
||||
## Périmètre
|
||||
|
||||
Deux fichiers à modifier, un fichier à nettoyer :
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `pmocontrol/Cargo.toml` | Dépendances |
|
||||
| `pmocontrol/src/control_point.rs` | Thread de découverte mDNS (lignes 145–195) |
|
||||
| `pmocontrol/src/discovery/chromecast_discovery.rs` | Parsing des réponses mDNS |
|
||||
| `pmoserver/src/logs/mod.rs` | Filtre de bruit `mdns=error` devenu inutile |
|
||||
|
||||
---
|
||||
|
||||
## Étape 1 — `pmocontrol/Cargo.toml`
|
||||
|
||||
### Supprimer
|
||||
```toml
|
||||
mdns = "3.0"
|
||||
```
|
||||
|
||||
Vérifier si `async-std` et `futures-util` sont utilisés **ailleurs** que dans le thread mDNS.
|
||||
D'après l'analyse :
|
||||
- `async_std` : uniquement `control_point.rs:161` → **supprimer**
|
||||
- `futures-util` : uniquement `control_point.rs:150,167` → **supprimer**
|
||||
|
||||
### Ajouter
|
||||
```toml
|
||||
mdns-sd = "0.19"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 2 — `pmocontrol/src/control_point.rs`
|
||||
|
||||
### Code actuel (lignes 145–195) à remplacer intégralement
|
||||
|
||||
```rust
|
||||
// Thread de découverte mDNS pour Chromecast
|
||||
let registry_for_mdns = Arc::clone(®istry);
|
||||
let udn_cache_for_mdns = Arc::clone(&udn_cache);
|
||||
thread::spawn(move || {
|
||||
use crate::discovery::ChromecastDiscoveryManager;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let mut discovery_manager =
|
||||
ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns);
|
||||
|
||||
debug!("Starting mDNS discovery thread for Chromecast devices");
|
||||
|
||||
const SERVICE_NAME: &str = "_googlecast._tcp.local";
|
||||
|
||||
async_std::task::block_on(async {
|
||||
match mdns::discover::all(SERVICE_NAME, Duration::from_secs(15)) {
|
||||
Ok(discovery) => {
|
||||
let stream = discovery.listen();
|
||||
futures_util::pin_mut!(stream);
|
||||
debug!("mDNS discovery stream started for Chromecast devices");
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(response) => {
|
||||
debug!("Received mDNS response with {} records", response.records().count());
|
||||
discovery_manager.handle_mdns_response(response);
|
||||
}
|
||||
Err(e) => { warn!("mDNS discovery error: {}", e); }
|
||||
}
|
||||
}
|
||||
warn!("mDNS discovery stream ended unexpectedly");
|
||||
}
|
||||
Err(e) => { error!("Failed to start mDNS discovery: {}", e); }
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Nouveau code
|
||||
|
||||
```rust
|
||||
// Thread de découverte mDNS pour Chromecast
|
||||
let registry_for_mdns = Arc::clone(®istry);
|
||||
let udn_cache_for_mdns = Arc::clone(&udn_cache);
|
||||
thread::spawn(move || {
|
||||
use crate::discovery::ChromecastDiscoveryManager;
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
|
||||
let mut discovery_manager =
|
||||
ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns);
|
||||
|
||||
debug!("Starting mDNS discovery thread for Chromecast devices");
|
||||
|
||||
// Note: mdns-sd requires the trailing dot in the service type
|
||||
const SERVICE_TYPE: &str = "_googlecast._tcp.local.";
|
||||
|
||||
let daemon = match ServiceDaemon::new() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
error!("Failed to create mDNS daemon: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let receiver = match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Failed to start mDNS browse for {}: {}", SERVICE_TYPE, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
debug!("mDNS discovery started for Chromecast devices");
|
||||
|
||||
while let Ok(event) = receiver.recv() {
|
||||
match event {
|
||||
ServiceEvent::ServiceResolved(info) => {
|
||||
debug!(
|
||||
fullname = info.get_fullname(),
|
||||
host = info.get_hostname(),
|
||||
port = info.get_port(),
|
||||
"mDNS Chromecast service resolved"
|
||||
);
|
||||
discovery_manager.handle_service_resolved(&info);
|
||||
}
|
||||
ServiceEvent::ServiceRemoved(_service_type, fullname) => {
|
||||
debug!(fullname = %fullname, "mDNS Chromecast service removed");
|
||||
// Pas de retrait actif du registre : le timeout habituel s'en charge
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("mDNS discovery receiver closed");
|
||||
});
|
||||
```
|
||||
|
||||
### Points d'attention
|
||||
- Le point final `.` dans `"_googlecast._tcp.local."` est **obligatoire** pour `mdns-sd`.
|
||||
- `receiver.recv()` est bloquant synchrone — pas besoin d'async runtime.
|
||||
- `ServiceDaemon` gère son propre thread interne ; inutile de relancer manuellement.
|
||||
|
||||
---
|
||||
|
||||
## Étape 3 — `pmocontrol/src/discovery/chromecast_discovery.rs`
|
||||
|
||||
### Supprimer l'import `mdns`
|
||||
|
||||
```rust
|
||||
// Supprimer ces uses implicites via le type dans la signature
|
||||
use std::collections::HashMap; // <- plus nécessaire si on passe par TxtProperties
|
||||
```
|
||||
|
||||
### Ajouter l'import `mdns-sd`
|
||||
|
||||
```rust
|
||||
use mdns_sd::ServiceInfo;
|
||||
```
|
||||
|
||||
### Remplacer `handle_mdns_response` par `handle_service_resolved`
|
||||
|
||||
#### Code actuel (lignes 39–179) — ~80 lignes de parsing manuel
|
||||
|
||||
Toute la logique d'extraction PTR / A / AAAA / SRV / TXT disparaît.
|
||||
|
||||
#### Nouveau code
|
||||
|
||||
```rust
|
||||
/// Traite un service Chromecast résolu par mDNS-SD.
|
||||
///
|
||||
/// `ServiceInfo` arrive pré-assemblé : plus besoin de jointure manuelle
|
||||
/// des enregistrements PTR / A / SRV / TXT.
|
||||
pub fn handle_service_resolved(&mut self, info: &ServiceInfo) {
|
||||
let fullname = info.get_fullname().to_string();
|
||||
|
||||
debug!("Processing resolved Chromecast service: {}", fullname);
|
||||
|
||||
// Adresse IP : préférer IPv4
|
||||
let host = match info
|
||||
.get_addresses()
|
||||
.iter()
|
||||
.find(|a| a.is_ipv4())
|
||||
.or_else(|| info.get_addresses().iter().next())
|
||||
{
|
||||
Some(addr) => addr.to_string(),
|
||||
None => {
|
||||
warn!("No IP address for Chromecast service: {}", fullname);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let port = info.get_port();
|
||||
|
||||
// TXT records — API directe par clé
|
||||
let uuid = info
|
||||
.get_property_val_str("id")
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let uuid = if uuid.is_empty() {
|
||||
format!("chromecast-{}-{}", host, port)
|
||||
} else {
|
||||
uuid
|
||||
};
|
||||
|
||||
let model = info.get_property_val_str("md").map(|s| s.to_string());
|
||||
|
||||
let friendly_name = info
|
||||
.get_property_val_str("fn")
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
// Fallback : extraire depuis le fullname, supprimer le suffixe de service
|
||||
fullname
|
||||
.split("._googlecast._tcp.local")
|
||||
.next()
|
||||
.unwrap_or("Unknown Chromecast")
|
||||
.split('-')
|
||||
.take_while(|part| part.len() != 32)
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
.trim()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
debug!(
|
||||
"Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})",
|
||||
friendly_name, host, port, uuid, model
|
||||
);
|
||||
|
||||
let udn = format!("uuid:{}", uuid);
|
||||
let default_max_age = 1800u64;
|
||||
|
||||
if !UDNRegistry::should_fetch(self.udn_cache.clone(), &udn, default_max_age) {
|
||||
debug!("Chromecast {} recently seen, skipping", udn);
|
||||
return;
|
||||
}
|
||||
|
||||
let renderer_info = build_renderer_info(
|
||||
&uuid,
|
||||
&friendly_name,
|
||||
&host,
|
||||
port,
|
||||
model.as_deref(),
|
||||
Some("Google Inc."),
|
||||
);
|
||||
|
||||
self.device_registry
|
||||
.write()
|
||||
.expect("DeviceRegistry mutex lock failed")
|
||||
.push_renderer(&renderer_info, default_max_age as u32);
|
||||
}
|
||||
```
|
||||
|
||||
### Supprimer
|
||||
- L'import `use std::collections::HashMap` (plus utilisé)
|
||||
- Tout le bloc `handle_mdns_response` (lignes 39–179)
|
||||
|
||||
### Conserver sans modification
|
||||
- `build_renderer_info` (lignes 182–234)
|
||||
- `extract_host_from_location` / `extract_port_from_location` (lignes 239–258)
|
||||
- Les tests (lignes 260–287)
|
||||
|
||||
---
|
||||
|
||||
## Étape 4 — `pmoserver/src/logs/mod.rs`
|
||||
|
||||
Le filtre de bruit `mdns=error` injecté dans `build_filter_with_noise_suppressions` n'est
|
||||
plus nécessaire. Deux options :
|
||||
|
||||
**Option A (recommandée)** — Supprimer l'entrée du tableau :
|
||||
```rust
|
||||
const NOISE_FILTERS: &[(&str, &str)] = &[
|
||||
// ("mdns", "mdns=error"), // supprimé : migration vers mdns-sd
|
||||
];
|
||||
```
|
||||
Ou supprimer `build_filter_with_noise_suppressions` entièrement si aucun autre bruit n'est
|
||||
à filtrer, et revenir à `EnvFilter::try_new(base)` direct.
|
||||
|
||||
**Option B** — Laisser en place. La directive `mdns=error` ne cause aucun dommage si la
|
||||
crate `mdns` n'est plus dans le build (elle sera simplement ignorée).
|
||||
|
||||
---
|
||||
|
||||
## Résumé des diffs
|
||||
|
||||
| Fichier | Lignes supprimées | Lignes ajoutées |
|
||||
|---|---|---|
|
||||
| `Cargo.toml` | `mdns`, `async-std`, `futures-util` | `mdns-sd` |
|
||||
| `control_point.rs` | ~50 (async block) | ~35 (sync recv loop) |
|
||||
| `chromecast_discovery.rs` | ~80 (parsing manuel) | ~50 (lecture ServiceInfo) |
|
||||
| `logs/mod.rs` | ~5 (filtre bruit) | 0 |
|
||||
|
||||
## Vérification
|
||||
|
||||
Après implémentation :
|
||||
1. `cargo check -p pmocontrol` sans erreurs ni `use of undeclared crate mdns`
|
||||
2. `cargo check -p pmoserver` sans erreurs
|
||||
3. Tester la découverte d'un Chromecast en réseau local
|
||||
4. Vérifier l'absence de `LabelIsNotAscii` dans les logs avec des appareils Apple présents
|
||||
323
Blackboard/Todo/refactoring_pmocontrol.md
Normal file
323
Blackboard/Todo/refactoring_pmocontrol.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# Refactoring Plan - Crate `pmocontrol`
|
||||
|
||||
## Contexte
|
||||
|
||||
La crate `pmocontrol` implémente un control point UPnP multiprotocole pour contrôler des renderers audio (UPnP/DLNA, OpenHome, LinkPlay, Arylic TCP, Chromecast). Une refactorisation récente avait pour objectif de monter la logique vers les couches abstraites, mais des duplications et des problèmes de conception subsistent.
|
||||
|
||||
---
|
||||
|
||||
## Structure analysée
|
||||
|
||||
- `music_renderer/` : Implémentations concrètes + façade `MusicRenderer`
|
||||
- `queue/` : Gestion abstraite et concrète des files de lecture
|
||||
- `discovery/` : Découverte SSDP et gestion des appareils
|
||||
- `upnp_clients/` : Clients SOAP pour services UPnP
|
||||
- `control_point.rs` : Point de contrôle principal
|
||||
|
||||
Backends : `UpnpRenderer`, `OpenHomeRenderer`, `LinkPlayRenderer`, `ArylicTcpRenderer`, `ChromecastRenderer`, `HybridUpnpArylicRenderer`
|
||||
|
||||
---
|
||||
|
||||
## CATÉGORIE P0 : BUGS LOGIQUES (à corriger immédiatement)
|
||||
|
||||
### BUG-1 : `sync_queue` dans UpnpRenderer ignore le cancel_token
|
||||
|
||||
**Fichier :** `src/music_renderer/upnp_renderer.rs` (lignes ~354-364)
|
||||
|
||||
**Description :** Le paramètre `cancel_token` est reçu comme `_cancel_token` (ignoré) et remplacé par un `Arc::new(AtomicBool::new(false))` fraîchement créé. Les demandes d'annulation de synchronisation de queue sont silencieusement ignorées pour le backend UPnP.
|
||||
|
||||
**Correction :**
|
||||
```rust
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>, // utiliser le param, pas _cancel_token
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_queue(items, cancel_token, on_ready) // passer le vrai token
|
||||
}
|
||||
```
|
||||
|
||||
**Tâche :** Vérifier également les autres backends (OpenHome, LinkPlay, Arylic, Chromecast) s'ils propagent correctement le cancel_token.
|
||||
|
||||
---
|
||||
|
||||
## CATÉGORIE P1 : DUPLICATIONS MAJEURES (à traiter en priorité)
|
||||
|
||||
### DUP-1 : Implémentation de `QueueBackend` répétée dans les 5+ renderers
|
||||
|
||||
**Fichiers :**
|
||||
- `src/music_renderer/upnp_renderer.rs` (~310-381)
|
||||
- `src/music_renderer/arylic_tcp.rs` (~368-450)
|
||||
- `src/music_renderer/linkplay_renderer.rs` (~246-330)
|
||||
- `src/music_renderer/chromecast_renderer.rs` (~866+)
|
||||
- `src/music_renderer/openhome_renderer.rs` (~629+)
|
||||
|
||||
**Description :** Chaque renderer implémente `QueueBackend` de manière identique : chaque méthode verrouille `self.queue` et délègue à la file sous-jacente. ~150+ lignes de boilerplate.
|
||||
|
||||
**Approche recommandée — Trait délégateur :**
|
||||
```rust
|
||||
// Dans queue/mod.rs ou music_renderer/mod.rs
|
||||
pub trait HasQueue {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>>;
|
||||
}
|
||||
|
||||
// Impl automatique pour QueueBackend si le type implémente HasQueue
|
||||
impl<T: HasQueue> QueueBackend for T {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
self.queue().lock().unwrap().len()
|
||||
}
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
self.queue().lock().unwrap().track_ids()
|
||||
}
|
||||
// ... toutes les méthodes déléguantes
|
||||
}
|
||||
|
||||
// Dans chaque renderer : une seule ligne
|
||||
impl HasQueue for UpnpRenderer {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> { &self.queue }
|
||||
}
|
||||
```
|
||||
|
||||
**Tâche :** Définir le trait `HasQueue`, implémenter `QueueBackend for T where T: HasQueue`, supprimer les implémentations manuelles dans chaque renderer.
|
||||
|
||||
---
|
||||
|
||||
### DUP-2 : Logique commune de `play_from_queue` dupliquée dans 4+ renderers
|
||||
|
||||
**Fichiers :**
|
||||
- `src/music_renderer/upnp_renderer.rs` (~184-256)
|
||||
- `src/music_renderer/linkplay_renderer.rs` (~189-212)
|
||||
- `src/music_renderer/arylic_tcp.rs` (~311-334)
|
||||
- `src/music_renderer/openhome_renderer.rs` (~partie similaire)
|
||||
|
||||
**Description :** Les 10-12 premières lignes de `play_from_queue` sont identiques dans tous les renderers : verrouillage de queue, gestion de l'index courant, fallback sur index 0 si non défini, récupération de l'item. Seule la partie terminale (play effectif sur le backend) diffère.
|
||||
|
||||
**Approche recommandée — Méthode par défaut dans un trait :**
|
||||
```rust
|
||||
pub trait QueueTransportControl: HasQueue + HasContinuousStream {
|
||||
// Primitive spécifique au backend
|
||||
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError>;
|
||||
|
||||
// Implémentation commune par défaut
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue().lock().unwrap();
|
||||
let current_index = match queue.current_index()? {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
if queue.len()? > 0 {
|
||||
queue.set_index(Some(0))?;
|
||||
0
|
||||
} else {
|
||||
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
let item = queue.get_item(current_index)?
|
||||
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
||||
drop(queue);
|
||||
|
||||
let is_stream = is_continuous_stream_url(&item.uri);
|
||||
*self.continuous_stream().lock().unwrap() = is_stream;
|
||||
self.play_item(&item)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Tâche :** Créer `QueueTransportControl` avec une méthode par défaut, implémenter `play_item` dans chaque renderer, supprimer la logique commune dupliquée.
|
||||
|
||||
---
|
||||
|
||||
### DUP-3 : Initialisation redondante des champs partagés dans tous les renderers
|
||||
|
||||
**Fichiers :** Constructeurs dans tous les fichiers renderer
|
||||
|
||||
**Description :** Chaque renderer répète la même construction :
|
||||
```rust
|
||||
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
|
||||
// ...
|
||||
continuous_stream: Arc::new(Mutex::new(false)),
|
||||
```
|
||||
|
||||
**Approche recommandée :**
|
||||
```rust
|
||||
pub struct SharedRendererState {
|
||||
pub queue: Arc<Mutex<MusicQueue>>,
|
||||
pub continuous_stream: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl SharedRendererState {
|
||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
Ok(Self {
|
||||
queue: Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?)),
|
||||
continuous_stream: Arc::new(Mutex::new(false)),
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Tâche :** Créer `SharedRendererState`, l'utiliser dans tous les constructeurs de renderers.
|
||||
|
||||
---
|
||||
|
||||
### DUP-4 : `parse_didl_duration` implémentée deux fois différemment
|
||||
|
||||
**Fichiers :**
|
||||
- `src/music_renderer/upnp_renderer.rs` (~383-420) : parsing manuel par string search (fragile)
|
||||
- `src/music_renderer/musicrenderer.rs` (~2089-2117) : via parser DIDL-Lite structuré (robuste)
|
||||
|
||||
**Description :** Deux implémentations divergentes. L'une risque de mal parser du DIDL là où l'autre réussit.
|
||||
|
||||
**Tâche :** Conserver uniquement la version via `DIDLLite::parse`, l'exporter depuis `music_renderer/mod.rs`, supprimer la version par string search dans `upnp_renderer.rs`.
|
||||
|
||||
---
|
||||
|
||||
## CATÉGORIE P2 : ALGORITHMES COMPLEXES ET ABSTRACTIONS MAL PLACÉES
|
||||
|
||||
### ALGO-1 : `schedule_sync` dans `music_queue.rs` — logique intriquée
|
||||
|
||||
**Fichier :** `src/queue/music_queue.rs` (~97-200)
|
||||
|
||||
**Description :** La méthode crée un thread worker avec :
|
||||
- Des `AtomicBool` pour synchronisation (sync_in_progress, sync_pending, sync_cancel_token)
|
||||
- Une boucle infinie interne qui re-tente si un nouveau job arrive
|
||||
- Un Guard RAII basé sur `Drop` pour le cleanup
|
||||
- Des closures capturées mêlant synchronisation et logique métier
|
||||
|
||||
Difficile à tester, à observer de l'extérieur, pas de timeout.
|
||||
|
||||
**Tâche :**
|
||||
1. Extraire la logique du worker dans une fonction `sync_worker_loop` avec signature claire
|
||||
2. Documenter le protocole de synchronisation avec les AtomicBool
|
||||
3. Ajouter une stratégie de timeout ou de sortie en cas de blocage
|
||||
|
||||
---
|
||||
|
||||
### ALGO-2 : Enum dispatch sprawl dans `MusicRendererBackend`
|
||||
|
||||
**Fichier :** `src/music_renderer/musicrenderer.rs` (~2134-2495)
|
||||
|
||||
**Description :** L'enum a 6 variantes. Chaque trait implémenté pour l'enum (`TransportControl`, `PlaybackStatus`, `PlaybackPosition`, `RendererBackend`, `QueueBackend`, etc.) contient un `match` sur les 6 variantes. Estimation : 200+ lignes de boilerplate purement mécanique. Ajouter une 7e variante requiert des mises à jour dans 25+ endroits.
|
||||
|
||||
**Approche recommandée — Macro de dispatch :**
|
||||
```rust
|
||||
macro_rules! dispatch {
|
||||
($self:expr, $method:ident($($arg:expr),*)) => {
|
||||
match $self {
|
||||
MusicRendererBackend::Upnp(b) => b.$method($($arg),*),
|
||||
MusicRendererBackend::OpenHome(b) => b.$method($($arg),*),
|
||||
MusicRendererBackend::LinkPlay(b) => b.$method($($arg),*),
|
||||
MusicRendererBackend::ArylicTcp(b) => b.$method($($arg),*),
|
||||
MusicRendererBackend::Chromecast(b) => b.$method($($arg),*),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.$method($($arg),*),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for MusicRendererBackend {
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||
dispatch!(self, play_uri(uri, meta))
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Tâche :** Définir la macro `dispatch!`, remplacer les match statements redondants, valider les cas où HybridUpnpArylic a une logique spéciale.
|
||||
|
||||
---
|
||||
|
||||
### ALGO-3 : Logique de protection des durées de streams dupliquée dans 3 endroits
|
||||
|
||||
**Fichiers :**
|
||||
- `src/queue/interne.rs` (~74-126) : `protect_stream_durations`
|
||||
- `src/queue/openhome.rs` (~400+) : logique similaire pour playlists OpenHome
|
||||
- `src/music_renderer/musicrenderer.rs` (~488-537) : dans `poll_and_emit_changes`
|
||||
|
||||
**Description :** La logique "refuser la diminution de durée pour un stream continu" est réimplémentée trois fois. Si la définition de "diminution acceptable" change, il faut modifier 3 fichiers.
|
||||
|
||||
**Tâche :** Créer `music_renderer/stream_utils.rs` (ou équivalent) avec une fonction `protect_stream_duration(old, new, is_stream) -> Option<String>` et l'utiliser dans les 3 endroits.
|
||||
|
||||
---
|
||||
|
||||
### ALGO-4 : Détection de flux continu fragmentée
|
||||
|
||||
**Fichiers :** `stream_detection.rs`, `musicrenderer.rs`, `queue/interne.rs`, `queue/openhome.rs`
|
||||
|
||||
**Description :** La détection "est-ce un stream continu?" passe par plusieurs chemins non unifiés :
|
||||
1. `TrackMetadata::is_continuous_stream`
|
||||
2. Appel `is_continuous_stream_url(uri)` (réseau)
|
||||
3. Absence de durée dans les métadonnées
|
||||
|
||||
Un stream peut être marqué continu dans une couche mais pas l'autre.
|
||||
|
||||
**Tâche :** Créer une fonction canonique unique :
|
||||
```rust
|
||||
pub fn is_continuous_stream(metadata: Option<&TrackMetadata>, uri: &str) -> bool {
|
||||
metadata.map(|m| m.is_continuous_stream).unwrap_or(false)
|
||||
|| is_continuous_stream_url(uri)
|
||||
}
|
||||
```
|
||||
Faire passer tous les codepaths par cette fonction.
|
||||
|
||||
---
|
||||
|
||||
## CATÉGORIE P3 : BONNES PRATIQUES (amélioration continue)
|
||||
|
||||
### BP-1 : `.unwrap()` sur mutex locks (>50 occurrences)
|
||||
|
||||
**Problème :** Si un mutex est empoisonné (panique dans une autre tâche), `.unwrap()` propage la panique. Aucun code ne gère ce cas.
|
||||
|
||||
**Tâche :** Remplacer `.unwrap()` par `.expect("message contextuel")` à court terme. À long terme, envisager `parking_lot::Mutex` (pas de concept de poison).
|
||||
|
||||
---
|
||||
|
||||
### BP-2 : Absence de gestion d'erreur dans les threads watcher et sync
|
||||
|
||||
**Fichiers :** `musicrenderer.rs` (watcher_loop), `music_queue.rs` (schedule_sync)
|
||||
|
||||
**Tâche :** Ajouter `error!` logs dans les threads et décider explicitement de la politique de redémarrage (continuer vs arrêter).
|
||||
|
||||
---
|
||||
|
||||
### BP-3 : Champs `pub` au lieu de `pub(crate)` dans `PlaylistBinding`
|
||||
|
||||
**Fichier :** `src/music_renderer/musicrenderer.rs` (struct `PlaylistBinding`)
|
||||
|
||||
**Tâche :** Rendre les champs `pub` → `pub(crate)` ou privés avec accesseurs.
|
||||
|
||||
---
|
||||
|
||||
### BP-4 : Documentation manquante sur les contrats des traits
|
||||
|
||||
**Fichiers :** `src/music_renderer/capabilities.rs`, `src/queue/backend.rs`
|
||||
|
||||
**Tâche :** Ajouter des doc-comments sur les traits clés (`TransportControl`, `PlaybackStatus`, `QueueBackend`) décrivant les invariants, les pré/post-conditions, et le comportement attendu.
|
||||
|
||||
---
|
||||
|
||||
## PLAN D'EXÉCUTION
|
||||
|
||||
### Phase 1 — Bugs (immédiat)
|
||||
- [ ] **BUG-1** : Corriger le cancel_token ignoré dans `upnp_renderer.rs::sync_queue`
|
||||
- [ ] Vérifier les autres renderers pour le même bug
|
||||
|
||||
### Phase 2 — Éliminer les duplications majeures (1-2 semaines)
|
||||
- [ ] **DUP-1** : Trait `HasQueue` + impl automatique de `QueueBackend`
|
||||
- [ ] **DUP-4** : Unifier `parse_didl_duration` sur la version DIDL-Lite
|
||||
- [ ] **DUP-3** : Créer `SharedRendererState` pour l'init commune
|
||||
- [ ] **DUP-2** : Trait `QueueTransportControl` avec `play_from_queue` par défaut
|
||||
|
||||
### Phase 3 — Simplifier les algorithmes (2-4 semaines)
|
||||
- [ ] **ALGO-2** : Macro `dispatch!` pour `MusicRendererBackend`
|
||||
- [ ] **ALGO-3** : Centraliser la protection des durées de stream
|
||||
- [ ] **ALGO-4** : Unifier la détection de flux continu
|
||||
- [ ] **ALGO-1** : Refactoriser `schedule_sync` (extraire `sync_worker_loop`)
|
||||
|
||||
### Phase 4 — Qualité continue
|
||||
- [ ] **BP-1** : Remplacer les `.unwrap()` critiques
|
||||
- [ ] **BP-2** : Gestion d'erreur dans les threads
|
||||
- [ ] **BP-3** : Visibilité des champs `PlaylistBinding`
|
||||
- [ ] **BP-4** : Documentation des traits
|
||||
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é.
|
||||
545
Cargo.lock
generated
545
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
BIN
PMOMusic-A-192.png
Normal file
BIN
PMOMusic-A-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
PMOMusic-A-512.png
Normal file
BIN
PMOMusic-A-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.40"
|
||||
version = "0.3.62"
|
||||
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"] }
|
||||
|
||||
@@ -15,14 +15,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// #[cfg(tokio_unstable)]
|
||||
// console_subscriber::init();
|
||||
|
||||
let server = Server::create_upnp_server().await?; // Routes personnalisées de l'application
|
||||
server
|
||||
.write()
|
||||
.await
|
||||
.add_route("/info", || async {
|
||||
serde_json::json!({"version": "1.0.0"})
|
||||
})
|
||||
.await;
|
||||
let server = Server::create_upnp_server().await?;
|
||||
|
||||
// Initialiser le système de gestion des sources musicales avec API REST
|
||||
info!("📡 Initializing music sources management system...");
|
||||
@@ -60,6 +53,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());
|
||||
@@ -85,6 +84,21 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialiser les ProtocolInfo du MediaServer
|
||||
server_instance.init_protocol_info();
|
||||
|
||||
let local_server_id = server_instance.udn().to_string();
|
||||
|
||||
// Exposer les informations de base de l'instance locale
|
||||
{
|
||||
let local_server_id_clone = local_server_id.clone();
|
||||
server
|
||||
.write()
|
||||
.await
|
||||
.add_route("/info", move || {
|
||||
let id = local_server_id_clone.clone();
|
||||
async move { serde_json::json!({"version": "1.0.0", "local_server_id": id}) }
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
info!(
|
||||
"✅ MediaServer ready at {}{}",
|
||||
server_instance.base_url(),
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webapp</title>
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/app/icons/icon-192.png" />
|
||||
<link rel="apple-touch-icon" href="/app/icons/icon-192.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#111827" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="PMOMusic" />
|
||||
<title>PMOMusic</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
4864
pmoapp/webapp/package-lock.json
generated
4864
pmoapp/webapp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.1.7",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vue-tsc": "^3.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
pmoapp/webapp/public/icons/icon-192.png
Normal file
BIN
pmoapp/webapp/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
pmoapp/webapp/public/icons/icon-512.png
Normal file
BIN
pmoapp/webapp/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
@@ -10,7 +10,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import NotificationToast from '@/components/NotificationToast.vue'
|
||||
import { useShareTarget } from '@/composables/useShareTarget'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
|
||||
const ui = useUIStore()
|
||||
const { shareError } = useShareTarget()
|
||||
|
||||
watch(shareError, (err) => {
|
||||
if (err) ui.notifyError(err)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -257,6 +257,9 @@
|
||||
|
||||
.drawer-footer {
|
||||
padding: var(--spacing-md);
|
||||
/* On mobile, extend into the safe-area so the footer doesn't sit under
|
||||
the system navigation bar / home indicator. */
|
||||
padding-bottom: max(var(--spacing-md), env(safe-area-inset-bottom, 0px));
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const props = defineProps<{
|
||||
const { state } = useRenderer(toRef(props, "rendererId"));
|
||||
const uiStore = useUIStore();
|
||||
const metadata = computed(() => state.value?.current_track);
|
||||
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
|
||||
const isSeeking = ref(false);
|
||||
const isDragging = ref(false);
|
||||
const dragProgress = ref(0);
|
||||
@@ -149,7 +150,7 @@ function handleProgressBarMouseDown(event: MouseEvent) {
|
||||
await api.seekTo(props.rendererId, newPositionSeconds);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
// En cas d'erreur, réinitialiser
|
||||
seekTargetMs.value = null;
|
||||
@@ -188,7 +189,7 @@ async function handleProgressBarClick(event: MouseEvent) {
|
||||
await api.seekTo(props.rendererId, newPositionSeconds);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
// En cas d'erreur, réinitialiser
|
||||
seekTargetMs.value = null;
|
||||
@@ -251,7 +252,7 @@ function handleOverlayProgressBarTouchStart(event: TouchEvent) {
|
||||
await api.seekTo(props.rendererId, newPositionSeconds);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
seekTargetMs.value = null;
|
||||
dragProgress.value = 0;
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -20,12 +20,14 @@ const isStopped = computed(
|
||||
state.value?.transport_state === "NO_MEDIA",
|
||||
);
|
||||
|
||||
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
|
||||
|
||||
async function handlePlay() {
|
||||
try {
|
||||
await resumeOrPlayFromQueue(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de démarrer la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +37,7 @@ async function handlePause() {
|
||||
await pause(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de mettre en pause: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de mettre en pause: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +47,7 @@ async function handleStop() {
|
||||
await stop(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible d'arrêter la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -55,7 +57,7 @@ async function handleNext() {
|
||||
await next(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de passer au morceau suivant: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, toRef } from "vue";
|
||||
import { ref, watch, computed, toRef } from "vue";
|
||||
import { useRenderer, useRenderers } from "@/composables/useRenderers";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import { Volume2, VolumeX } from "lucide-vue-next";
|
||||
@@ -12,6 +12,7 @@ const { state } = useRenderer(toRef(props, "rendererId"));
|
||||
const { setVolume, toggleMute } = useRenderers();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
|
||||
const localVolume = ref(state.value?.volume ?? 50);
|
||||
|
||||
// Synchroniser localVolume avec le state
|
||||
@@ -41,7 +42,7 @@ function handleVolumeChange(event: Event) {
|
||||
await setVolume(props.rendererId, localVolume.value);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de régler le volume: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de régler le volume: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
debounceTimer = null;
|
||||
@@ -53,7 +54,7 @@ async function handleToggleMute() {
|
||||
await toggleMute(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de basculer le mode muet: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de couper/rétablir le son: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,10 @@ function handleRendererDrawerClick() {
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
height: 72px;
|
||||
padding: 0 var(--spacing-md);
|
||||
/* Extend background into the system navigation bar area (iOS home indicator,
|
||||
Android gesture bar). Content stays in the 72px zone; only the visual
|
||||
background bleeds into the safe-area below. */
|
||||
padding: 0 var(--spacing-md) env(safe-area-inset-bottom, 0px);
|
||||
background: rgba(22, 22, 32, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
@@ -323,7 +326,7 @@ function handleRendererDrawerClick() {
|
||||
@media (max-width: 768px) {
|
||||
.bottom-bar {
|
||||
height: 64px;
|
||||
padding: 0 var(--spacing-sm);
|
||||
padding: 0 var(--spacing-sm) env(safe-area-inset-bottom, 0px);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
|
||||
@@ -860,14 +860,8 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
/* Mobile responsive - portrait */
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.renderer-drawer {
|
||||
width: 100vw; /* Mobile portrait: 100% de l'écran */
|
||||
background: rgba(
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
0.06
|
||||
); /* Encore plus transparent sur mobile */
|
||||
box-shadow: none; /* Pas d'ombre sur les côtés */
|
||||
width: 100vw;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
|
||||
@@ -19,6 +19,7 @@ const { renderer, state, queue, refresh } = useRenderer(
|
||||
toRef(props, "rendererId"),
|
||||
);
|
||||
const uiStore = useUIStore();
|
||||
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
|
||||
|
||||
// Détection mobile portrait pour afficher le drawer au lieu de la colonne
|
||||
const isMobilePortrait = useMediaQuery(
|
||||
@@ -59,7 +60,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
error,
|
||||
);
|
||||
uiStore.notifyError(
|
||||
`Erreur: ${error instanceof Error ? error.message : "Impossible de sauter à cet item"}`,
|
||||
`« ${rendererName.value} » — impossible de lire ce morceau: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -230,7 +231,8 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
/* Queue drawer visible sur mobile (géré par v-if maintenant) */
|
||||
.queue-drawer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
/* Ancré juste au-dessus de la BottomTabBar (64px) et de la safe-area système */
|
||||
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(22, 22, 32, 0.96);
|
||||
@@ -238,11 +240,11 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.4);
|
||||
/* Fermé: caché sauf le toggle (56px) qui dépasse au-dessus de la BottomTabBar (64px) */
|
||||
transform: translateY(calc(100% - 56px - 64px));
|
||||
/* Fermé: seul le toggle (56px) visible */
|
||||
transform: translateY(calc(100% - 56px));
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 95; /* Au-dessus de la BottomTabBar (z-index: 100) */
|
||||
max-height: 70vh;
|
||||
z-index: 95; /* En dessous de la BottomTabBar (z-index: 100) */
|
||||
max-height: calc(100vh - 64px - env(safe-area-inset-bottom, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Fermé: ne bloque pas les clics en dehors du toggle */
|
||||
@@ -250,9 +252,9 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
}
|
||||
|
||||
.queue-drawer.open {
|
||||
/* Ouvert: remonte mais s'arrête à 64px du bas pour laisser la BottomTabBar accessible */
|
||||
transform: translateY(64px);
|
||||
pointer-events: auto; /* Ouvert: capture les clics */
|
||||
/* Ouvert: le drawer occupe toute la hauteur disponible au-dessus de la tab bar */
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
@@ -265,6 +267,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
.queue-drawer-toggle {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -280,7 +283,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
}
|
||||
|
||||
.queue-drawer-content {
|
||||
max-height: calc(70vh - 56px);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
@@ -291,12 +294,12 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 64px; /* S'arrête au-dessus de la BottomTabBar */
|
||||
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 94; /* Entre la BottomTabBar et le drawer */
|
||||
pointer-events: auto; /* Capture les clics pour fermer le drawer */
|
||||
z-index: 94;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.controls-column {
|
||||
|
||||
@@ -41,23 +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 } =
|
||||
@@ -112,6 +134,20 @@ watch(drawerContentRef, (el) => {
|
||||
if (el) setupObserver();
|
||||
});
|
||||
|
||||
// Recharger quand le cache est invalidé par un événement SSE (containers_updated / global_updated)
|
||||
watch(browseData, async (data, oldData) => {
|
||||
if (!data && oldData && currentServer.value && currentContainerId.value && !isLoading.value) {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await browseContainer(currentServer.value.id, currentContainerId.value, false);
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Erreur reload après invalidation cache:", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert)
|
||||
const openMenuId = ref<string | null>(null);
|
||||
|
||||
@@ -151,6 +187,7 @@ watch(
|
||||
clearPath();
|
||||
closeMenu();
|
||||
imageStates.clear();
|
||||
searchInput.value = '';
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -201,6 +238,7 @@ async function handleServerClick(server: MediaServerSummary) {
|
||||
|
||||
// Commencer la navigation dans ce serveur
|
||||
currentServer.value = server;
|
||||
searchInput.value = '';
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
@@ -220,6 +258,7 @@ function goBack() {
|
||||
currentServer.value = null;
|
||||
currentContainerId.value = null;
|
||||
clearPath();
|
||||
searchInput.value = '';
|
||||
}
|
||||
|
||||
async function handleContainerClick(item: ContainerEntry) {
|
||||
@@ -274,8 +313,12 @@ function isPlayable(item: ContainerEntry): boolean {
|
||||
|
||||
// Détermine si un container est navigable
|
||||
function isNavigable(item: ContainerEntry): boolean {
|
||||
// Tous les containers sont navigables (on laisse le serveur décider si vide)
|
||||
return item.is_container;
|
||||
if (!item.is_container) return false;
|
||||
// Une playlist avec un seul item : on garde le caractère jouable mais on retire
|
||||
// la navigation pour éviter que l'utilisateur joue l'item directement et perde
|
||||
// le bénéfice des mises à jour de métadonnées de la playlist.
|
||||
if (isPlayable(item) && item.child_count === 1) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleItemClick(item: ContainerEntry) {
|
||||
@@ -454,7 +497,7 @@ function handleSettingsClick() {
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<button
|
||||
v-if="searchInput || isSearchMode"
|
||||
v-if="searchInput"
|
||||
class="search-clear-btn"
|
||||
@click="handleClearSearch"
|
||||
title="Effacer"
|
||||
@@ -559,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
|
||||
@@ -1424,14 +1420,8 @@ function handleSettingsClick() {
|
||||
/* Mobile responsive - portrait */
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.server-drawer {
|
||||
width: 100vw; /* Mobile portrait: 100% de l'écran */
|
||||
background: rgba(
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
0.06
|
||||
); /* Encore plus transparent sur mobile */
|
||||
box-shadow: none; /* Pas d'ombre sur les côtés */
|
||||
width: 100vw;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
|
||||
@@ -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,25 +212,23 @@ export function useMediaServers() {
|
||||
}
|
||||
}
|
||||
|
||||
// Recherche dans un serveur
|
||||
async function searchServer(serverId: string, query: string) {
|
||||
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
|
||||
|
||||
const data = await api.searchServer(serverId, query)
|
||||
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)
|
||||
@@ -242,11 +238,6 @@ export function useMediaServers() {
|
||||
}
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchResults.value = null
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
// Getters
|
||||
function getServerById(id: string) {
|
||||
return serversCache.value.get(id)
|
||||
@@ -301,13 +292,9 @@ export function useMediaServers() {
|
||||
getServerById,
|
||||
getBrowseCached,
|
||||
hasMore,
|
||||
// Search
|
||||
searchResults,
|
||||
searchQuery,
|
||||
searchServer,
|
||||
clearSearch,
|
||||
// Actions
|
||||
fetchServers,
|
||||
searchServer,
|
||||
browseContainer,
|
||||
loadMoreBrowse,
|
||||
setPath,
|
||||
|
||||
@@ -27,9 +27,11 @@ const loadingIds = reactive(new Set<string>());
|
||||
const queueRefreshingIds = reactive(new Set<string>());
|
||||
const selectedRendererId = ref<string | null>(null);
|
||||
|
||||
// Debounce pour les refetches queue_updated
|
||||
const queueUpdateDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const QUEUE_UPDATE_DEBOUNCE_MS = 300;
|
||||
// Compteur d'erreurs consécutives par renderer — on ne notifie l'utilisateur
|
||||
// qu'après SNAPSHOT_ERROR_THRESHOLD échecs d'affilée pour absorber les hoquets
|
||||
// transitoires des devices (Arylic, etc.).
|
||||
const consecutiveSnapshotErrors = new Map<string, number>();
|
||||
const SNAPSHOT_ERROR_THRESHOLD = 3;
|
||||
|
||||
// Cache des renderers (summary)
|
||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
||||
@@ -205,15 +207,10 @@ function ensureSSEInitialized() {
|
||||
snapshot.state.queue_len = event.queue_length;
|
||||
queueRefreshingIds.delete(rendererId);
|
||||
|
||||
// Annuler le timer précédent pour ce renderer
|
||||
const existingTimer = queueUpdateDebounceTimers.get(rendererId);
|
||||
if (existingTimer) clearTimeout(existingTimer);
|
||||
|
||||
// Programmer un seul fetch après stabilisation
|
||||
queueUpdateDebounceTimers.set(rendererId, setTimeout(() => {
|
||||
queueUpdateDebounceTimers.delete(rendererId);
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
}, QUEUE_UPDATE_DEBOUNCE_MS));
|
||||
// Fetch queue items immediately when we get queue_updated
|
||||
// The debounce was causing race conditions where queue_len was updated
|
||||
// but items weren't fetched yet when user clicked play
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
break;
|
||||
|
||||
case "binding_changed":
|
||||
@@ -342,7 +339,7 @@ async function fetchRenderers(force = false, retries = 2) {
|
||||
error.value = lastError?.message ?? "Erreur fetch renderers";
|
||||
|
||||
// Notifier l'utilisateur en cas d'erreur finale
|
||||
uiStore.notifyError("Impossible de rafraîchir la liste des renderers");
|
||||
uiStore.notifyError("Impossible de rafraîchir la liste des lecteurs audio");
|
||||
}
|
||||
|
||||
async function fetchRendererSnapshot(
|
||||
@@ -376,18 +373,25 @@ async function fetchRendererSnapshot(
|
||||
const snapshot = await api.getRendererFullSnapshot(rendererId);
|
||||
snapshots.set(rendererId, snapshot);
|
||||
lastSnapshotAt.set(rendererId, Date.now());
|
||||
|
||||
consecutiveSnapshotErrors.set(rendererId, 0);
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err);
|
||||
// En cas d'erreur, on supprime le snapshot pour permettre une nouvelle tentative
|
||||
snapshots.delete(rendererId);
|
||||
|
||||
// Notifier l'utilisateur
|
||||
uiStore.notifyError(`Impossible de récupérer l'état du renderer`);
|
||||
const count = (consecutiveSnapshotErrors.get(rendererId) ?? 0) + 1;
|
||||
consecutiveSnapshotErrors.set(rendererId, count);
|
||||
console.error(`[useRenderers] Erreur snapshot ${rendererId} (${count}/${SNAPSHOT_ERROR_THRESHOLD}):`, err);
|
||||
|
||||
// Garder le dernier snapshot connu plutôt que de le supprimer — l'UI reste
|
||||
// affichable même si le device est momentanément muet.
|
||||
|
||||
// Notifier seulement après N échecs consécutifs pour absorber les hoquets passagers
|
||||
if (count >= SNAPSHOT_ERROR_THRESHOLD) {
|
||||
const name = renderersCache.value.get(rendererId)?.friendly_name ?? rendererId;
|
||||
uiStore.notifyError(`Impossible de récupérer l'état de « ${name} »`);
|
||||
// Remettre à zéro pour ne pas spammer si le device reste instable
|
||||
consecutiveSnapshotErrors.set(rendererId, 0);
|
||||
}
|
||||
} finally {
|
||||
// Toujours nettoyer le flag de chargement
|
||||
loadingIds.delete(rendererId);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,10 +451,11 @@ async function resumeOrPlayFromQueue(id: string) {
|
||||
return play(id);
|
||||
}
|
||||
|
||||
if (
|
||||
["STOPPED", "NO_MEDIA"].includes(state.transport_state) &&
|
||||
snapshot.queue.items.length > 0
|
||||
) {
|
||||
// If queue has items, always try to play from queue regardless of transport
|
||||
// state. After a sleep timer stop, the device may report an unknown/transient
|
||||
// state ("load", network error → "UNKNOWN", etc.) that doesn't match the
|
||||
// expected "STOPPED"/"NO_MEDIA" strings, yet the queue is non-empty.
|
||||
if (snapshot.queue.items.length > 0) {
|
||||
return api.resume(id);
|
||||
}
|
||||
|
||||
@@ -574,6 +579,8 @@ export function useRenderers() {
|
||||
volumeUp,
|
||||
volumeDown,
|
||||
toggleMute,
|
||||
// Selection
|
||||
selectedRendererId,
|
||||
// Playlist binding
|
||||
attachPlaylist,
|
||||
detachPlaylist,
|
||||
|
||||
90
pmoapp/webapp/src/composables/useShareTarget.ts
Normal file
90
pmoapp/webapp/src/composables/useShareTarget.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { searchSource } from '@/services/pmosource'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
|
||||
export interface ShareTargetResult {
|
||||
url: string
|
||||
title: string | null
|
||||
containerId: string
|
||||
}
|
||||
|
||||
const pendingShare = ref<ShareTargetResult | null>(null)
|
||||
const shareError = ref<string | null>(null)
|
||||
|
||||
let localServerId: string | null = null
|
||||
|
||||
async function fetchLocalServerId(): Promise<string | null> {
|
||||
if (localServerId) return localServerId
|
||||
try {
|
||||
const resp = await fetch('/api/info')
|
||||
if (!resp.ok) return null
|
||||
const data = await resp.json()
|
||||
localServerId = data.local_server_id ?? null
|
||||
return localServerId
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function useShareTarget() {
|
||||
const { selectedRendererId, attachAndPlayPlaylist } = useRenderers()
|
||||
|
||||
async function handleShareIfPresent() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const sharedUrl = params.get('share_url') ?? params.get('share_text') ?? null
|
||||
const sharedTitle = params.get('share_title')
|
||||
|
||||
if (!sharedUrl) return
|
||||
|
||||
const clean = new URL(window.location.href)
|
||||
clean.searchParams.delete('share_url')
|
||||
clean.searchParams.delete('share_title')
|
||||
clean.searchParams.delete('share_text')
|
||||
window.history.replaceState({}, '', clean.toString())
|
||||
|
||||
try {
|
||||
shareError.value = null
|
||||
const result = await searchSource('url', sharedUrl)
|
||||
if (result.total === 0) {
|
||||
shareError.value = `Aucun contenu trouvé pour : ${sharedUrl}`
|
||||
return
|
||||
}
|
||||
|
||||
const container = result.containers[0] ?? null
|
||||
const containerId = container?.id ?? result.items[0]?.id
|
||||
|
||||
if (!containerId) {
|
||||
shareError.value = 'Contenu résolu mais sans identifiant jouable'
|
||||
return
|
||||
}
|
||||
|
||||
const serverId = await fetchLocalServerId()
|
||||
const rendererId = selectedRendererId.value
|
||||
|
||||
if (!serverId || !rendererId) {
|
||||
// Pas de renderer sélectionné ou serveur inconnu : stocker pour affichage manuel
|
||||
pendingShare.value = { url: sharedUrl, title: sharedTitle, containerId }
|
||||
return
|
||||
}
|
||||
|
||||
await attachAndPlayPlaylist(rendererId, serverId, containerId)
|
||||
} catch (e) {
|
||||
shareError.value = e instanceof Error ? e.message : 'Erreur lors de la résolution'
|
||||
}
|
||||
}
|
||||
|
||||
function clearShare() {
|
||||
pendingShare.value = null
|
||||
shareError.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleShareIfPresent()
|
||||
})
|
||||
|
||||
return {
|
||||
pendingShare,
|
||||
shareError,
|
||||
clearShare,
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,18 @@ export function getSourceImageUrl(sourceId: string): string {
|
||||
return `${API_BASE}/${sourceId}/image`
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche dans une source musicale (URL, texte libre…)
|
||||
*/
|
||||
export async function searchSource(sourceId: string, query: string): Promise<BrowseResponse> {
|
||||
const params = new URLSearchParams({ q: query })
|
||||
const response = await fetch(`${API_BASE}/${sourceId}/search?${params.toString()}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Search failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les capacités d'une source
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,67 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
base: '/app/',
|
||||
manifest: {
|
||||
name: 'PMOMusic',
|
||||
short_name: 'PMOMusic',
|
||||
description: 'Contrôleur UPnP/DLNA pour votre musique',
|
||||
start_url: '/app/',
|
||||
display: 'standalone',
|
||||
orientation: 'any',
|
||||
theme_color: '#111827',
|
||||
background_color: '#111827',
|
||||
icons: [
|
||||
{
|
||||
src: '/app/icons/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/app/icons/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
share_target: {
|
||||
action: '/app/',
|
||||
method: 'GET',
|
||||
params: {
|
||||
url: 'share_url',
|
||||
title: 'share_title',
|
||||
text: 'share_text',
|
||||
},
|
||||
},
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: '/app/index.html',
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^\/api\//,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
networkTimeoutSeconds: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /^\/audio\//,
|
||||
handler: 'NetworkOnly',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
base: '/app/', // Base path pour le déploiement
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE},
|
||||
pipeline::{Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker, StreamType,
|
||||
AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker,
|
||||
};
|
||||
use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat};
|
||||
use std::{
|
||||
|
||||
@@ -155,6 +155,7 @@ impl DB {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute("PRAGMA foreign_keys = ON", [])?;
|
||||
|
||||
// 1. CRÉATION DES TABLES EN PREMIER
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS asset (
|
||||
pk TEXT PRIMARY KEY,
|
||||
@@ -180,21 +181,30 @@ impl DB {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Créer un index sur la collection pour les requêtes rapides
|
||||
// 2. Optimisations SQLite pour production
|
||||
conn.execute_batch(
|
||||
"
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA cache_size = -32768;
|
||||
PRAGMA temp_store = MEMORY;
|
||||
PRAGMA mmap_size = 268435456;
|
||||
",
|
||||
)?;
|
||||
|
||||
// 3. CRÉATION DES INDEX APRÈS LES TABLES
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asset_collection
|
||||
ON ASSET (collection)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Créer un index composite pour optimiser la politique LRU (get_oldest)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asset_lru
|
||||
ON asset (last_used ASC, hits ASC)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Crée un index composite pour rendre unique les ids si défini dans une collection
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX
|
||||
IF NOT EXISTS asset_collection_id_unique
|
||||
@@ -203,12 +213,32 @@ impl DB {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// LAZY PK SUPPORT: Index sur lazy_pk pour lookups rapides (lazy_pk → real pk)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_metadata_key_value ON metadata (key, value)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asset_last_used ON asset (last_used DESC)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asset_hits ON asset (hits DESC)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asset_lazy_pk ON asset (lazy_pk)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_lazy_pk_unique
|
||||
ON asset (lazy_pk) WHERE lazy_pk IS NOT NULL",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Index unique sur lazy_pk (non-NULL) pour éviter les doublons
|
||||
// Un lazy_pk ne peut pointer que vers un seul entry
|
||||
conn.execute(
|
||||
@@ -220,7 +250,12 @@ impl DB {
|
||||
// Inscrire la version du schéma
|
||||
conn.execute_batch(&format!("PRAGMA user_version = {}", SCHEMA_VERSION))?;
|
||||
|
||||
Ok((Self { conn: Mutex::new(conn) }, was_reset))
|
||||
Ok((
|
||||
Self {
|
||||
conn: Mutex::new(conn),
|
||||
},
|
||||
was_reset,
|
||||
))
|
||||
}
|
||||
|
||||
/// Ajoute ou met à jour une entrée dans la base de données
|
||||
@@ -1150,7 +1185,9 @@ impl DB {
|
||||
|
||||
tracing::debug!(
|
||||
"update_lazy_to_downloaded: {} → {} ({} metadata rows still under lazy_pk)",
|
||||
lazy_pk, real_pk, meta_under_lazy
|
||||
lazy_pk,
|
||||
real_pk,
|
||||
meta_under_lazy
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -20,9 +20,7 @@ ratatui = { version = "0.26", default-features = false, features = ["crossterm"]
|
||||
crossterm = "0.27"
|
||||
rust_cast = "0.19"
|
||||
rustls = { version = "0.23", features = ["aws-lc-rs"] }
|
||||
mdns = "3.0"
|
||||
async-std = "1.12"
|
||||
futures-util = "0.3"
|
||||
mdns-sd = "0.19"
|
||||
smol = "2.0"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -13,7 +13,7 @@ use tracing::{debug, error, info, warn};
|
||||
use crate::discovery::manager::UDNRegistry;
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::events::{MediaServerEventBus, RendererEventBus};
|
||||
use crate::media_server::{MediaBrowser, MusicServer, playback_item_from_entry};
|
||||
use crate::media_server::{playback_item_from_entry, MediaBrowser, MusicServer};
|
||||
use crate::media_server_events::spawn_media_server_event_runtime;
|
||||
use crate::model::{MediaServerEvent, RendererEvent};
|
||||
use crate::model::{PlaybackState, TrackMetadata};
|
||||
@@ -26,7 +26,7 @@ use crate::openapi::{
|
||||
CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView,
|
||||
RendererStateView,
|
||||
};
|
||||
use crate::queue::{EnqueueMode, PlaybackItem, QueueSnapshot};
|
||||
use crate::queue::{EnqueueMode, PlaybackItem, QueueSnapshot, SyncScheduleOutcome};
|
||||
use crate::registry::DeviceRegistry;
|
||||
|
||||
/// Control point minimal :
|
||||
@@ -147,51 +147,52 @@ impl ControlPoint {
|
||||
let udn_cache_for_mdns = Arc::clone(&udn_cache);
|
||||
thread::spawn(move || {
|
||||
use crate::discovery::ChromecastDiscoveryManager;
|
||||
use futures_util::StreamExt;
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
|
||||
// Créer le gestionnaire de découverte UPNP
|
||||
let mut discovery_manager =
|
||||
ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns);
|
||||
|
||||
debug!("Starting mDNS discovery thread for Chromecast devices");
|
||||
|
||||
const SERVICE_NAME: &str = "_googlecast._tcp.local";
|
||||
const SERVICE_TYPE: &str = "_googlecast._tcp.local.";
|
||||
|
||||
// Run async discovery in a blocking task
|
||||
async_std::task::block_on(async {
|
||||
// Create mDNS discovery stream with 15 second query interval
|
||||
// (shorter interval for faster initial discovery)
|
||||
match mdns::discover::all(SERVICE_NAME, Duration::from_secs(15)) {
|
||||
Ok(discovery) => {
|
||||
let stream = discovery.listen();
|
||||
futures_util::pin_mut!(stream);
|
||||
|
||||
debug!("mDNS discovery stream started for Chromecast devices");
|
||||
|
||||
// Listen to mDNS responses
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(response) => {
|
||||
debug!(
|
||||
"Received mDNS response with {} records",
|
||||
response.records().count()
|
||||
);
|
||||
|
||||
discovery_manager.handle_mdns_response(response);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("mDNS discovery error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("mDNS discovery stream ended unexpectedly");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to start mDNS discovery: {}", e);
|
||||
}
|
||||
let daemon = match ServiceDaemon::new() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
error!("Failed to create mDNS daemon: {}", e);
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let receiver = match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Failed to start mDNS browse for {}: {}", SERVICE_TYPE, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
debug!("mDNS discovery started for Chromecast devices");
|
||||
|
||||
while let Ok(event) = receiver.recv() {
|
||||
match event {
|
||||
ServiceEvent::ServiceResolved(info) => {
|
||||
debug!(
|
||||
fullname = info.get_fullname(),
|
||||
host = info.get_hostname(),
|
||||
port = info.get_port(),
|
||||
"mDNS Chromecast service resolved"
|
||||
);
|
||||
discovery_manager.handle_service_resolved(&info);
|
||||
}
|
||||
ServiceEvent::ServiceRemoved(_service_type, fullname) => {
|
||||
debug!(fullname = %fullname, "mDNS Chromecast service removed");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("mDNS discovery receiver closed");
|
||||
});
|
||||
|
||||
// Note: Renderer polling is now handled by each MusicRenderer's own watcher thread.
|
||||
@@ -267,19 +268,12 @@ impl ControlPoint {
|
||||
"Triggering queue refresh for bound playlist"
|
||||
);
|
||||
|
||||
if let Err(err) = refresh_attached_queue_for(
|
||||
let _ = schedule_queue_refresh_for(
|
||||
®istry_for_media_worker,
|
||||
&renderer_id,
|
||||
&event_bus_for_media_worker,
|
||||
None,
|
||||
) {
|
||||
warn!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
error = %err,
|
||||
"Failed to refresh queue from playlist container"
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
MediaServerEvent::Online { server_id, info } => {
|
||||
@@ -337,18 +331,12 @@ impl ControlPoint {
|
||||
"Periodic refresh triggered for bound playlist"
|
||||
);
|
||||
|
||||
if let Err(err) = refresh_attached_queue_for(
|
||||
let _ = schedule_queue_refresh_for(
|
||||
®istry_for_periodic,
|
||||
&renderer_id,
|
||||
&event_bus_for_periodic,
|
||||
None,
|
||||
) {
|
||||
warn!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
error = %err,
|
||||
"Periodic refresh failed for bound playlist"
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
})?;
|
||||
@@ -851,11 +839,17 @@ impl ControlPoint {
|
||||
"Playing current playback item from queue"
|
||||
);
|
||||
|
||||
// Temporarily disable auto-advance to prevent race condition
|
||||
// when renderer sends Stopped event during SetAVTransportURI
|
||||
renderer.set_playback_source(PlaybackSource::None);
|
||||
// Note: We set playback_source to FromQueue BEFORE calling play_from_queue().
|
||||
// This prevents a race condition where:
|
||||
// 1. play_from_queue() is called
|
||||
// 2. watcher detects STOPPED from track transition (before playback_source is set)
|
||||
// 3. with source=None, is_playing_from_queue() returns false, breaking auto-advance
|
||||
// By setting it BEFORE, even if STOPPED is detected, source will be FromQueue.
|
||||
// Note: We no longer temporarily set playback_source to None (that caused Bug #2).
|
||||
|
||||
// Start playback using play_from_queue which preserves the queue
|
||||
// Set source to FromQueue BEFORE calling to prevent race condition
|
||||
renderer.set_playback_source(PlaybackSource::FromQueue);
|
||||
if let Err(err) = renderer.play_from_queue() {
|
||||
error!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
@@ -877,7 +871,7 @@ impl ControlPoint {
|
||||
let metadata = playback_item_track_metadata(&item);
|
||||
renderer.set_last_metadata(Some(metadata));
|
||||
|
||||
renderer.set_playback_source(PlaybackSource::FromQueue);
|
||||
// Note: playback_source already set to FromQueue above
|
||||
|
||||
// Prefetch next track if supported (gapless playback)
|
||||
self.prefetch_next_track(&renderer, renderer_id);
|
||||
@@ -907,8 +901,9 @@ impl ControlPoint {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Temporarily disable auto-advance to prevent race condition
|
||||
renderer.set_playback_source(PlaybackSource::None);
|
||||
// Note: Same fix as play_current_from_queue - set source BEFORE calling.
|
||||
// Set source to FromQueue BEFORE calling to prevent race condition
|
||||
renderer.set_playback_source(PlaybackSource::FromQueue);
|
||||
|
||||
// Use the backend's play_next which handles queue advancement correctly for each backend type
|
||||
if let Err(err) = renderer.play_next_from_queue() {
|
||||
@@ -1047,8 +1042,9 @@ impl ControlPoint {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Temporarily disable auto-advance to prevent race condition
|
||||
renderer.set_playback_source(PlaybackSource::None);
|
||||
// Note: Same fix as play_current_from_queue - set source BEFORE calling.
|
||||
// Set source to FromQueue BEFORE calling to prevent race condition
|
||||
renderer.set_playback_source(PlaybackSource::FromQueue);
|
||||
|
||||
// Use the backend's play_from_index which handles everything correctly
|
||||
if let Err(err) = renderer.play_from_index(index) {
|
||||
@@ -1182,19 +1178,23 @@ impl ControlPoint {
|
||||
binding.auto_play_on_refresh = auto_play;
|
||||
renderer.set_playlist_binding(Some(binding));
|
||||
|
||||
let mut auto_start_cb = |rid: &DeviceId| self.play_current_from_queue(rid);
|
||||
let callback: Option<&mut dyn FnMut(&DeviceId) -> Result<(), ControlPointError>> =
|
||||
if auto_play {
|
||||
Some(&mut auto_start_cb)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
return refresh_attached_queue_for(
|
||||
&self.registry,
|
||||
renderer_id,
|
||||
&self.event_bus,
|
||||
callback,
|
||||
);
|
||||
let callback: Option<
|
||||
Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>,
|
||||
> = if auto_play {
|
||||
let reg = Arc::clone(&self.registry);
|
||||
Some(Box::new(move |rid: &DeviceId| {
|
||||
let renderer = reg.read().unwrap().get_renderer(rid).ok_or_else(|| {
|
||||
ControlPointError::ControlPoint(format!("Renderer {} not found", rid.0))
|
||||
})?;
|
||||
// Use retry version to handle transient renderer errors (like JBL Authentics 300)
|
||||
renderer.play_current_from_queue_with_retry()
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _ =
|
||||
schedule_queue_refresh_for(&self.registry, renderer_id, &self.event_bus, callback);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// CRITICAL: When attaching a new playlist to a renderer, we must UNCONDITIONALLY
|
||||
@@ -1247,21 +1247,27 @@ impl ControlPoint {
|
||||
// Note: BindingChanged event is emitted automatically by MusicRenderer::set_playlist_binding()
|
||||
|
||||
// For initial attach with auto_play, force playback start (don't check if idle)
|
||||
let mut auto_start_cb = |rid: &DeviceId| {
|
||||
debug!(
|
||||
renderer = rid.0.as_str(),
|
||||
"Attach callback: forcing playback start (not checking if idle)"
|
||||
);
|
||||
self.play_current_from_queue(rid)
|
||||
let callback: Option<
|
||||
Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>,
|
||||
> = if auto_play {
|
||||
let reg = Arc::clone(&self.registry);
|
||||
Some(Box::new(move |rid: &DeviceId| {
|
||||
debug!(
|
||||
renderer = rid.0.as_str(),
|
||||
"Attach callback: forcing playback start (not checking if idle)"
|
||||
);
|
||||
let renderer = reg.read().unwrap().get_renderer(rid).ok_or_else(|| {
|
||||
ControlPointError::ControlPoint(format!("Renderer {} not found", rid.0))
|
||||
})?;
|
||||
// Use retry version to handle transient renderer errors (like JBL Authentics 300)
|
||||
renderer.play_current_from_queue_with_retry()
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let callback: Option<&mut dyn FnMut(&DeviceId) -> Result<(), ControlPointError>> =
|
||||
if auto_play {
|
||||
Some(&mut auto_start_cb)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
refresh_attached_queue_for(&self.registry, renderer_id, &self.event_bus, callback)
|
||||
let _ = schedule_queue_refresh_for(&self.registry, renderer_id, &self.event_bus, callback);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Detach a renderer's queue from its associated playlist container.
|
||||
@@ -1760,6 +1766,203 @@ fn refresh_attached_queue_for(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_queue_items_for(
|
||||
music_server: &Arc<crate::media_server::MusicServer>,
|
||||
container_id: &str,
|
||||
) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||
const MAX_BROWSE_ATTEMPTS: usize = 3;
|
||||
const BROWSE_RETRY_DELAY_MS: u64 = 200;
|
||||
const BROWSE_PAGE_SIZE: u32 = 64;
|
||||
|
||||
let entries = {
|
||||
let mut all_entries = Vec::new();
|
||||
let mut offset = 0u32;
|
||||
loop {
|
||||
let mut attempt = 1;
|
||||
let page = loop {
|
||||
match music_server.browse_children(container_id, offset, BROWSE_PAGE_SIZE) {
|
||||
Ok(e) => break e,
|
||||
Err(err) => {
|
||||
if attempt >= MAX_BROWSE_ATTEMPTS {
|
||||
return Err(err);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(
|
||||
BROWSE_RETRY_DELAY_MS * attempt as u64,
|
||||
));
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
let fetched = page.len() as u32;
|
||||
all_entries.extend(page);
|
||||
if fetched < BROWSE_PAGE_SIZE {
|
||||
break;
|
||||
}
|
||||
offset += fetched;
|
||||
}
|
||||
all_entries
|
||||
};
|
||||
|
||||
let new_items: Vec<PlaybackItem> = entries
|
||||
.iter()
|
||||
.filter_map(|entry| playback_item_from_entry(music_server.clone(), entry))
|
||||
.collect();
|
||||
|
||||
Ok(new_items)
|
||||
}
|
||||
|
||||
fn schedule_queue_refresh_for(
|
||||
registry: &Arc<RwLock<DeviceRegistry>>,
|
||||
renderer_id: &DeviceId,
|
||||
event_bus: &RendererEventBus,
|
||||
after_refresh: Option<
|
||||
Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>,
|
||||
>,
|
||||
) -> SyncScheduleOutcome {
|
||||
// Step 1: Get renderer from registry
|
||||
let renderer = {
|
||||
let reg = registry.read().unwrap();
|
||||
reg.get_renderer(renderer_id)
|
||||
};
|
||||
|
||||
let renderer = match renderer {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
"schedule_queue_refresh_for: renderer not found"
|
||||
);
|
||||
return SyncScheduleOutcome::Scheduled;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if there's a binding
|
||||
let (server_id, container_id) = {
|
||||
let binding = match renderer.get_playlist_binding() {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
"schedule_queue_refresh_for: no binding present"
|
||||
);
|
||||
return SyncScheduleOutcome::Scheduled;
|
||||
}
|
||||
};
|
||||
(binding.server_id.clone(), binding.container_id.clone())
|
||||
};
|
||||
|
||||
// Step 2: Get server from registry
|
||||
let music_server = {
|
||||
let reg = registry.read().unwrap();
|
||||
reg.get_server(&server_id)
|
||||
};
|
||||
|
||||
let music_server = match music_server {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
warn!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
"schedule_queue_refresh_for: server not found in registry"
|
||||
);
|
||||
return SyncScheduleOutcome::Scheduled;
|
||||
}
|
||||
};
|
||||
|
||||
if !music_server.is_online() {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
"schedule_queue_refresh_for: server offline, skipping refresh"
|
||||
);
|
||||
return SyncScheduleOutcome::Scheduled;
|
||||
}
|
||||
|
||||
// Emit QueueRefreshing
|
||||
event_bus.broadcast(RendererEvent::QueueRefreshing {
|
||||
id: renderer_id.clone(),
|
||||
});
|
||||
|
||||
// Get the queue Arc
|
||||
let queue_arc = {
|
||||
let reg = registry.read().unwrap();
|
||||
match reg.get_renderer_queue_arc(renderer_id) {
|
||||
Some(q) => q,
|
||||
None => {
|
||||
warn!("schedule_queue_refresh_for: could not get queue arc");
|
||||
return SyncScheduleOutcome::Scheduled;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Build the pending_items_fn for re-fetching
|
||||
let registry_clone = Arc::clone(registry);
|
||||
let server_id_clone = server_id.clone();
|
||||
let container_id_clone = container_id.clone();
|
||||
let pending_fn = Box::new(move || {
|
||||
let music_server = {
|
||||
let reg = registry_clone.read().unwrap();
|
||||
reg.get_server(&server_id_clone)
|
||||
};
|
||||
match music_server {
|
||||
Some(s) => fetch_queue_items_for(&s, &container_id_clone),
|
||||
None => Err(ControlPointError::MediaServerError(
|
||||
"Server not found".to_string(),
|
||||
)),
|
||||
}
|
||||
});
|
||||
|
||||
// Build on_ready callback
|
||||
let rid = renderer_id.clone();
|
||||
let bus = event_bus.clone();
|
||||
let after_cb = after_refresh;
|
||||
let on_ready: Option<Box<dyn FnOnce() + Send + 'static>> = Some(Box::new(move || {
|
||||
bus.broadcast(RendererEvent::QueueReadyToPlay { id: rid.clone() });
|
||||
if let Some(cb) = after_cb {
|
||||
if let Err(e) = cb(&rid) {
|
||||
warn!("auto-play callback failed: {}", e);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Build on_complete callback
|
||||
let rid2 = renderer_id.clone();
|
||||
let bus2 = event_bus.clone();
|
||||
let on_complete = Box::new(move |queue_len: usize| {
|
||||
bus2.broadcast(RendererEvent::QueueUpdated {
|
||||
id: rid2.clone(),
|
||||
queue_length: queue_len,
|
||||
});
|
||||
});
|
||||
|
||||
// First fetch
|
||||
let items = match fetch_queue_items_for(&music_server, &container_id) {
|
||||
Ok(items) => items,
|
||||
Err(e) => {
|
||||
warn!("schedule_queue_refresh_for: failed to fetch items: {}", e);
|
||||
return SyncScheduleOutcome::Scheduled;
|
||||
}
|
||||
};
|
||||
|
||||
// Call schedule_sync
|
||||
let outcome = crate::queue::MusicQueue::schedule_sync(
|
||||
&queue_arc,
|
||||
&renderer_id.0,
|
||||
items,
|
||||
pending_fn,
|
||||
on_ready,
|
||||
on_complete,
|
||||
);
|
||||
|
||||
if matches!(outcome, SyncScheduleOutcome::AlreadyRunning) {
|
||||
event_bus.broadcast(RendererEvent::QueueSyncCancelled {
|
||||
id: renderer_id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
outcome
|
||||
}
|
||||
|
||||
fn didl_item_from_playback_item(item: &PlaybackItem) -> DidlItem {
|
||||
let metadata = item.metadata.as_ref();
|
||||
let title = metadata
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
//! This module handles the discovery of Chromecast devices and registers them
|
||||
//! directly into the `DeviceRegistry`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use crate::DeviceId;
|
||||
use crate::DeviceRegistry;
|
||||
use mdns_sd::ResolvedService;
|
||||
use mdns_sd::ServiceInfo;
|
||||
|
||||
use crate::discovery::manager::UDNRegistry;
|
||||
use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol};
|
||||
use crate::DeviceId;
|
||||
use crate::DeviceRegistry;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Gestionnaire des événements mDNS pour Chromecast.
|
||||
@@ -32,146 +33,80 @@ impl ChromecastDiscoveryManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Traite une réponse mDNS pour un appareil Chromecast.
|
||||
/// Traite un service Chromecast résolu par mDNS-SD.
|
||||
///
|
||||
/// Cette fonction parse les réponses de service discovery mDNS pour les appareils
|
||||
/// Chromecast et les enregistre directement dans le registre.
|
||||
pub fn handle_mdns_response(&mut self, response: mdns::Response) {
|
||||
// Extract basic information from the mDNS response
|
||||
let service_name = match response.records().find_map(|r| {
|
||||
if let mdns::RecordKind::PTR(ref name) = r.kind {
|
||||
Some(name.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
Some(name) => name,
|
||||
None => {
|
||||
warn!("No PTR record found in mDNS response");
|
||||
return;
|
||||
}
|
||||
};
|
||||
/// `ServiceInfo` arrive pré-assemblé : plus besoin de jointure manuelle
|
||||
/// des enregistrements PTR / A / SRV / TXT.
|
||||
pub fn handle_service_resolved(&mut self, info: &ResolvedService) {
|
||||
let fullname = info.get_fullname().to_string();
|
||||
|
||||
debug!("Processing mDNS response for service: {}", service_name);
|
||||
debug!("Processing resolved Chromecast service: {}", fullname);
|
||||
|
||||
// Extract IP addresses
|
||||
let addresses: Vec<IpAddr> = response
|
||||
.records()
|
||||
.filter_map(|r| match r.kind {
|
||||
mdns::RecordKind::A(addr) => Some(IpAddr::V4(addr)),
|
||||
mdns::RecordKind::AAAA(addr) => Some(IpAddr::V6(addr)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if addresses.is_empty() {
|
||||
warn!(
|
||||
"No IP address found for Chromecast device: {}",
|
||||
service_name
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer IPv4 addresses
|
||||
let host = match addresses
|
||||
let host = match info
|
||||
.get_addresses()
|
||||
.iter()
|
||||
.find(|addr| matches!(addr, IpAddr::V4(_)))
|
||||
.or_else(|| addresses.first())
|
||||
.find(|a| a.is_ipv4())
|
||||
.or_else(|| info.get_addresses().iter().next())
|
||||
{
|
||||
Some(addr) => addr.to_string(),
|
||||
None => {
|
||||
warn!("Could not extract host from addresses");
|
||||
warn!("No IP address for Chromecast service: {}", fullname);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Extract port from SRV record
|
||||
let port = response
|
||||
.records()
|
||||
.find_map(|r| {
|
||||
if let mdns::RecordKind::SRV { port, .. } = r.kind {
|
||||
Some(port)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(8009); // Default Chromecast port
|
||||
let port = info.get_port();
|
||||
|
||||
// Extract TXT records for additional metadata
|
||||
let txt_records: HashMap<String, String> = response
|
||||
.records()
|
||||
.filter_map(|r| {
|
||||
if let mdns::RecordKind::TXT(ref data) = r.kind {
|
||||
Some(data.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.flat_map(|data| {
|
||||
// data is Vec<String>, each string is "key=value"
|
||||
data.into_iter().filter_map(|s| {
|
||||
let parts: Vec<&str> = s.splitn(2, '=').collect();
|
||||
if parts.len() == 2 {
|
||||
Some((parts[0].to_string(), parts[1].to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let uuid = info
|
||||
.get_property_val_str("id")
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let uuid = if uuid.is_empty() {
|
||||
format!("chromecast-{}-{}", host, port)
|
||||
} else {
|
||||
uuid
|
||||
};
|
||||
|
||||
// Extract metadata from TXT records
|
||||
let model = txt_records.get("md").cloned();
|
||||
let uuid = txt_records
|
||||
.get("id")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("chromecast-{}-{}", host, port));
|
||||
let manufacturer = Some("Google Inc.".to_string());
|
||||
let model = info.get_property_val_str("md").map(|s| s.to_string());
|
||||
|
||||
// Extract friendly name from TXT record "fn" if available
|
||||
// Otherwise, extract from service instance name (PTR record)
|
||||
let friendly_name = txt_records.get("fn").cloned().unwrap_or_else(|| {
|
||||
// Fallback: extract from service name, removing the UUID suffix if present
|
||||
service_name
|
||||
.split("._googlecast._tcp.local")
|
||||
.next()
|
||||
.unwrap_or("Unknown Chromecast")
|
||||
.split('-')
|
||||
.take_while(|part| part.len() != 32) // Skip 32-char hex UUID
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
.trim()
|
||||
.to_string()
|
||||
});
|
||||
let friendly_name = info
|
||||
.get_property_val_str("fn")
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
fullname
|
||||
.split("._googlecast._tcp.local")
|
||||
.next()
|
||||
.unwrap_or("Unknown Chromecast")
|
||||
.split('-')
|
||||
.take_while(|part| part.len() != 32)
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
.trim()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
debug!(
|
||||
"Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})",
|
||||
friendly_name, host, port, uuid, model
|
||||
);
|
||||
|
||||
// Build UDN and check cache
|
||||
let udn = format!("uuid:{}", uuid);
|
||||
let default_max_age = 1800u64;
|
||||
|
||||
// Pour Chromecast, on utilise un max_age par défaut car mDNS n'a pas ce concept
|
||||
let default_max_age = 1800u64; // 30 minutes
|
||||
|
||||
// Check cache to avoid redundant updates
|
||||
if !UDNRegistry::should_fetch(self.udn_cache.clone(), &udn, default_max_age) {
|
||||
debug!("Chromecast {} recently seen, skipping", udn);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create RendererInfo for the registry
|
||||
let renderer_info = build_renderer_info(
|
||||
&uuid,
|
||||
&friendly_name,
|
||||
&host,
|
||||
port,
|
||||
model.as_deref(),
|
||||
manufacturer.as_deref(),
|
||||
Some("Google Inc."),
|
||||
);
|
||||
|
||||
// Register the renderer
|
||||
self.device_registry
|
||||
.write()
|
||||
.expect("DeviceRegistry mutex lock failed")
|
||||
|
||||
@@ -49,6 +49,8 @@ pub enum ControlPointError {
|
||||
SnapshotError(String),
|
||||
#[error("Error on ControlPoint: {0}")]
|
||||
ControlPoint(String),
|
||||
#[error("Queue sync cancelled (superseded by a newer request)")]
|
||||
SyncCancelled,
|
||||
}
|
||||
|
||||
impl ControlPointError {
|
||||
@@ -56,6 +58,15 @@ impl ControlPointError {
|
||||
ControlPointError::UpnpOperationNotSupported(operation.to_string(), service.to_string())
|
||||
}
|
||||
|
||||
/// Returns true if the error is a transient transport-level failure that
|
||||
/// may succeed on retry (TCP refused, connection reset, timeout).
|
||||
///
|
||||
/// Protocol-level errors (UPnP fault, HTTP 4xx/5xx with a valid body)
|
||||
/// are **not** transient: the device understood the request and rejected it.
|
||||
pub fn is_transient_soap_error(&self) -> bool {
|
||||
matches!(self, ControlPointError::SoapAction(_))
|
||||
}
|
||||
|
||||
pub fn upnp_missing_return_value(value: &str) -> Self {
|
||||
ControlPointError::UpnpMissingReturnValue(value.to_string())
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ impl RendererEventBus {
|
||||
pub(crate) fn subscribe(&self) -> Receiver<RendererEvent> {
|
||||
let (tx, rx) = unbounded::<RendererEvent>();
|
||||
{
|
||||
let mut subscribers = self.subscribers.lock().unwrap();
|
||||
let mut subscribers = self.subscribers.lock().expect("event subscribers mutex poisoned");
|
||||
subscribers.push(tx);
|
||||
}
|
||||
rx
|
||||
@@ -27,7 +27,7 @@ impl RendererEventBus {
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn broadcast(&self, event: RendererEvent) {
|
||||
let mut subscribers = self.subscribers.lock().unwrap();
|
||||
let mut subscribers = self.subscribers.lock().expect("event subscribers mutex poisoned");
|
||||
subscribers.retain(|tx| tx.send(event.clone()).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -47,14 +47,14 @@ impl MediaServerEventBus {
|
||||
pub fn subscribe(&self) -> Receiver<MediaServerEvent> {
|
||||
let (tx, rx) = unbounded::<MediaServerEvent>();
|
||||
{
|
||||
let mut subscribers = self.subscribers.lock().unwrap();
|
||||
let mut subscribers = self.subscribers.lock().expect("event subscribers mutex poisoned");
|
||||
subscribers.push(tx);
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
||||
pub(crate) fn broadcast(&self, event: MediaServerEvent) {
|
||||
let mut subscribers = self.subscribers.lock().unwrap();
|
||||
let mut subscribers = self.subscribers.lock().expect("event subscribers mutex poisoned");
|
||||
subscribers.retain(|tx| tx.send(event.clone()).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +373,7 @@ pub struct MediaEntry {
|
||||
pub is_container: bool,
|
||||
pub class: String,
|
||||
pub resources: Vec<MediaResource>,
|
||||
pub child_count: Option<u32>,
|
||||
pub artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
pub genre: Option<String>,
|
||||
@@ -709,6 +710,7 @@ fn map_didl_entries(xml: &str) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
is_container: true,
|
||||
class: container.class,
|
||||
resources: Vec::new(),
|
||||
child_count: container.child_count.as_deref().and_then(|s| s.parse().ok()),
|
||||
artist: container.artist,
|
||||
album: None,
|
||||
genre: None,
|
||||
@@ -742,6 +744,7 @@ fn map_didl_entries(xml: &str) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
is_container: false,
|
||||
class: item.class,
|
||||
resources,
|
||||
child_count: None,
|
||||
artist: item.artist,
|
||||
album: item.album,
|
||||
genre: item.genre,
|
||||
|
||||
@@ -437,6 +437,12 @@ pub enum RendererEvent {
|
||||
QueueRefreshing {
|
||||
id: DeviceId,
|
||||
},
|
||||
QueueReadyToPlay {
|
||||
id: DeviceId,
|
||||
},
|
||||
QueueSyncCancelled {
|
||||
id: DeviceId,
|
||||
},
|
||||
BindingChanged {
|
||||
id: DeviceId,
|
||||
binding: Option<PlaylistBinding>,
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::DeviceIdentity;
|
||||
use crate::arylic_client::{
|
||||
ARYLIC_TCP_PORT, DEFAULT_TIMEOUT_SECS, send_command_no_response, send_command_optional,
|
||||
send_command_required,
|
||||
send_command_no_response, send_command_optional, send_command_required, ARYLIC_TCP_PORT,
|
||||
DEFAULT_TIMEOUT_SECS,
|
||||
};
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::linkplay_client::extract_linkplay_host;
|
||||
use crate::model::{PlaybackState, RendererInfo};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||
TransportControl, VolumeControl,
|
||||
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
QueueTransportControl, TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||
use crate::music_renderer::time_utils::{format_hhmmss, ms_to_seconds, parse_hhmmss_strict};
|
||||
use crate::queue::MusicQueue;
|
||||
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::music_renderer::HasQueue;
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::DeviceIdentity;
|
||||
|
||||
/// Raw response from Arylic MCU+PINFGET command
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -137,7 +137,7 @@ impl RendererFromMediaRendererInfo for ArylicTcpRenderer {
|
||||
impl ArylicTcpRenderer {
|
||||
/// Returns true if currently playing a continuous stream (radio without duration)
|
||||
pub fn is_continuous_stream(&self) -> bool {
|
||||
*self.continuous_stream.lock().unwrap()
|
||||
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned")
|
||||
}
|
||||
|
||||
/// Create an ArylicTcpRenderer with a shared queue (for HybridUpnpArylic)
|
||||
@@ -243,190 +243,44 @@ impl PlaybackStatus for ArylicTcpRenderer {
|
||||
|
||||
impl PlaybackPosition for ArylicTcpRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||
let info = match self.fetch_playback_info() {
|
||||
Ok(info) => {
|
||||
tracing::debug!("ArylicTcp fetch_playback_info returned: {:?}", info);
|
||||
info
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("ArylicTcp fetch_playback_info failed: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let info = self.fetch_playback_info().map_err(|e| {
|
||||
tracing::warn!("ArylicTcp fetch_playback_info failed: {}", e);
|
||||
e
|
||||
})?;
|
||||
|
||||
let mut position_info = info.position_info();
|
||||
tracing::debug!("ArylicTcp fetch_playback_info returned: {:?}", info);
|
||||
|
||||
let mut position = info.position_info();
|
||||
tracing::debug!(
|
||||
"ArylicTcp position_info: track_duration={:?}, rel_time={:?}, track_metadata={:?}, track_uri={:?}",
|
||||
position_info.track_duration,
|
||||
position_info.rel_time,
|
||||
position_info
|
||||
.track_metadata
|
||||
.as_ref()
|
||||
.map(|s| &s[..s.len().min(100)]),
|
||||
position_info.track_uri
|
||||
"ArylicTcp position_info: track_duration={:?}, rel_time={:?}",
|
||||
position.track_duration,
|
||||
position.rel_time,
|
||||
);
|
||||
|
||||
// Récupérer les métadonnées depuis la queue (avec protection contre diminution de durée)
|
||||
// Normalement current_index est toujours Some() si la queue n'est pas vide (règle métier)
|
||||
let mut queue_guard = self.queue.lock().unwrap();
|
||||
let queue_item = queue_guard.peek_current().ok().flatten();
|
||||
// Replace device metadata with queue metadata (queue is authoritative).
|
||||
crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position);
|
||||
|
||||
if let Some((current_item, _)) = queue_item {
|
||||
// Build DIDL metadata XML from cached/protected TrackMetadata
|
||||
if let Some(ref metadata) = current_item.metadata {
|
||||
tracing::debug!(
|
||||
"ArylicTcp playback_position: using queue metadata - title={:?}, artist={:?}, duration={:?}, is_stream={}",
|
||||
metadata.title,
|
||||
metadata.artist,
|
||||
metadata.duration,
|
||||
metadata.is_continuous_stream
|
||||
);
|
||||
position_info.track_metadata = Some(
|
||||
crate::music_renderer::musicrenderer::build_didl_lite_metadata(
|
||||
metadata,
|
||||
¤t_item.uri,
|
||||
¤t_item.protocol_info,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
tracing::warn!("ArylicTcp playback_position: queue item has no metadata");
|
||||
}
|
||||
position_info.track_uri = Some(current_item.uri.clone());
|
||||
} else {
|
||||
tracing::warn!("ArylicTcp playback_position: no current queue item");
|
||||
}
|
||||
drop(queue_guard);
|
||||
|
||||
Ok(position_info)
|
||||
Ok(position)
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererBackend for ArylicTcpRenderer {
|
||||
|
||||
impl QueueTransportControl for ArylicTcpRenderer {
|
||||
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.play_uri(&item.uri, "")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl HasQueue for ArylicTcpRenderer {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueTransportControl for ArylicTcpRenderer {
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
|
||||
let current_index = match queue.current_index()? {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
if queue.len()? > 0 {
|
||||
queue.set_index(Some(0))?;
|
||||
0
|
||||
} else {
|
||||
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let item = queue
|
||||
.get_item(current_index)?
|
||||
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
||||
|
||||
let uri = item.uri.clone();
|
||||
drop(queue);
|
||||
|
||||
self.play_uri(&uri, "")
|
||||
}
|
||||
|
||||
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
if !queue.advance()? {
|
||||
return Err(ControlPointError::QueueError("No next track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
if !queue.rewind()? {
|
||||
return Err(ControlPointError::QueueError("No previous track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
queue.set_index(Some(index))?;
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for ArylicTcpRenderer {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
self.queue.lock().unwrap().len()
|
||||
}
|
||||
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
self.queue.lock().unwrap().track_ids()
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
self.queue.lock().unwrap().id_to_position(id)
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
self.queue.lock().unwrap().position_to_id(id)
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
self.queue.lock().unwrap().current_track()
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
self.queue.lock().unwrap().current_index()
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
self.queue.lock().unwrap().queue_snapshot()
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().set_index(index)
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace_queue(items, current_index)
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().sync_queue(items)
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
self.queue.lock().unwrap().get_item(index)
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().replace_item(index, item)
|
||||
}
|
||||
|
||||
fn enqueue_items(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().enqueue_items(items, mode)
|
||||
impl HasContinuousStream for ArylicTcpRenderer {
|
||||
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
|
||||
&self.continuous_stream
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,96 @@
|
||||
// pmocontrol/src/capabilities.rs
|
||||
use anyhow::Result;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::queue::MusicQueue;
|
||||
use crate::{errors::ControlPointError, model::PlaybackState};
|
||||
use crate::queue::{MusicQueue, QueueBackend};
|
||||
use crate::{errors::ControlPointError, model::PlaybackState, PlaybackItem};
|
||||
|
||||
/// Backend-specific operations for renderers.
|
||||
/// Marker trait for renderer backends that own a `MusicQueue`.
|
||||
///
|
||||
/// This trait provides access to backend-specific resources like the queue.
|
||||
pub trait RendererBackend {
|
||||
/// Returns a reference to the queue associated with this backend.
|
||||
/// Implementing this trait automatically provides the full `QueueBackend`
|
||||
/// blanket implementation (see `queue/backend.rs`). Backends only need to
|
||||
/// return a reference to their `Arc<Mutex<MusicQueue>>` field.
|
||||
pub trait HasQueue {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>>;
|
||||
}
|
||||
|
||||
/// Marker trait for renderer backends that track stream continuity.
|
||||
///
|
||||
/// The flag is `true` while the renderer is playing a continuous stream
|
||||
/// (e.g. an internet radio station) and `false` for bounded media files.
|
||||
/// It is used by the watcher to decide whether auto-advance should be
|
||||
/// suppressed when playback stops.
|
||||
pub trait HasContinuousStream {
|
||||
fn continuous_stream(&self) -> &Arc<Mutex<bool>>;
|
||||
}
|
||||
|
||||
/// Queue-aware transport control operations.
|
||||
///
|
||||
/// These operations combine queue management with transport control,
|
||||
/// allowing navigation (next/previous) and track selection from the queue.
|
||||
#[allow(dead_code)]
|
||||
pub trait QueueTransportControl {
|
||||
/// Play the next track from the queue.
|
||||
fn play_next(&self) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Play the previous track from the queue.
|
||||
#[allow(dead_code)]
|
||||
fn play_previous(&self) -> Result<(), ControlPointError>;
|
||||
pub trait QueueTransportControl: HasQueue + HasContinuousStream {
|
||||
/// Play a specific item from the queue (backend-specific implementation).
|
||||
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Play from the queue at the current index (or initialize to 0 if not set).
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError>;
|
||||
/// This is the default implementation that handles queue navigation.
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue().lock().expect("queue mutex poisoned");
|
||||
|
||||
let current_index = match queue.current_index()? {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
if queue.len()? > 0 {
|
||||
queue.set_index(Some(0))?;
|
||||
0
|
||||
} else {
|
||||
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let item = queue
|
||||
.get_item(current_index)?
|
||||
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
||||
|
||||
drop(queue);
|
||||
|
||||
let is_stream = crate::music_renderer::is_continuous_stream(item.metadata.as_ref(), &item.uri);
|
||||
*self.continuous_stream().lock().expect("continuous_stream mutex poisoned") = is_stream;
|
||||
|
||||
self.play_item(&item)
|
||||
}
|
||||
|
||||
/// Play the next track from the queue.
|
||||
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue().lock().expect("queue mutex poisoned");
|
||||
if !queue.advance()? {
|
||||
return Err(ControlPointError::QueueError("No next track".into()));
|
||||
}
|
||||
}
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
/// Play the previous track from the queue.
|
||||
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue().lock().expect("queue mutex poisoned");
|
||||
if !queue.rewind()? {
|
||||
return Err(ControlPointError::QueueError("No previous track".into()));
|
||||
}
|
||||
}
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
/// Play from a specific index in the queue.
|
||||
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError>;
|
||||
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue().lock().expect("queue mutex poisoned");
|
||||
queue.set_index(Some(index))?;
|
||||
}
|
||||
self.play_from_queue()
|
||||
}
|
||||
}
|
||||
|
||||
/// Logical playback position across backends.
|
||||
@@ -47,52 +107,97 @@ pub struct PlaybackPositionInfo {
|
||||
pub track_metadata: Option<String>, // DIDL-Lite XML from GetPositionInfo
|
||||
pub track_uri: Option<String>, // Current track URI
|
||||
}
|
||||
/// Provides the current playback position and track metadata.
|
||||
///
|
||||
/// All time fields use the format `"HH:MM:SS"` (or `None` when unavailable).
|
||||
/// `track_metadata` carries a raw DIDL-Lite XML fragment returned by the device;
|
||||
/// callers that only need structured metadata should use `extract_track_metadata`
|
||||
/// from the watcher module instead.
|
||||
pub trait PlaybackPosition {
|
||||
/// Returns the current playback position information.
|
||||
///
|
||||
/// Returns `Err` if the renderer is unreachable or the query fails.
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError>;
|
||||
}
|
||||
|
||||
/// Generic abstraction for playback status (transport state).
|
||||
/// Generic abstraction for the current transport state.
|
||||
///
|
||||
/// For UPnP AV, this is backed by AVTransport::GetTransportInfo.
|
||||
/// For OpenHome, a future implementation will adapt from OH Info/Time.
|
||||
/// # Implementations
|
||||
///
|
||||
/// - **UPnP AV**: backed by `AVTransport::GetTransportInfo`.
|
||||
/// - **OpenHome**: adapted from OH `Info` / `Time` services.
|
||||
/// - **LinkPlay / Arylic**: mapped from the vendor status response.
|
||||
///
|
||||
/// # Postconditions
|
||||
///
|
||||
/// The returned `PlaybackState` must be one of the canonical values defined by
|
||||
/// the `PlaybackState` enum. Backend-specific states that have no canonical
|
||||
/// equivalent should be mapped to the closest approximation (e.g. "BUFFERING"
|
||||
/// → `PlaybackState::Transitioning`).
|
||||
pub trait PlaybackStatus {
|
||||
/// Returns the current transport state of the renderer.
|
||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError>;
|
||||
}
|
||||
|
||||
/// Abstraction générique des capacités de transport (lecture / pause / stop / seek)
|
||||
/// indépendamment du protocole sous-jacent (UPnP AV, OpenHome, ...).
|
||||
/// Generic transport control abstraction (play / pause / stop / seek),
|
||||
/// independent of the underlying protocol (UPnP AV, OpenHome, …).
|
||||
///
|
||||
/// # Invariants
|
||||
///
|
||||
/// - `play_uri` sets the active resource and begins playback atomically from the
|
||||
/// caller's perspective. Implementations may split this into two protocol steps
|
||||
/// (e.g. `SetAVTransportURI` + `Play` for UPnP AV) but the caller should not
|
||||
/// need to know.
|
||||
/// - `play` / `pause` / `stop` operate on whatever resource is currently loaded;
|
||||
/// they do not change the queue pointer.
|
||||
/// - `seek_rel_time` uses the format `"HH:MM:SS"`. Backends that do not support
|
||||
/// seeking should return `ControlPointError::NotSupported`.
|
||||
///
|
||||
/// # Relation to `QueueTransportControl`
|
||||
///
|
||||
/// `TransportControl` knows nothing about the queue. `QueueTransportControl`
|
||||
/// extends it with queue-aware navigation (`play_next`, `play_previous`, …).
|
||||
pub trait TransportControl {
|
||||
/// Set la ressource à lire (URI + métadonnées) et/ou commence la lecture.
|
||||
/// Load a resource (URI + DIDL-Lite metadata) and begin playback.
|
||||
///
|
||||
/// Selon l'implémentation, cette méthode peut soit :
|
||||
/// - faire un "Set...URI" + "Play" (cas UPnP AV),
|
||||
/// - ou configurer la file de lecture (cas OpenHome, etc.).
|
||||
/// Depending on the backend this may execute as a single atomic operation or as
|
||||
/// two sequential commands (set resource, then play).
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Démarre ou reprend la lecture.
|
||||
/// Start or resume playback of the currently loaded resource.
|
||||
fn play(&self) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Met la lecture en pause.
|
||||
/// Pause the current playback.
|
||||
fn pause(&self) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Arrête la lecture.
|
||||
/// Stop the current playback and release the loaded resource.
|
||||
fn stop(&self) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Seek à un temps relatif (HH:MM:SS) si supporté.
|
||||
/// Seek to a relative time position expressed as `"HH:MM:SS"`.
|
||||
///
|
||||
/// Returns `ControlPointError::NotSupported` when the backend does not
|
||||
/// implement seeking.
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError>;
|
||||
}
|
||||
|
||||
/// Abstraction générique des capacités de contrôle de volume / mute.
|
||||
/// Generic volume and mute control abstraction.
|
||||
///
|
||||
/// # Volume scale
|
||||
///
|
||||
/// Volume values are expressed on the native scale of each renderer.
|
||||
/// UPnP AV and OpenHome renderers typically use 0–100. Callers should
|
||||
/// not assume any particular scale; use the values returned by `volume()`
|
||||
/// as the baseline for relative adjustments.
|
||||
pub trait VolumeControl {
|
||||
/// Retourne le volume logique courant (échelle dépendante du renderer).
|
||||
/// Returns the current logical volume (renderer-specific scale).
|
||||
fn volume(&self) -> Result<u16, ControlPointError>;
|
||||
|
||||
/// Définit le volume logique (échelle dépendante du renderer).
|
||||
/// Sets the logical volume (renderer-specific scale).
|
||||
fn set_volume(&self, v: u16) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Indique si le renderer est muet (mute activé).
|
||||
/// Returns `true` when the renderer is muted.
|
||||
fn mute(&self) -> Result<bool, ControlPointError>;
|
||||
|
||||
/// Active ou désactive le mute.
|
||||
/// Enables (`true`) or disables (`false`) mute.
|
||||
fn set_mute(&self, m: bool) -> Result<(), ControlPointError>;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! are wrapped in sync calls using smol::block_on for compatibility with
|
||||
//! the existing sync trait interfaces.
|
||||
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex, Once};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
@@ -22,11 +23,12 @@ use crate::discovery::chromecast_discovery::{
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::model::{PlaybackState, RendererInfo};
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||
TransportControl, VolumeControl,
|
||||
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
QueueTransportControl, TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||
use crate::music_renderer::time_utils::{format_hhmmss_f64, parse_hhmmss_strict};
|
||||
use crate::music_renderer::HasQueue;
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::DeviceIdentity;
|
||||
@@ -34,7 +36,7 @@ use crate::DeviceIdentity;
|
||||
use rust_cast::{
|
||||
channels::{
|
||||
heartbeat::HeartbeatResponse,
|
||||
media::{Media, PlayerState as CastPlayerState, StreamType},
|
||||
media::{Media, PlayerState as CastPlayerState, StatusEntry, StreamType},
|
||||
receiver::CastDeviceApp,
|
||||
},
|
||||
CastDevice, ChannelMessage,
|
||||
@@ -170,7 +172,51 @@ impl RendererFromMediaRendererInfo for ChromecastRenderer {
|
||||
impl ChromecastRenderer {
|
||||
/// Returns true if currently playing a continuous stream (radio without duration)
|
||||
pub fn is_continuous_stream(&self) -> bool {
|
||||
*self.continuous_stream.lock().unwrap()
|
||||
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned")
|
||||
}
|
||||
|
||||
/// Returns `(transport_id, media_entry)` for the currently active Cast session.
|
||||
///
|
||||
/// This encapsulates the repeated sequence:
|
||||
/// connect device → get receiver status → get active app → connect to app → get media status
|
||||
///
|
||||
/// Used by all transport operations (play/pause/stop/seek) and by
|
||||
/// playback_state/playback_position to avoid duplicating this boilerplate.
|
||||
fn get_active_media_entry<'d>(
|
||||
&self,
|
||||
device: &'d CastDevice<'d>,
|
||||
) -> Result<(String, StatusEntry), ControlPointError> {
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
})?;
|
||||
|
||||
let app = status
|
||||
.applications
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError("No active app found".into()))?;
|
||||
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||
})?;
|
||||
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||
})?;
|
||||
|
||||
let entry = media_status
|
||||
.entries
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError("No media session found".into()))?;
|
||||
|
||||
Ok((app.transport_id, entry))
|
||||
}
|
||||
|
||||
/// Connect to the device with retry on connection failures.
|
||||
@@ -201,15 +247,6 @@ impl TransportControl for ChromecastRenderer {
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||
debug!("ChromecastRenderer: play_uri({})", uri);
|
||||
|
||||
// Détecte si l'URL est un flux continu
|
||||
let is_stream = crate::music_renderer::is_continuous_stream_url(uri);
|
||||
*self.continuous_stream.lock().unwrap() = is_stream;
|
||||
tracing::debug!(
|
||||
"ChromecastRenderer play_uri: URI={}, continuous_stream={}",
|
||||
uri,
|
||||
is_stream
|
||||
);
|
||||
|
||||
// Signal any existing play thread to stop
|
||||
if let Ok(mut stop) = self.stop_signal.lock() {
|
||||
*stop = true;
|
||||
@@ -376,186 +413,53 @@ impl TransportControl for ChromecastRenderer {
|
||||
|
||||
fn play(&self) -> Result<(), ControlPointError> {
|
||||
debug!("ChromecastRenderer: play()");
|
||||
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
// Get receiver status to find the active app
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
})?;
|
||||
|
||||
let app = status
|
||||
.applications
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||
|
||||
// Connect to the app
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||
})?;
|
||||
|
||||
// Get media status
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||
})?;
|
||||
|
||||
let media_entry = media_status
|
||||
.entries
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||
|
||||
// Send play command
|
||||
let (transport_id, entry) = self.get_active_media_entry(&device)?;
|
||||
device
|
||||
.media
|
||||
.play(app.transport_id.as_str(), media_entry.media_session_id)
|
||||
.play(transport_id.as_str(), entry.media_session_id)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to play: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), ControlPointError> {
|
||||
debug!("ChromecastRenderer: pause()");
|
||||
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
})?;
|
||||
|
||||
let app = status
|
||||
.applications
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||
})?;
|
||||
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||
})?;
|
||||
|
||||
let media_entry = media_status
|
||||
.entries
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||
|
||||
let (transport_id, entry) = self.get_active_media_entry(&device)?;
|
||||
device
|
||||
.media
|
||||
.pause(app.transport_id.as_str(), media_entry.media_session_id)
|
||||
.pause(transport_id.as_str(), entry.media_session_id)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to pause: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<(), ControlPointError> {
|
||||
debug!("ChromecastRenderer: stop()");
|
||||
|
||||
// Signal the play thread to stop
|
||||
// Signal the play thread to stop first.
|
||||
// The thread terminates on its own; play_uri() will wait for it if needed.
|
||||
if let Ok(mut stop) = self.stop_signal.lock() {
|
||||
*stop = true;
|
||||
}
|
||||
|
||||
// Note: We don't wait for the thread here as stop() should be quick.
|
||||
// The thread will terminate on its own when it checks stop_signal.
|
||||
// If a new play_uri() is called, it will properly wait for this thread.
|
||||
|
||||
// Also send stop command to the device
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
})?;
|
||||
|
||||
let app = status
|
||||
.applications
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||
})?;
|
||||
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||
})?;
|
||||
|
||||
let media_entry = media_status
|
||||
.entries
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||
|
||||
let (transport_id, entry) = self.get_active_media_entry(&device)?;
|
||||
device
|
||||
.media
|
||||
.stop(app.transport_id.as_str(), media_entry.media_session_id)
|
||||
.stop(transport_id.as_str(), entry.media_session_id)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to stop: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||
debug!("ChromecastRenderer: seek_rel_time({})", hhmmss);
|
||||
|
||||
let total_seconds = parse_hhmmss_strict(hhmmss)? as f32;
|
||||
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
})?;
|
||||
|
||||
let app = status
|
||||
.applications
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||
})?;
|
||||
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||
})?;
|
||||
|
||||
let media_entry = media_status
|
||||
.entries
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||
|
||||
let (transport_id, entry) = self.get_active_media_entry(&device)?;
|
||||
device
|
||||
.media
|
||||
.seek(
|
||||
app.transport_id.as_str(),
|
||||
media_entry.media_session_id,
|
||||
Some(total_seconds),
|
||||
None,
|
||||
)
|
||||
.seek(transport_id.as_str(), entry.media_session_id, Some(total_seconds), None)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to seek: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -564,125 +468,57 @@ impl PlaybackStatus for ChromecastRenderer {
|
||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||
let device = self.connect_with_retry()?;
|
||||
|
||||
// Get receiver status to find the active app
|
||||
// If no app is running there is no media — return NoMedia without error.
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
})?;
|
||||
tracing::debug!("Chromecast playback_state: {} apps running", status.applications.len());
|
||||
|
||||
tracing::debug!(
|
||||
"Chromecast playback_state: {} apps running",
|
||||
status.applications.len()
|
||||
);
|
||||
if status.applications.is_empty() {
|
||||
tracing::debug!("Chromecast playback_state: no apps running, returning NoMedia");
|
||||
return Ok(PlaybackState::NoMedia);
|
||||
}
|
||||
|
||||
// If no app is running, return NoMedia
|
||||
let app = match status.applications.first() {
|
||||
Some(app) => {
|
||||
tracing::debug!("Chromecast playback_state: app={}", app.display_name);
|
||||
app
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("Chromecast playback_state: no apps running, returning NoMedia");
|
||||
return Ok(PlaybackState::NoMedia);
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to the app
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||
})?;
|
||||
|
||||
// Get media status
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||
})?;
|
||||
|
||||
tracing::debug!(
|
||||
"Chromecast playback_state: {} media entries",
|
||||
media_status.entries.len()
|
||||
);
|
||||
|
||||
// If no media entry, return NoMedia
|
||||
let media_entry = match media_status.entries.first() {
|
||||
Some(entry) => {
|
||||
match self.get_active_media_entry(&device) {
|
||||
Ok((_, entry)) => {
|
||||
tracing::debug!(
|
||||
"Chromecast playback_state: player_state={:?}, current_time={:?}",
|
||||
entry.player_state,
|
||||
entry.current_time
|
||||
entry.player_state, entry.current_time
|
||||
);
|
||||
entry
|
||||
Ok(map_player_state(&entry.player_state))
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("Chromecast playback_state: no media entries, returning NoMedia");
|
||||
return Ok(PlaybackState::NoMedia);
|
||||
// No media session → device is idle
|
||||
Err(_) => {
|
||||
tracing::debug!("Chromecast playback_state: no media session, returning NoMedia");
|
||||
Ok(PlaybackState::NoMedia)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(map_player_state(&media_entry.player_state))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackPosition for ChromecastRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||
let device = self.connect_with_retry()?;
|
||||
let (_, entry) = self.get_active_media_entry(&device)?;
|
||||
|
||||
// Get receiver status to find the active app
|
||||
let status = device.receiver.get_status().map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||
})?;
|
||||
let rel_time = entry.current_time.map(|t| format_hhmmss_f64(t as f64));
|
||||
let track_duration = entry.media.as_ref().and_then(|m| m.duration).map(|d| format_hhmmss_f64(d as f64));
|
||||
let track_uri = entry.media.as_ref().map(|m| m.content_id.clone());
|
||||
|
||||
let app = status
|
||||
.applications
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||
|
||||
// Connect to the app
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||
})?;
|
||||
|
||||
// Get media status
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| {
|
||||
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||
})?;
|
||||
|
||||
let media_entry = media_status
|
||||
.entries
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||
|
||||
// Extract position information
|
||||
let rel_time = media_entry
|
||||
.current_time
|
||||
.map(|time| format_hhmmss_f64(time as f64));
|
||||
|
||||
let track_duration = media_entry
|
||||
.media
|
||||
.as_ref()
|
||||
.and_then(|m| m.duration)
|
||||
.map(|dur| format_hhmmss_f64(dur as f64));
|
||||
|
||||
let track_uri = media_entry.media.as_ref().map(|m| m.content_id.clone());
|
||||
|
||||
Ok(PlaybackPositionInfo {
|
||||
let mut position = PlaybackPositionInfo {
|
||||
track: Some(1),
|
||||
rel_time,
|
||||
abs_time: None,
|
||||
track_duration,
|
||||
track_metadata: None, // Chromecast doesn't use DIDL-Lite
|
||||
track_metadata: None,
|
||||
track_uri,
|
||||
})
|
||||
};
|
||||
|
||||
// Replace device metadata with queue metadata (queue is authoritative;
|
||||
// Chromecast does not return DIDL-Lite natively so the queue is the only source).
|
||||
crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position);
|
||||
|
||||
Ok(position)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,131 +634,22 @@ impl VolumeControl for ChromecastRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererBackend for ChromecastRenderer {
|
||||
|
||||
impl QueueTransportControl for ChromecastRenderer {
|
||||
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.play_uri(&item.uri, "")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl HasQueue for ChromecastRenderer {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueTransportControl for ChromecastRenderer {
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
|
||||
let current_index = match queue.current_index()? {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
if queue.len()? > 0 {
|
||||
queue.set_index(Some(0))?;
|
||||
0
|
||||
} else {
|
||||
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let item = queue
|
||||
.get_item(current_index)?
|
||||
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
||||
|
||||
let uri = item.uri.clone();
|
||||
drop(queue);
|
||||
|
||||
self.play_uri(&uri, "")
|
||||
}
|
||||
|
||||
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
if !queue.advance()? {
|
||||
return Err(ControlPointError::QueueError("No next track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
if !queue.rewind()? {
|
||||
return Err(ControlPointError::QueueError("No previous track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
queue.set_index(Some(index))?;
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for ChromecastRenderer {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
self.queue.lock().unwrap().len()
|
||||
}
|
||||
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
self.queue.lock().unwrap().track_ids()
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
self.queue.lock().unwrap().id_to_position(id)
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
self.queue.lock().unwrap().position_to_id(id)
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
self.queue.lock().unwrap().current_track()
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
self.queue.lock().unwrap().current_index()
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
self.queue.lock().unwrap().queue_snapshot()
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().set_index(index)
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace_queue(items, current_index)
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().sync_queue(items)
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
self.queue.lock().unwrap().get_item(index)
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().replace_item(index, item)
|
||||
}
|
||||
|
||||
fn enqueue_items(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().enqueue_items(items, mode)
|
||||
impl HasContinuousStream for ChromecastRenderer {
|
||||
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
|
||||
&self.continuous_stream
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
use std::fmt;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use ureq::Agent;
|
||||
|
||||
use crate::DeviceIdentity;
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::linkplay_client::{
|
||||
LinkPlayStatus, build_agent, extract_linkplay_host, fetch_status_for_host, percent_encode,
|
||||
build_agent, extract_linkplay_host, fetch_status_for_host, percent_encode, LinkPlayStatus,
|
||||
};
|
||||
use crate::model::{PlaybackState, RendererInfo};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||
TransportControl, VolumeControl,
|
||||
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
QueueTransportControl, TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||
use crate::music_renderer::time_utils::parse_hhmmss_strict;
|
||||
use crate::queue::MusicQueue;
|
||||
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::music_renderer::HasQueue;
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend};
|
||||
use crate::DeviceIdentity;
|
||||
|
||||
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3;
|
||||
|
||||
@@ -91,21 +91,12 @@ impl RendererFromMediaRendererInfo for LinkPlayRenderer {
|
||||
impl LinkPlayRenderer {
|
||||
/// Returns true if currently playing a continuous stream (radio without duration)
|
||||
pub fn is_continuous_stream(&self) -> bool {
|
||||
*self.continuous_stream.lock().unwrap()
|
||||
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned")
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for LinkPlayRenderer {
|
||||
fn play_uri(&self, uri: &str, _meta: &str) -> Result<(), ControlPointError> {
|
||||
// Détecte si l'URL est un flux continu
|
||||
let is_stream = crate::music_renderer::is_continuous_stream_url(uri);
|
||||
*self.continuous_stream.lock().unwrap() = is_stream;
|
||||
tracing::debug!(
|
||||
"LinkPlayRenderer play_uri: URI={}, continuous_stream={}",
|
||||
uri,
|
||||
is_stream
|
||||
);
|
||||
|
||||
let encoded = percent_encode(uri);
|
||||
self.send_player_command(&format!("play:{}", encoded))
|
||||
}
|
||||
@@ -155,155 +146,29 @@ impl PlaybackStatus for LinkPlayRenderer {
|
||||
|
||||
impl PlaybackPosition for LinkPlayRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||
let mut position_info = self.fetch_status()?.position_info();
|
||||
|
||||
// Use queue metadata instead of direct status metadata to benefit from duration protection
|
||||
let mut queue_guard = self.queue.lock().unwrap();
|
||||
let queue_item = queue_guard.peek_current().ok().flatten();
|
||||
|
||||
if let Some((current_item, _)) = queue_item {
|
||||
if let Some(ref metadata) = current_item.metadata {
|
||||
position_info.track_metadata = Some(
|
||||
crate::music_renderer::musicrenderer::build_didl_lite_metadata(
|
||||
metadata,
|
||||
¤t_item.uri,
|
||||
¤t_item.protocol_info,
|
||||
),
|
||||
);
|
||||
}
|
||||
position_info.track_uri = Some(current_item.uri.clone());
|
||||
}
|
||||
drop(queue_guard);
|
||||
|
||||
Ok(position_info)
|
||||
let mut position = self.fetch_status()?.position_info();
|
||||
// Replace device metadata with queue metadata (queue is authoritative).
|
||||
crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position);
|
||||
Ok(position)
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererBackend for LinkPlayRenderer {
|
||||
|
||||
impl QueueTransportControl for LinkPlayRenderer {
|
||||
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.play_uri(&item.uri, "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl HasQueue for LinkPlayRenderer {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueTransportControl for LinkPlayRenderer {
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
|
||||
let current_index = match queue.current_index()? {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
if queue.len()? > 0 {
|
||||
queue.set_index(Some(0))?;
|
||||
0
|
||||
} else {
|
||||
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let item = queue
|
||||
.get_item(current_index)?
|
||||
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
||||
|
||||
let uri = item.uri.clone();
|
||||
drop(queue);
|
||||
|
||||
self.play_uri(&uri, "")
|
||||
}
|
||||
|
||||
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
if !queue.advance()? {
|
||||
return Err(ControlPointError::QueueError("No next track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
if !queue.rewind()? {
|
||||
return Err(ControlPointError::QueueError("No previous track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
queue.set_index(Some(index))?;
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for LinkPlayRenderer {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
self.queue.lock().unwrap().len()
|
||||
}
|
||||
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
self.queue.lock().unwrap().track_ids()
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
self.queue.lock().unwrap().id_to_position(id)
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
self.queue.lock().unwrap().position_to_id(id)
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
self.queue.lock().unwrap().current_track()
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
self.queue.lock().unwrap().current_index()
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
self.queue.lock().unwrap().queue_snapshot()
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().set_index(index)
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace_queue(items, current_index)
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().sync_queue(items)
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
self.queue.lock().unwrap().get_item(index)
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().replace_item(index, item)
|
||||
}
|
||||
|
||||
fn enqueue_items(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().enqueue_items(items, mode)
|
||||
impl HasContinuousStream for LinkPlayRenderer {
|
||||
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
|
||||
&self.continuous_stream
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ mod upnp_renderer;
|
||||
mod openhome;
|
||||
mod openhome_renderer;
|
||||
|
||||
mod capabilities;
|
||||
pub mod capabilities;
|
||||
mod chromecast_renderer;
|
||||
|
||||
mod musicrenderer;
|
||||
@@ -18,13 +18,14 @@ pub mod watcher;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
HasContinuousStream, HasQueue, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
QueueTransportControl, TransportControl, VolumeControl,
|
||||
};
|
||||
pub use crate::music_renderer::musicrenderer::{MusicRenderer, PlaylistBinding};
|
||||
pub use crate::music_renderer::sleep_timer::SleepTimer;
|
||||
pub use crate::music_renderer::stream_detection::is_continuous_stream_url;
|
||||
pub use crate::music_renderer::stream_detection::{is_continuous_stream, is_continuous_stream_url};
|
||||
use crate::{
|
||||
RendererInfo, errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend,
|
||||
errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend, RendererInfo,
|
||||
};
|
||||
|
||||
pub trait RendererFromMediaRendererInfo {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||
TransportControl, VolumeControl,
|
||||
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
QueueTransportControl, TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::time_utils::{format_hhmmss_u32, parse_time_flexible};
|
||||
use crate::DeviceIdentity;
|
||||
@@ -15,6 +15,7 @@ use crate::music_renderer::openhome::{
|
||||
build_info_client, build_playlist_client, build_product_client, build_radio_client,
|
||||
build_time_client, build_volume_client,
|
||||
};
|
||||
use crate::music_renderer::HasQueue;
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::upnp_clients::{
|
||||
@@ -88,7 +89,7 @@ impl OpenHomeRenderer {
|
||||
|
||||
/// Returns true if currently playing a continuous stream (radio without duration)
|
||||
pub fn is_continuous_stream(&self) -> bool {
|
||||
*self.continuous_stream.lock().unwrap()
|
||||
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned")
|
||||
}
|
||||
|
||||
pub fn has_playlist(&self) -> bool {
|
||||
@@ -175,16 +176,15 @@ impl OpenHomeRenderer {
|
||||
/// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes.
|
||||
pub(crate) fn openhome_playlist_len(&self) -> Result<usize, ControlPointError> {
|
||||
// Use queue.len() which uses cached track_ids() internally
|
||||
let queue = self.queue.lock().unwrap();
|
||||
let queue = self.queue.lock().expect("queue mutex poisoned");
|
||||
queue.len()
|
||||
}
|
||||
|
||||
/// Retourne les IDs des pistes de la playlist OpenHome.
|
||||
/// Plus rapide que snapshot_openhome_playlist() car ne récupère pas les métadonnées.
|
||||
pub(crate) fn openhome_playlist_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
// Use the queue's cached track_ids() instead of direct id_array() call
|
||||
let queue = self.queue.lock().unwrap();
|
||||
if let MusicQueue::OpenHome(oh_queue) = &*queue {
|
||||
let queue = self.queue.lock().expect("queue mutex poisoned");
|
||||
if let Some(oh_queue) = queue.as_openhome() {
|
||||
oh_queue.track_ids()
|
||||
} else {
|
||||
Err(ControlPointError::QueueError(
|
||||
@@ -209,9 +209,8 @@ impl OpenHomeRenderer {
|
||||
let insert_after = match after_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
// Use the queue's cached track_ids() instead of direct id_array() call
|
||||
let queue = self.queue.lock().unwrap();
|
||||
if let MusicQueue::OpenHome(oh_queue) = &*queue {
|
||||
let queue = self.queue.lock().expect("queue mutex poisoned");
|
||||
if let Some(oh_queue) = queue.as_openhome() {
|
||||
oh_queue
|
||||
.track_ids()?
|
||||
.last()
|
||||
@@ -268,11 +267,6 @@ impl RendererFromMediaRendererInfo for OpenHomeRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererBackend for OpenHomeRenderer {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for OpenHomeRenderer {
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||
@@ -371,7 +365,7 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
|
||||
// Check cache first
|
||||
{
|
||||
let mut cache = self.position_cache.lock().unwrap();
|
||||
let mut cache = self.position_cache.lock().expect("position_cache mutex poisoned");
|
||||
|
||||
// Track calls for warning detection
|
||||
cache.calls_in_last_second.push(now);
|
||||
@@ -412,8 +406,8 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
let mut track_metadata_xml = None;
|
||||
|
||||
// Get track ID from queue (uses cached data)
|
||||
let queue_guard_for_id = self.queue.lock().unwrap();
|
||||
if let MusicQueue::OpenHome(oh_queue) = &*queue_guard_for_id {
|
||||
let queue_guard_for_id = self.queue.lock().expect("queue mutex poisoned");
|
||||
if let Some(oh_queue) = queue_guard_for_id.as_openhome() {
|
||||
match oh_queue.current_track() {
|
||||
Ok(id_opt) => track_id = id_opt,
|
||||
Err(err) => debug!(
|
||||
@@ -425,7 +419,7 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
drop(queue_guard_for_id);
|
||||
|
||||
// Use queue API to get current item with cached metadata
|
||||
let mut queue_guard = self.queue.lock().unwrap();
|
||||
let mut queue_guard = self.queue.lock().expect("queue mutex poisoned");
|
||||
if let Ok(Some((current_item, _))) = queue_guard.peek_current() {
|
||||
// Use metadata from queue cache (updated via OpenHome events)
|
||||
track_uri = Some(current_item.uri.clone());
|
||||
@@ -442,7 +436,7 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
}
|
||||
|
||||
// Check if the URI has changed to detect track changes
|
||||
let mut cached_uri = self.current_track_uri.lock().unwrap();
|
||||
let mut cached_uri = self.current_track_uri.lock().expect("current_track_uri mutex poisoned");
|
||||
let uri_changed = cached_uri.as_ref() != Some(¤t_item.uri);
|
||||
|
||||
if uri_changed {
|
||||
@@ -454,7 +448,7 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
|
||||
// Détecte si la nouvelle URL est un flux continu
|
||||
let is_stream = crate::music_renderer::is_continuous_stream_url(¤t_item.uri);
|
||||
*self.continuous_stream.lock().unwrap() = is_stream;
|
||||
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned") = is_stream;
|
||||
tracing::debug!("OpenHome URI changed, continuous_stream={}", is_stream);
|
||||
|
||||
*cached_uri = Some(current_item.uri.clone());
|
||||
@@ -490,7 +484,7 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
|
||||
// Update cache with fresh data
|
||||
{
|
||||
let mut cache = self.position_cache.lock().unwrap();
|
||||
let mut cache = self.position_cache.lock().expect("position_cache mutex poisoned");
|
||||
cache.last_position = Some(position_info.clone());
|
||||
cache.last_update = Some(now);
|
||||
}
|
||||
@@ -498,28 +492,6 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
Ok(position_info)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse duration from DIDL-Lite metadata XML (OpenHome version)
|
||||
#[allow(dead_code)]
|
||||
fn parse_didl_duration_openhome(didl: &str) -> Option<String> {
|
||||
// Search for duration attribute in <res> element
|
||||
let res_start = didl.find("<res ")?;
|
||||
let after_res = &didl[res_start..];
|
||||
let tag_close = after_res.find('>')?;
|
||||
let tag_attrs = &after_res[..tag_close];
|
||||
|
||||
if let Some(duration_start) = tag_attrs.find("duration=\"") {
|
||||
let duration_offset = duration_start + "duration=\"".len();
|
||||
if let Some(duration_end) = tag_attrs[duration_offset..].find('"') {
|
||||
let duration = &tag_attrs[duration_offset..duration_offset + duration_end];
|
||||
return Some(duration.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!("OpenHome: No duration found in DIDL metadata");
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState {
|
||||
match raw.trim().to_ascii_uppercase().as_str() {
|
||||
"PLAYING" => PlaybackState::Playing,
|
||||
@@ -531,6 +503,11 @@ pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState {
|
||||
}
|
||||
|
||||
impl QueueTransportControl for OpenHomeRenderer {
|
||||
fn play_item(&self, _item: &PlaybackItem) -> Result<(), ControlPointError> {
|
||||
let playlist = self.playlist_client_for("play_item")?;
|
||||
playlist.play()
|
||||
}
|
||||
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let queue = self
|
||||
@@ -555,50 +532,6 @@ impl QueueTransportControl for OpenHomeRenderer {
|
||||
playlist.play()
|
||||
}
|
||||
|
||||
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self
|
||||
.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?;
|
||||
let len = queue.len().unwrap_or(0);
|
||||
let current = queue.current_index().ok().flatten();
|
||||
let current_track_id = queue.current_track().ok().flatten();
|
||||
let all_ids = queue.track_ids().ok().unwrap_or_default();
|
||||
tracing::trace!(
|
||||
queue_len = len,
|
||||
current_index = ?current,
|
||||
current_track_id = ?current_track_id,
|
||||
all_track_ids = ?all_ids,
|
||||
"OpenHome play_next: advancing queue"
|
||||
);
|
||||
if !queue.advance()? {
|
||||
tracing::trace!(
|
||||
queue_len = len,
|
||||
current_index = ?current,
|
||||
"OpenHome play_next: advance() returned false — no next track"
|
||||
);
|
||||
return Err(ControlPointError::QueueError("No next track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self
|
||||
.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?;
|
||||
if !queue.rewind()? {
|
||||
return Err(ControlPointError::QueueError("No previous track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||
// For OpenHome, we need to convert index to track_id
|
||||
let track_id = {
|
||||
@@ -628,103 +561,44 @@ impl QueueTransportControl for OpenHomeRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for OpenHomeRenderer {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.len()
|
||||
impl HasQueue for OpenHomeRenderer {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.track_ids()
|
||||
impl HasContinuousStream for OpenHomeRenderer {
|
||||
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
|
||||
&self.continuous_stream
|
||||
}
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.id_to_position(id)
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.position_to_id(id)
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.current_track()
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.current_index()
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.queue_snapshot()
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.set_index(index)
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
impl OpenHomeRenderer {
|
||||
pub fn replace_queue_with_background(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.replace_queue(items, current_index)
|
||||
}
|
||||
.map_err(|_| ControlPointError::QueueError("Mutex poisoned".into()))?
|
||||
.replace_queue(items, current_index)?;
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.sync_queue(items)
|
||||
}
|
||||
let queue = self.queue.clone();
|
||||
std::thread::spawn(move || {
|
||||
debug!("🔄 OpenHome: préchargement métadonnées queue en background");
|
||||
if let Ok(mut queue) = queue.lock() {
|
||||
if let Ok(Some(idx)) = queue.current_index() {
|
||||
let end = std::cmp::min(idx + 10, queue.len().unwrap_or(0));
|
||||
for i in idx..end {
|
||||
let _ = queue.get_item(i);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
debug!("✅ OpenHome: préchargement métadonnées terminé");
|
||||
});
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.get_item(index)
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.replace_item(index, item)
|
||||
}
|
||||
|
||||
fn enqueue_items(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.enqueue_items(items, mode)
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ pub fn is_continuous_stream_url(url: &str) -> bool {
|
||||
|
||||
// Check cache first
|
||||
{
|
||||
let cache = STREAM_CACHE.lock().unwrap();
|
||||
let cache = STREAM_CACHE.lock().expect("stream cache mutex poisoned");
|
||||
if let Some(&cached_result) = cache.get(url) {
|
||||
trace!("Cache hit for {}: is_stream={}", url, cached_result);
|
||||
return cached_result;
|
||||
@@ -62,7 +62,7 @@ pub fn is_continuous_stream_url(url: &str) -> bool {
|
||||
|
||||
// Check if already being verified
|
||||
{
|
||||
let mut pending = PENDING_CHECKS.lock().unwrap();
|
||||
let mut pending = PENDING_CHECKS.lock().expect("pending checks mutex poisoned");
|
||||
if pending.contains(url) {
|
||||
debug!(
|
||||
"Stream detection already in progress for {}, returning false temporarily",
|
||||
@@ -93,13 +93,13 @@ pub fn is_continuous_stream_url(url: &str) -> bool {
|
||||
|
||||
// Store in cache
|
||||
{
|
||||
let mut cache = STREAM_CACHE.lock().unwrap();
|
||||
let mut cache = STREAM_CACHE.lock().expect("stream cache mutex poisoned");
|
||||
cache.insert(url_owned.clone(), result);
|
||||
}
|
||||
|
||||
// Remove from pending
|
||||
{
|
||||
let mut pending = PENDING_CHECKS.lock().unwrap();
|
||||
let mut pending = PENDING_CHECKS.lock().expect("pending checks mutex poisoned");
|
||||
pending.remove(&url_owned);
|
||||
}
|
||||
|
||||
@@ -185,12 +185,27 @@ fn check_stream_headers(url: &str) -> Result<bool, String> {
|
||||
|
||||
trace!(
|
||||
"Stream detection for {}: content-length={}, chunked={}, streaming_mime={}, is_stream={}",
|
||||
url, has_content_length, is_chunked, is_streaming_mime, is_stream
|
||||
url,
|
||||
has_content_length,
|
||||
is_chunked,
|
||||
is_streaming_mime,
|
||||
is_stream
|
||||
);
|
||||
|
||||
Ok(is_stream)
|
||||
}
|
||||
|
||||
/// Canonical check: returns `true` if this item should be treated as a continuous stream.
|
||||
///
|
||||
/// Checks `metadata.is_continuous_stream` first (already computed at ingest time),
|
||||
/// then falls back to the URL-based HTTP detection.
|
||||
///
|
||||
/// Use this function everywhere transport-layer code needs to decide whether playback is
|
||||
/// a continuous stream (radio) vs bounded media (file/album track).
|
||||
pub fn is_continuous_stream(metadata: Option<&crate::model::TrackMetadata>, uri: &str) -> bool {
|
||||
metadata.map(|m| m.is_continuous_stream).unwrap_or(false) || is_continuous_stream_url(uri)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::model::PlaybackState;
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||
TransportControl, VolumeControl,
|
||||
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
QueueTransportControl, TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::musicrenderer::{MusicRendererBackend, build_didl_lite_metadata};
|
||||
use crate::music_renderer::musicrenderer::{
|
||||
build_didl_lite_metadata, parse_didl_duration, MusicRendererBackend,
|
||||
};
|
||||
use crate::music_renderer::HasQueue;
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::upnp_clients::{
|
||||
AvTransportClient, ConnectionInfo, ConnectionManagerClient, PositionInfo, ProtocolInfo,
|
||||
@@ -114,7 +117,7 @@ impl UpnpRenderer {
|
||||
|
||||
/// Returns true if currently playing a continuous stream (radio without duration)
|
||||
pub fn is_continuous_stream(&self) -> bool {
|
||||
*self.continuous_stream.lock().unwrap()
|
||||
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,67 +177,27 @@ impl RendererFromMediaRendererInfo for UpnpRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererBackend for UpnpRenderer {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueTransportControl for UpnpRenderer {
|
||||
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
|
||||
// Get or initialize current index
|
||||
let current_index = match queue.current_index()? {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
if queue.len()? > 0 {
|
||||
queue.set_index(Some(0))?;
|
||||
0
|
||||
} else {
|
||||
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Get the item
|
||||
let item = queue
|
||||
.get_item(current_index)?
|
||||
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
||||
|
||||
drop(queue);
|
||||
|
||||
// Build metadata - handle optional TrackMetadata
|
||||
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
|
||||
let metadata = if let Some(ref track_metadata) = item.metadata {
|
||||
build_didl_lite_metadata(track_metadata, &item.uri, &item.protocol_info)
|
||||
} else {
|
||||
// Fallback to minimal DIDL-Lite if no metadata
|
||||
format!(
|
||||
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="0" parentID="-1" restricted="1"><res protocolInfo="{}">{}</res></item></DIDL-Lite>"#,
|
||||
item.protocol_info, item.uri
|
||||
)
|
||||
};
|
||||
|
||||
// Détecte si l'URL est un flux continu en interrogeant le serveur HTTP
|
||||
let is_stream = crate::music_renderer::is_continuous_stream_url(&item.uri);
|
||||
*self.continuous_stream.lock().unwrap() = is_stream;
|
||||
tracing::debug!(
|
||||
"UpnpRenderer play_from_queue: URI={}, continuous_stream={}",
|
||||
item.uri,
|
||||
is_stream
|
||||
);
|
||||
|
||||
// Parse et cache la durée du DIDL (fallback pour certains amplis)
|
||||
let duration = parse_didl_duration(&metadata);
|
||||
if let Some(ref dur) = duration {
|
||||
tracing::debug!("Caching duration from queue DIDL: {}", dur);
|
||||
*self.cached_duration.lock().unwrap() = Some(dur.clone());
|
||||
*self.cached_duration.lock().expect("cached_duration mutex poisoned") = Some(dur.clone());
|
||||
} else {
|
||||
tracing::debug!("No duration to cache from queue DIDL");
|
||||
*self.cached_duration.lock().unwrap() = None;
|
||||
*self.cached_duration.lock().expect("cached_duration mutex poisoned") = None;
|
||||
}
|
||||
|
||||
// UPNP: SetAVTransportURI + Play
|
||||
let avt = self.avtransport()?;
|
||||
avt.set_av_transport_uri(&item.uri, &metadata)?;
|
||||
avt.play(0, "1")?;
|
||||
@@ -242,132 +205,19 @@ impl QueueTransportControl for UpnpRenderer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
if !queue.advance()? {
|
||||
return Err(ControlPointError::QueueError("No next track".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
if !queue.rewind()? {
|
||||
return Err(ControlPointError::QueueError("No previous track".into()));
|
||||
}
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
|
||||
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
queue.set_index(Some(index))?;
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
impl HasQueue for UpnpRenderer {
|
||||
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for UpnpRenderer {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
self.queue.lock().unwrap().len()
|
||||
}
|
||||
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
self.queue.lock().unwrap().track_ids()
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
self.queue.lock().unwrap().id_to_position(id)
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
self.queue.lock().unwrap().position_to_id(id)
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
self.queue.lock().unwrap().current_track()
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
self.queue.lock().unwrap().current_index()
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
self.queue.lock().unwrap().queue_snapshot()
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().set_index(index)
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace_queue(items, current_index)
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().sync_queue(items)
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
self.queue.lock().unwrap().get_item(index)
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().replace_item(index, item)
|
||||
}
|
||||
|
||||
fn enqueue_items(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().enqueue_items(items, mode)
|
||||
impl HasContinuousStream for UpnpRenderer {
|
||||
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
|
||||
&self.continuous_stream
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse le DIDL-Lite pour extraire la durée du premier élément <res>
|
||||
fn parse_didl_duration(didl: &str) -> Option<String> {
|
||||
// Recherche de l'élément <res> (avec ou sans espace après)
|
||||
let res_start = didl
|
||||
.find("<res ")
|
||||
.or_else(|| didl.find("<res>"))
|
||||
.or_else(|| didl.find("<res\n"))
|
||||
.or_else(|| didl.find("<res\t"))?;
|
||||
|
||||
let after_res = &didl[res_start..];
|
||||
|
||||
// Recherche de l'attribut duration dans cet élément <res>
|
||||
// Il doit être avant la fermeture du tag (avant '>')
|
||||
if let Some(tag_close) = after_res.find('>') {
|
||||
let tag_attrs = &after_res[..tag_close];
|
||||
|
||||
if let Some(duration_start) = tag_attrs.find("duration=\"") {
|
||||
let duration_offset = duration_start + "duration=\"".len();
|
||||
if let Some(duration_end) = tag_attrs[duration_offset..].find('"') {
|
||||
let duration = &tag_attrs[duration_offset..duration_offset + duration_end];
|
||||
return Some(duration.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::warn!("No duration attribute found in DIDL <res> element");
|
||||
None
|
||||
}
|
||||
|
||||
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
|
||||
///
|
||||
/// Cette impl se base sur AVTransport (InstanceID = 0).
|
||||
@@ -381,23 +231,14 @@ impl TransportControl for UpnpRenderer {
|
||||
);
|
||||
}
|
||||
|
||||
// Détecte si l'URL est un flux continu en interrogeant le serveur HTTP
|
||||
let is_stream = crate::music_renderer::is_continuous_stream_url(uri);
|
||||
*self.continuous_stream.lock().unwrap() = is_stream;
|
||||
tracing::debug!(
|
||||
"UpnpRenderer play_uri: URI={}, continuous_stream={}",
|
||||
uri,
|
||||
is_stream
|
||||
);
|
||||
|
||||
// Parse le DIDL pour extraire la durée (fallback pour certains amplis)
|
||||
let duration = parse_didl_duration(meta);
|
||||
if let Some(ref dur) = duration {
|
||||
tracing::debug!("Caching duration from DIDL: {}", dur);
|
||||
*self.cached_duration.lock().unwrap() = Some(dur.clone());
|
||||
*self.cached_duration.lock().expect("cached_duration mutex poisoned") = Some(dur.clone());
|
||||
} else {
|
||||
tracing::debug!("No duration to cache from DIDL");
|
||||
*self.cached_duration.lock().unwrap() = None;
|
||||
*self.cached_duration.lock().expect("cached_duration mutex poisoned") = None;
|
||||
}
|
||||
|
||||
let avt = self.avtransport()?;
|
||||
@@ -476,57 +317,30 @@ impl PlaybackPosition for UpnpRenderer {
|
||||
);
|
||||
|
||||
// Normalize "00:00:00" or "0:00:00" to None (some renderers return this for unknown duration)
|
||||
let normalized_duration = raw.track_duration.as_ref().and_then(|d| {
|
||||
if d == "00:00:00" || d == "0:00:00" {
|
||||
None
|
||||
} else {
|
||||
Some(d.clone())
|
||||
}
|
||||
let track_duration = raw.track_duration.as_ref().and_then(|d| {
|
||||
if d == "00:00:00" || d == "0:00:00" { None } else { Some(d.clone()) }
|
||||
});
|
||||
|
||||
let track_duration = normalized_duration;
|
||||
|
||||
// Récupérer les métadonnées depuis la queue (avec protection contre diminution de durée)
|
||||
// plutôt que depuis GetPositionInfo qui peut retourner des métadonnées obsolètes
|
||||
let mut track_metadata_xml = None;
|
||||
let mut track_uri = raw.track_uri.clone();
|
||||
|
||||
let mut queue_guard = self.queue.lock().unwrap();
|
||||
|
||||
// Récupérer l'item courant de la queue
|
||||
// Normalement current_index est toujours Some() si la queue n'est pas vide (règle métier)
|
||||
let queue_item = queue_guard.peek_current().ok().flatten();
|
||||
|
||||
if let Some((current_item, _)) = queue_item {
|
||||
track_uri = Some(current_item.uri.clone());
|
||||
|
||||
// Build DIDL metadata XML from cached/protected TrackMetadata
|
||||
if let Some(ref metadata) = current_item.metadata {
|
||||
track_metadata_xml = Some(
|
||||
crate::music_renderer::musicrenderer::build_didl_lite_metadata(
|
||||
metadata,
|
||||
¤t_item.uri,
|
||||
¤t_item.protocol_info,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(queue_guard);
|
||||
|
||||
tracing::trace!(
|
||||
"UPnP playback_position: track_duration={:?}, rel_time={:?}, using_queue_metadata={}",
|
||||
track_duration,
|
||||
raw.rel_time,
|
||||
track_metadata_xml.is_some()
|
||||
);
|
||||
|
||||
Ok(PlaybackPositionInfo {
|
||||
let mut position = PlaybackPositionInfo {
|
||||
track: Some(raw.track),
|
||||
rel_time: raw.rel_time,
|
||||
abs_time: raw.abs_time,
|
||||
track_duration,
|
||||
track_metadata: track_metadata_xml,
|
||||
track_uri,
|
||||
})
|
||||
track_metadata: None,
|
||||
track_uri: raw.track_uri,
|
||||
};
|
||||
|
||||
// Replace device metadata with queue metadata (queue is authoritative and protected
|
||||
// against stale / decreasing durations for streams).
|
||||
crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position);
|
||||
|
||||
tracing::trace!(
|
||||
"UPnP playback_position: track_duration={:?}, rel_time={:?}, using_queue_metadata={}",
|
||||
position.track_duration,
|
||||
position.rel_time,
|
||||
position.track_metadata.is_some()
|
||||
);
|
||||
|
||||
Ok(position)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
@@ -2211,7 +2213,7 @@ async fn browse_container(
|
||||
title: e.title,
|
||||
class: e.class,
|
||||
is_container: e.is_container,
|
||||
child_count: None,
|
||||
child_count: e.child_count,
|
||||
artist: e.artist,
|
||||
album: e.album,
|
||||
album_art_uri,
|
||||
@@ -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(
|
||||
@@ -2496,15 +2517,17 @@ async fn search_server(
|
||||
title: e.title,
|
||||
class: e.class,
|
||||
is_container: e.is_container,
|
||||
child_count: None,
|
||||
child_count: e.child_count,
|
||||
artist: e.artist,
|
||||
album: e.album,
|
||||
album_art_uri: e.album_art_uri,
|
||||
})
|
||||
.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,
|
||||
|
||||
@@ -28,7 +28,85 @@
|
||||
//! - This identity is used by the sync helpers to preserve the current
|
||||
//! track across queue rebuilds when the MediaServer content changes.
|
||||
|
||||
use crate::{PlaybackItem, QueueSnapshot, errors::ControlPointError};
|
||||
use crate::music_renderer::HasQueue;
|
||||
use crate::queue::MusicQueue;
|
||||
use crate::{errors::ControlPointError, PlaybackItem, QueueSnapshot};
|
||||
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||
|
||||
/// Blanket implementation of QueueBackend for types that have a queue.
|
||||
/// All methods simply delegate to the underlying MusicQueue.
|
||||
impl<T: HasQueue> QueueBackend for T {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").len()
|
||||
}
|
||||
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").track_ids()
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").id_to_position(id)
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").position_to_id(id)
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").current_track()
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").current_index()
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").queue_snapshot()
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").set_index(index)
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace_queue(items, current_index)
|
||||
}
|
||||
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_queue(items, cancel_token, on_ready)
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").get_item(index)
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").replace_item(index, item)
|
||||
}
|
||||
|
||||
fn enqueue_items(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue().lock().expect("queue mutex poisoned").enqueue_items(items, mode)
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level enqueue mode.
|
||||
///
|
||||
@@ -107,7 +185,12 @@ pub trait QueueBackend {
|
||||
/// corresponds to the old current index track.
|
||||
/// If the old current index track is absent from the new queue,
|
||||
/// it is kept as the first item and the new items are appended after it.
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError>;
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Returns the item at `index`, if it exists.
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError>;
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::{
|
||||
queue::{MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot},
|
||||
DeviceId, DeviceIdentity, RendererInfo,
|
||||
};
|
||||
use std::sync::{atomic::AtomicBool, Arc};
|
||||
|
||||
/// Internal/local queue implementation.
|
||||
///
|
||||
@@ -269,25 +270,43 @@ impl QueueBackend for InternalQueue {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
use tracing::debug;
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
if items.is_empty() {
|
||||
return self.replace_queue(Vec::new(), None);
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
|
||||
let has_current = self.current_index().ok().flatten().is_some();
|
||||
if has_current {
|
||||
if let Some(f) = on_ready.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
let _ = self.replace_queue(Vec::new(), None);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Protéger les durées des streams contre la diminution
|
||||
let updated_items = self.protect_stream_durations(items);
|
||||
|
||||
// Récupérer l'item actuel
|
||||
let current = self.current_index.and_then(|idx| {
|
||||
self.items
|
||||
.get(idx)
|
||||
.map(|item| (idx, item.uri.clone(), item.didl_id.clone()))
|
||||
});
|
||||
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
|
||||
if let Some((_current_idx, current_uri, current_didl_id)) = current {
|
||||
// Chercher l'item actuel dans la nouvelle liste (par URI d'abord, puis par didl_id)
|
||||
let new_idx = updated_items
|
||||
.iter()
|
||||
.position(|item| item.uri == current_uri)
|
||||
@@ -298,34 +317,25 @@ impl QueueBackend for InternalQueue {
|
||||
});
|
||||
|
||||
if let Some(new_idx) = new_idx {
|
||||
// Item trouvé dans la nouvelle liste
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
current_uri = current_uri.as_str(),
|
||||
new_idx,
|
||||
"sync_queue: current item found in new playlist"
|
||||
);
|
||||
self.replace_queue(updated_items, Some(new_idx))
|
||||
self.replace_queue(updated_items, Some(new_idx))?;
|
||||
} else {
|
||||
// Item pas trouvé - cela ne devrait pas arriver si la playlist n'a pas changé
|
||||
// Loguer pour diagnostic
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
current_uri = current_uri.as_str(),
|
||||
current_didl_id = current_didl_id.as_str(),
|
||||
new_items_count = updated_items.len(),
|
||||
"sync_queue: current item NOT found in new playlist, preserving as first item"
|
||||
);
|
||||
let current_item = self.items[self.current_index.unwrap()].clone();
|
||||
let mut new_items = Vec::with_capacity(updated_items.len() + 1);
|
||||
new_items.push(current_item);
|
||||
new_items.extend(updated_items);
|
||||
self.replace_queue(new_items, Some(0))
|
||||
self.replace_queue(new_items, Some(0))?;
|
||||
}
|
||||
} else {
|
||||
// Pas d'item actuel
|
||||
self.replace_queue(updated_items, None)
|
||||
self.replace_queue(updated_items, None)?;
|
||||
}
|
||||
|
||||
if !has_current {
|
||||
if let Some(f) = on_ready.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enqueue_items(
|
||||
@@ -502,6 +512,6 @@ impl QueueFromRendererInfo for InternalQueue {
|
||||
}
|
||||
|
||||
fn to_backend(self) -> MusicQueue {
|
||||
MusicQueue::Internal(self)
|
||||
MusicQueue::from_internal(self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,28 +7,34 @@ mod snapshot;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub use backend::{EnqueueMode, QueueBackend};
|
||||
pub use music_queue::MusicQueue;
|
||||
pub use music_queue::{MusicQueue, SyncScheduleOutcome};
|
||||
pub use snapshot::{PlaybackItem, QueueSnapshot};
|
||||
|
||||
// Internal queue implementations - not part of the public API
|
||||
pub(crate) use interne::InternalQueue;
|
||||
pub(crate) use openhome::OpenHomeQueue;
|
||||
|
||||
use crate::{RendererInfo, errors::ControlPointError};
|
||||
use crate::music_renderer::time_utils::parse_time_flexible;
|
||||
use crate::{errors::ControlPointError, RendererInfo};
|
||||
|
||||
/// Returns true if `new_dur` < `old_dur` (both parseable as HH:MM:SS/MM:SS/SS).
|
||||
/// Used to protect stream durations from decreasing for the same track.
|
||||
pub(super) fn stream_duration_decreased(old_dur: &str, new_dur: &str) -> bool {
|
||||
match (parse_time_flexible(old_dur).ok(), parse_time_flexible(new_dur).ok()) {
|
||||
pub(crate) fn stream_duration_decreased(old_dur: &str, new_dur: &str) -> bool {
|
||||
match (
|
||||
parse_time_flexible(old_dur).ok(),
|
||||
parse_time_flexible(new_dur).ok(),
|
||||
) {
|
||||
(Some(old_secs), Some(new_secs)) => new_secs < old_secs,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if `new_dur` > `old_dur` (both parseable as HH:MM:SS/MM:SS/SS).
|
||||
pub(super) fn stream_duration_increased(old_dur: &str, new_dur: &str) -> bool {
|
||||
match (parse_time_flexible(old_dur).ok(), parse_time_flexible(new_dur).ok()) {
|
||||
pub(crate) fn stream_duration_increased(old_dur: &str, new_dur: &str) -> bool {
|
||||
match (
|
||||
parse_time_flexible(old_dur).ok(),
|
||||
parse_time_flexible(new_dur).ok(),
|
||||
) {
|
||||
(Some(old_secs), Some(new_secs)) => new_secs > old_secs,
|
||||
_ => false,
|
||||
}
|
||||
|
||||
@@ -3,25 +3,276 @@ use crate::queue::{
|
||||
EnqueueMode, InternalQueue, OpenHomeQueue, QueueBackend, QueueFromRendererInfo,
|
||||
};
|
||||
use crate::{PlaybackItem, QueueSnapshot, RendererInfo};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering::SeqCst},
|
||||
Arc, Mutex,
|
||||
};
|
||||
use std::thread;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncScheduleOutcome {
|
||||
Scheduled,
|
||||
AlreadyRunning,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MusicQueue {
|
||||
enum MusicQueueBackend {
|
||||
Internal(InternalQueue),
|
||||
OpenHome(OpenHomeQueue),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MusicQueue {
|
||||
backend: MusicQueueBackend,
|
||||
sync_in_progress: Arc<AtomicBool>,
|
||||
sync_pending: Arc<AtomicBool>,
|
||||
sync_cancel_token: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl MusicQueue {
|
||||
/// Creates a queue appropriate for the given renderer.
|
||||
/// This is the factory method used by QueueFromRendererInfo trait.
|
||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicQueue, ControlPointError> {
|
||||
if info.capabilities().has_oh_playlist() {
|
||||
Ok(MusicQueue::OpenHome(OpenHomeQueue::from_renderer_info(
|
||||
info,
|
||||
)?))
|
||||
let backend = if info.capabilities().has_oh_playlist() {
|
||||
MusicQueueBackend::OpenHome(OpenHomeQueue::from_renderer_info(info)?)
|
||||
} else {
|
||||
Ok(MusicQueue::Internal(InternalQueue::from_renderer_info(
|
||||
info,
|
||||
)?))
|
||||
MusicQueueBackend::Internal(InternalQueue::from_renderer_info(info)?)
|
||||
};
|
||||
|
||||
Ok(MusicQueue {
|
||||
backend,
|
||||
sync_in_progress: Arc::new(AtomicBool::new(false)),
|
||||
sync_pending: Arc::new(AtomicBool::new(false)),
|
||||
sync_cancel_token: Arc::new(AtomicBool::new(false)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a MusicQueue from an InternalQueue backend.
|
||||
pub fn from_internal(queue: InternalQueue) -> MusicQueue {
|
||||
MusicQueue {
|
||||
backend: MusicQueueBackend::Internal(queue),
|
||||
sync_in_progress: Arc::new(AtomicBool::new(false)),
|
||||
sync_pending: Arc::new(AtomicBool::new(false)),
|
||||
sync_cancel_token: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a MusicQueue from an OpenHomeQueue backend.
|
||||
pub fn from_openhome(queue: OpenHomeQueue) -> MusicQueue {
|
||||
MusicQueue {
|
||||
backend: MusicQueueBackend::OpenHome(queue),
|
||||
sync_in_progress: Arc::new(AtomicBool::new(false)),
|
||||
sync_pending: Arc::new(AtomicBool::new(false)),
|
||||
sync_cancel_token: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this is an OpenHome backend.
|
||||
pub fn is_openhome(&self) -> bool {
|
||||
matches!(self.backend, MusicQueueBackend::OpenHome(_))
|
||||
}
|
||||
|
||||
/// Returns a reference to the OpenHome queue if this is an OpenHome backend.
|
||||
pub fn as_openhome(&self) -> Option<&OpenHomeQueue> {
|
||||
match &self.backend {
|
||||
MusicQueueBackend::OpenHome(q) => Some(q),
|
||||
MusicQueueBackend::Internal(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the OpenHome queue if this is an OpenHome backend.
|
||||
pub fn as_openhome_mut(&mut self) -> Option<&mut OpenHomeQueue> {
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::OpenHome(q) => Some(q),
|
||||
MusicQueueBackend::Internal(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> Result<usize, ControlPointError> {
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.len(),
|
||||
MusicQueueBackend::OpenHome(q) => q.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schedule_sync(
|
||||
queue_arc: &Arc<Mutex<MusicQueue>>,
|
||||
renderer_id: &str,
|
||||
items: Vec<PlaybackItem>,
|
||||
pending_items_fn: Box<
|
||||
dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static,
|
||||
>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||
on_complete: Box<dyn Fn(usize) + Send + 'static>,
|
||||
) -> SyncScheduleOutcome {
|
||||
let (sync_in_progress, sync_pending, sync_cancel_token) = {
|
||||
let q = queue_arc.lock().expect("MusicQueue mutex poisoned");
|
||||
(
|
||||
Arc::clone(&q.sync_in_progress),
|
||||
Arc::clone(&q.sync_pending),
|
||||
Arc::clone(&q.sync_cancel_token),
|
||||
)
|
||||
};
|
||||
|
||||
if sync_in_progress.swap(true, SeqCst) {
|
||||
sync_cancel_token.store(true, SeqCst);
|
||||
sync_pending.store(true, SeqCst);
|
||||
return SyncScheduleOutcome::AlreadyRunning;
|
||||
}
|
||||
|
||||
sync_cancel_token.store(false, SeqCst);
|
||||
sync_pending.store(false, SeqCst);
|
||||
|
||||
let queue_arc = Arc::clone(queue_arc);
|
||||
let thread_name = format!("queue-sync-{}", renderer_id);
|
||||
|
||||
thread::Builder::new()
|
||||
.name(thread_name)
|
||||
.spawn(move || {
|
||||
// Protocol for the three AtomicBools:
|
||||
// sync_in_progress : set to true before spawn, cleared on Drop via Guard.
|
||||
// sync_pending : set to true by a concurrent caller that arrives while
|
||||
// a sync is already running. The worker re-fetches items
|
||||
// and loops when it detects this flag on exit.
|
||||
// sync_cancel_token: set to true when a new sync request interrupts an
|
||||
// in-progress one. Passed into QueueBackend::sync_queue
|
||||
// so it can abort early.
|
||||
struct Guard(Arc<AtomicBool>);
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, SeqCst);
|
||||
}
|
||||
}
|
||||
let _guard = Guard(Arc::clone(&sync_in_progress));
|
||||
// Error policy: sync errors are logged by sync_worker_loop (warn level) and
|
||||
// the thread exits normally. No restart — a new sync can be scheduled via
|
||||
// schedule_sync. The Guard Drop clears sync_in_progress unconditionally,
|
||||
// even on panic, keeping the AtomicBool protocol consistent.
|
||||
tracing::debug!(thread = %std::thread::current().name().unwrap_or("?"), "queue-sync thread started");
|
||||
Self::sync_worker_loop(
|
||||
queue_arc,
|
||||
items,
|
||||
pending_items_fn,
|
||||
on_ready,
|
||||
on_complete,
|
||||
sync_pending,
|
||||
sync_cancel_token,
|
||||
);
|
||||
tracing::debug!(thread = %std::thread::current().name().unwrap_or("?"), "queue-sync thread done");
|
||||
})
|
||||
.expect("Failed to spawn queue-sync thread");
|
||||
|
||||
SyncScheduleOutcome::Scheduled
|
||||
}
|
||||
|
||||
/// Inner loop executed by the sync worker thread.
|
||||
///
|
||||
/// Runs at least once with `initial_items`. If a new sync request arrives while the
|
||||
/// loop is running (`sync_pending` becomes true), it re-fetches items via
|
||||
/// `pending_items_fn` and iterates again, allowing the latest playlist state to win.
|
||||
fn sync_worker_loop(
|
||||
queue_arc: Arc<Mutex<MusicQueue>>,
|
||||
initial_items: Vec<PlaybackItem>,
|
||||
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send>,
|
||||
initial_on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
on_complete: Box<dyn Fn(usize) + Send>,
|
||||
sync_pending: Arc<AtomicBool>,
|
||||
sync_cancel_token: Arc<AtomicBool>,
|
||||
) {
|
||||
let mut current_items = initial_items;
|
||||
let mut current_on_ready = Some(initial_on_ready);
|
||||
let mut on_complete = Some(on_complete);
|
||||
|
||||
loop {
|
||||
sync_pending.store(false, SeqCst);
|
||||
sync_cancel_token.store(false, SeqCst);
|
||||
|
||||
// Extract the real on_ready BEFORE locking the queue.
|
||||
// on_ready may call play_from_queue() which re-locks the queue,
|
||||
// so we must NOT call it while holding queue_arc.
|
||||
let real_on_ready = current_on_ready.take().flatten();
|
||||
let on_ready_triggered = Arc::new(AtomicBool::new(false));
|
||||
let proxy_on_ready: Option<Box<dyn FnOnce() + Send + 'static>> =
|
||||
real_on_ready.as_ref().map(|_| {
|
||||
let flag = Arc::clone(&on_ready_triggered);
|
||||
Box::new(move || {
|
||||
flag.store(true, SeqCst);
|
||||
}) as Box<dyn FnOnce() + Send + 'static>
|
||||
});
|
||||
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
items = current_items.len(),
|
||||
has_on_ready = real_on_ready.is_some(),
|
||||
"queue-sync: calling sync_queue"
|
||||
);
|
||||
|
||||
let result = {
|
||||
let mut q = queue_arc.lock().expect("MusicQueue mutex poisoned");
|
||||
<MusicQueue as QueueBackend>::sync_queue(
|
||||
&mut q,
|
||||
current_items,
|
||||
&sync_cancel_token,
|
||||
proxy_on_ready,
|
||||
)
|
||||
};
|
||||
// Queue lock is released here.
|
||||
// Now safe to call on_ready (which may re-lock the queue).
|
||||
let carry_on_ready = if on_ready_triggered.load(SeqCst) {
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
"queue-sync: on_ready triggered, calling callback"
|
||||
);
|
||||
if let Some(f) = real_on_ready {
|
||||
f();
|
||||
}
|
||||
None
|
||||
} else {
|
||||
if real_on_ready.is_some() {
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
"queue-sync: on_ready not triggered, carrying to next attempt"
|
||||
);
|
||||
}
|
||||
real_on_ready
|
||||
};
|
||||
|
||||
match result {
|
||||
Err(ControlPointError::SyncCancelled) => {
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
"queue-sync: cancelled"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("queue-sync error: {}", e);
|
||||
}
|
||||
Ok(()) => {
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
"queue-sync: completed successfully"
|
||||
);
|
||||
if let Some(cb) = on_complete.take() {
|
||||
let queue_len = queue_arc.lock().expect("MusicQueue mutex poisoned").len().unwrap_or(0);
|
||||
cb(queue_len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !sync_pending.load(SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
match pending_items_fn() {
|
||||
Ok(new_items) => {
|
||||
current_items = new_items;
|
||||
current_on_ready = Some(carry_on_ready);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("queue-sync pending re-fetch error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,58 +280,58 @@ impl MusicQueue {
|
||||
impl QueueBackend for MusicQueue {
|
||||
// Primitives
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.len(),
|
||||
MusicQueue::OpenHome(q) => q.len(),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.len(),
|
||||
MusicQueueBackend::OpenHome(q) => q.len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.track_ids(),
|
||||
MusicQueue::OpenHome(q) => q.track_ids(),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.track_ids(),
|
||||
MusicQueueBackend::OpenHome(q) => q.track_ids(),
|
||||
}
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.id_to_position(id),
|
||||
MusicQueue::OpenHome(q) => q.id_to_position(id),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.id_to_position(id),
|
||||
MusicQueueBackend::OpenHome(q) => q.id_to_position(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.position_to_id(id),
|
||||
MusicQueue::OpenHome(q) => q.position_to_id(id),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.position_to_id(id),
|
||||
MusicQueueBackend::OpenHome(q) => q.position_to_id(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.current_track(),
|
||||
MusicQueue::OpenHome(q) => q.current_track(),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.current_track(),
|
||||
MusicQueueBackend::OpenHome(q) => q.current_track(),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.current_index(),
|
||||
MusicQueue::OpenHome(q) => q.current_index(),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.current_index(),
|
||||
MusicQueueBackend::OpenHome(q) => q.current_index(),
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.queue_snapshot(),
|
||||
MusicQueue::OpenHome(q) => q.queue_snapshot(),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.queue_snapshot(),
|
||||
MusicQueueBackend::OpenHome(q) => q.queue_snapshot(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.set_index(index),
|
||||
MusicQueue::OpenHome(q) => q.set_index(index),
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.set_index(index),
|
||||
MusicQueueBackend::OpenHome(q) => q.set_index(index),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,30 +340,35 @@ impl QueueBackend for MusicQueue {
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.replace_queue(items, current_index),
|
||||
MusicQueue::OpenHome(q) => q.replace_queue(items, current_index),
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.replace_queue(items, current_index),
|
||||
MusicQueueBackend::OpenHome(q) => q.replace_queue(items, current_index),
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.sync_queue(items),
|
||||
MusicQueue::OpenHome(q) => q.sync_queue(items),
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.sync_queue(items, cancel_token, on_ready),
|
||||
MusicQueueBackend::OpenHome(q) => q.sync_queue(items, cancel_token, on_ready),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.get_item(index),
|
||||
MusicQueue::OpenHome(q) => q.get_item(index),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.get_item(index),
|
||||
MusicQueueBackend::OpenHome(q) => q.get_item(index),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.replace_item(index, item),
|
||||
MusicQueue::OpenHome(q) => q.replace_item(index, item),
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.replace_item(index, item),
|
||||
MusicQueueBackend::OpenHome(q) => q.replace_item(index, item),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,59 +377,59 @@ impl QueueBackend for MusicQueue {
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.enqueue_items(items, mode),
|
||||
MusicQueue::OpenHome(q) => q.enqueue_items(items, mode),
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.enqueue_items(items, mode),
|
||||
MusicQueueBackend::OpenHome(q) => q.enqueue_items(items, mode),
|
||||
}
|
||||
}
|
||||
|
||||
// Optimized helpers
|
||||
fn clear_queue(&mut self) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.clear_queue(),
|
||||
MusicQueue::OpenHome(q) => q.clear_queue(),
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.clear_queue(),
|
||||
MusicQueueBackend::OpenHome(q) => q.clear_queue(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> Result<bool, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.is_empty(),
|
||||
MusicQueue::OpenHome(q) => q.is_empty(),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.is_empty(),
|
||||
MusicQueueBackend::OpenHome(q) => q.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.upcoming_len(),
|
||||
MusicQueue::OpenHome(q) => q.upcoming_len(),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.upcoming_len(),
|
||||
MusicQueueBackend::OpenHome(q) => q.upcoming_len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.upcoming_items(),
|
||||
MusicQueue::OpenHome(q) => q.upcoming_items(),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.upcoming_items(),
|
||||
MusicQueueBackend::OpenHome(q) => q.upcoming_items(),
|
||||
}
|
||||
}
|
||||
|
||||
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.peek_current(),
|
||||
MusicQueue::OpenHome(q) => q.peek_current(),
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.peek_current(),
|
||||
MusicQueueBackend::OpenHome(q) => q.peek_current(),
|
||||
}
|
||||
}
|
||||
|
||||
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.dequeue_next(),
|
||||
MusicQueue::OpenHome(q) => q.dequeue_next(),
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.dequeue_next(),
|
||||
MusicQueueBackend::OpenHome(q) => q.dequeue_next(),
|
||||
}
|
||||
}
|
||||
|
||||
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.append_or_init_index(items),
|
||||
MusicQueue::OpenHome(q) => q.append_or_init_index(items),
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.append_or_init_index(items),
|
||||
MusicQueueBackend::OpenHome(q) => q.append_or_init_index(items),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
use std::usize;
|
||||
|
||||
@@ -170,6 +170,8 @@ pub struct OpenHomeQueue {
|
||||
/// Permet de maintenir des métadonnées à jour même si le service OpenHome
|
||||
/// ne permet pas de les modifier directement.
|
||||
metadata_cache: Mutex<HashMap<u32, Option<crate::model::TrackMetadata>>>,
|
||||
/// Cache URI by track ID for fast path matching
|
||||
uri_by_id: Mutex<HashMap<u32, String>>,
|
||||
/// Cache for track IDs to avoid redundant IdArray SOAP calls
|
||||
track_ids_cache: Arc<Mutex<TrackIdsCache>>,
|
||||
/// Cache for current track ID to avoid redundant Id SOAP calls
|
||||
@@ -178,6 +180,16 @@ pub struct OpenHomeQueue {
|
||||
read_list_cache: Arc<Mutex<ReadListCache>>,
|
||||
}
|
||||
|
||||
/// Fast path result for queue sync optimization
|
||||
enum FastPathResult {
|
||||
/// New items are an append to the current queue
|
||||
AppendOnly { new_items: Vec<PlaybackItem> },
|
||||
/// Items were deleted from the end
|
||||
DeleteFromEnd { delete_ids: Vec<u32> },
|
||||
/// No fast path possible - need full LCS sync
|
||||
NeedFullSync,
|
||||
}
|
||||
|
||||
impl OpenHomeQueue {
|
||||
pub fn new(
|
||||
renderer_id: DeviceId,
|
||||
@@ -191,6 +203,7 @@ impl OpenHomeQueue {
|
||||
info_client,
|
||||
product_client,
|
||||
metadata_cache: Mutex::new(HashMap::new()),
|
||||
uri_by_id: Mutex::new(HashMap::new()),
|
||||
track_ids_cache: Arc::new(Mutex::new(TrackIdsCache::new())),
|
||||
current_track_id_cache: Arc::new(Mutex::new(CurrentTrackIdCache::new())),
|
||||
read_list_cache: Arc::new(Mutex::new(ReadListCache::new())),
|
||||
@@ -220,15 +233,126 @@ impl OpenHomeQueue {
|
||||
|
||||
/// Invalide les caches track_ids et read_list (après insert/delete sans impact sur la piste courante).
|
||||
fn invalidate_track_caches(&self) {
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.track_ids_cache.lock().expect("track_ids_cache mutex poisoned").invalidate();
|
||||
self.read_list_cache.lock().expect("read_list_cache mutex poisoned").invalidate();
|
||||
}
|
||||
|
||||
/// Invalide tous les caches (après delete_all, seek, stop — opérations qui changent la piste courante).
|
||||
fn invalidate_all_caches(&self) {
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||
self.track_ids_cache.lock().expect("track_ids_cache mutex poisoned").invalidate();
|
||||
self.read_list_cache.lock().expect("read_list_cache mutex poisoned").invalidate();
|
||||
self.current_track_id_cache.lock().expect("current_track_id_cache mutex poisoned").invalidate();
|
||||
self.uri_by_id.lock().expect("uri_by_id mutex poisoned").clear();
|
||||
}
|
||||
|
||||
/// Tries to detect a simple append-only or delete-from-end pattern without ReadList.
|
||||
///
|
||||
/// This is a fast path optimization that avoids the expensive ReadList calls when:
|
||||
/// 1. New items are an append to existing queue (only inserts needed)
|
||||
/// 2. Items were deleted from the end (only deletes needed)
|
||||
///
|
||||
/// Returns `FastPathResult::NeedFullSync` if the pattern doesn't match or
|
||||
/// if cache data is insufficient to verify.
|
||||
fn try_fast_path(&self, new_items: &[PlaybackItem]) -> FastPathResult {
|
||||
let current_ids = match self.track_ids() {
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
error = %e,
|
||||
"try_fast_path: failed to get current IDs, falling back to full sync"
|
||||
);
|
||||
return FastPathResult::NeedFullSync;
|
||||
}
|
||||
};
|
||||
|
||||
let current_len = current_ids.len();
|
||||
let new_len = new_items.len();
|
||||
|
||||
if new_len > current_len {
|
||||
let prefix_matches = self.check_prefix_matches(¤t_ids, &new_items[..current_len]);
|
||||
if prefix_matches {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
current_len, new_len, "try_fast_path: detected append-only pattern"
|
||||
);
|
||||
return FastPathResult::AppendOnly {
|
||||
new_items: new_items[current_len..].to_vec(),
|
||||
};
|
||||
}
|
||||
} else if new_len < current_len {
|
||||
let prefix_matches = self.check_prefix_matches(¤t_ids[..new_len], new_items);
|
||||
if prefix_matches {
|
||||
let delete_ids: Vec<u32> = current_ids[new_len..].to_vec();
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
current_len,
|
||||
new_len,
|
||||
delete_count = delete_ids.len(),
|
||||
"try_fast_path: detected delete-from-end pattern"
|
||||
);
|
||||
return FastPathResult::DeleteFromEnd { delete_ids };
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
current_len, new_len, "try_fast_path: no fast path detected, falling back to full sync"
|
||||
);
|
||||
FastPathResult::NeedFullSync
|
||||
}
|
||||
|
||||
/// Checks if the prefix of the new items matches the current queue items.
|
||||
/// Uses local metadata cache or falls back to URI comparison.
|
||||
fn check_prefix_matches(&self, current_ids: &[u32], new_items: &[PlaybackItem]) -> bool {
|
||||
if current_ids.len() != new_items.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let metadata_cache = match self.metadata_cache.lock() {
|
||||
Ok(cache) => cache,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let uri_cache = match self.uri_by_id.lock() {
|
||||
Ok(cache) => cache,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
for (idx, new_item) in new_items.iter().enumerate() {
|
||||
let current_id = current_ids[idx];
|
||||
|
||||
// First check if we have cached metadata for this track_id that matches
|
||||
if let Some(cached_metadata) = metadata_cache.get(¤t_id) {
|
||||
if let Some(cached) = cached_metadata {
|
||||
// Compare using title + artist as identity (for streams)
|
||||
if let Some(ref new_metadata) = new_item.metadata {
|
||||
let same_title = cached.title.as_ref() == new_metadata.title.as_ref();
|
||||
let same_artist = cached.artist.as_ref() == new_metadata.artist.as_ref();
|
||||
if same_title && same_artist {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No metadata match - fall back to URI comparison using uri_by_id cache
|
||||
if new_item.uri.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare against cached URI for this track_id
|
||||
if let Some(cached_uri) = uri_cache.get(¤t_id) {
|
||||
if cached_uri == &new_item.uri {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No match found - fall back to full sync
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Met à jour les métadonnées d'un item de la queue à l'index spécifié.
|
||||
@@ -250,7 +374,7 @@ impl OpenHomeQueue {
|
||||
metadata: Option<crate::model::TrackMetadata>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
let track_id = self.position_to_id(index)?;
|
||||
self.cache_metadata(track_id, metadata);
|
||||
self.cache_metadata(track_id, metadata, "");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -260,8 +384,19 @@ impl OpenHomeQueue {
|
||||
/// chanson donc toute durée est acceptée.
|
||||
/// Pour les fichiers normaux (non-streams), les métadonnées sont acceptées telles quelles.
|
||||
/// Cette fonction est le SEUL point d'entrée pour modifier le cache.
|
||||
fn cache_metadata(&self, track_id: u32, new_metadata: Option<crate::model::TrackMetadata>) {
|
||||
let mut cache = self.metadata_cache.lock().unwrap();
|
||||
fn cache_metadata(
|
||||
&self,
|
||||
track_id: u32,
|
||||
new_metadata: Option<crate::model::TrackMetadata>,
|
||||
uri: &str,
|
||||
) {
|
||||
let mut cache = self.metadata_cache.lock().expect("metadata_cache mutex poisoned");
|
||||
let mut uri_cache = self.uri_by_id.lock().expect("uri_by_id mutex poisoned");
|
||||
|
||||
// Update URI cache
|
||||
if !uri.is_empty() {
|
||||
uri_cache.insert(track_id, uri.to_string());
|
||||
}
|
||||
|
||||
// Vérifier s'il y a déjà des métadonnées en cache
|
||||
if let Some(cached_meta) = cache.get(&track_id) {
|
||||
@@ -346,7 +481,7 @@ impl OpenHomeQueue {
|
||||
// Le cache contient les métadonnées stables mises lors de l'insertion
|
||||
// Les métadonnées de l'entry (venant de ReadList) changent pour les streams
|
||||
let metadata = {
|
||||
let cache = self.metadata_cache.lock().unwrap();
|
||||
let cache = self.metadata_cache.lock().expect("metadata_cache mutex poisoned");
|
||||
if let Some(cached_meta) = cache.get(&entry.id) {
|
||||
// Utiliser les métadonnées stables du cache
|
||||
tracing::trace!(
|
||||
@@ -366,7 +501,7 @@ impl OpenHomeQueue {
|
||||
);
|
||||
drop(cache); // Libérer le lock avant d'appeler cache_metadata
|
||||
// Mettre en cache pour éviter les oscillations sur les flux radio
|
||||
self.cache_metadata(entry.id, fresh.clone());
|
||||
self.cache_metadata(entry.id, fresh.clone(), entry.uri());
|
||||
fresh
|
||||
}
|
||||
};
|
||||
@@ -397,7 +532,7 @@ impl OpenHomeQueue {
|
||||
.insert(after_id, &item.uri, &metadata_xml)?;
|
||||
|
||||
// Enregistrer les métadonnées dans le cache
|
||||
self.cache_metadata(new_id, item.metadata);
|
||||
self.cache_metadata(new_id, item.metadata, &item.uri);
|
||||
|
||||
Ok(new_id)
|
||||
}
|
||||
@@ -409,32 +544,44 @@ impl OpenHomeQueue {
|
||||
&mut self,
|
||||
new_items: Vec<PlaybackItem>,
|
||||
playing_id: usize,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
// Get current track IDs from OpenHome
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
let current_track_ids = self.track_ids()?;
|
||||
|
||||
// Delete everything except the currently playing item
|
||||
// Using delete_id_if_exists() to handle cases where another control point
|
||||
// may have already modified the playlist
|
||||
for &track_id in current_track_ids.iter().rev() {
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
if track_id as usize != playing_id {
|
||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").remove(&track_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new items after the currently playing track
|
||||
let mut previous_id = playing_id as u32;
|
||||
let mut first_insert_done = false;
|
||||
for item in new_items {
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
let metadata = build_metadata_xml(&item);
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
|
||||
// Enregistrer les métadonnées dans le cache
|
||||
self.cache_metadata(new_id, item.metadata);
|
||||
self.cache_metadata(new_id, item.metadata, &item.uri);
|
||||
|
||||
previous_id = new_id;
|
||||
|
||||
if !first_insert_done && on_ready.is_some() {
|
||||
first_insert_done = true;
|
||||
if let Some(f) = on_ready.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
@@ -442,7 +589,6 @@ impl OpenHomeQueue {
|
||||
"Gentle sync completed: preserved playing track as first item (not in new playlist)"
|
||||
);
|
||||
|
||||
// Invalidate cache after playlist modifications
|
||||
self.invalidate_track_caches();
|
||||
|
||||
Ok(())
|
||||
@@ -454,8 +600,13 @@ impl OpenHomeQueue {
|
||||
old_ids: &[u32],
|
||||
keep_flags: &[bool],
|
||||
position_label: &str,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
for (idx, &track_id) in old_ids.iter().enumerate().rev() {
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
if !keep_flags[idx] {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
@@ -465,7 +616,7 @@ impl OpenHomeQueue {
|
||||
track_id
|
||||
);
|
||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").remove(&track_id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -480,8 +631,11 @@ impl OpenHomeQueue {
|
||||
keep_old_flags: &[bool],
|
||||
mut previous_id: u32,
|
||||
position_label: &str,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<u32, ControlPointError> {
|
||||
// Collect IDs of kept items (in order)
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
let remaining_ids: Vec<u32> = old_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -489,16 +643,18 @@ impl OpenHomeQueue {
|
||||
.collect();
|
||||
|
||||
let mut remaining_idx = 0;
|
||||
let mut first_insert_done = false;
|
||||
|
||||
// Rebuild section
|
||||
for (idx, item) in new_items.iter().enumerate() {
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
if keep_new_flags[idx] {
|
||||
let existing_id = remaining_ids[remaining_idx];
|
||||
remaining_idx += 1;
|
||||
previous_id = existing_id;
|
||||
|
||||
// Mettre à jour les métadonnées de l'item existant conservé
|
||||
self.cache_metadata(existing_id, item.metadata.clone());
|
||||
self.cache_metadata(existing_id, item.metadata.clone(), &item.uri);
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
@@ -513,8 +669,7 @@ impl OpenHomeQueue {
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
|
||||
// Enregistrer les métadonnées du nouvel item
|
||||
self.cache_metadata(new_id, item.metadata.clone());
|
||||
self.cache_metadata(new_id, item.metadata.clone(), &item.uri);
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
@@ -526,6 +681,13 @@ impl OpenHomeQueue {
|
||||
new_id
|
||||
);
|
||||
previous_id = new_id;
|
||||
|
||||
if !first_insert_done && on_ready.is_some() {
|
||||
first_insert_done = true;
|
||||
if let Some(f) = on_ready.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,6 +704,8 @@ impl OpenHomeQueue {
|
||||
pivot_id: usize,
|
||||
snapshot: &QueueSnapshot,
|
||||
current_track_ids: &[u32],
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
// Find the pivot index in our current state
|
||||
let pivot_idx = current_track_ids
|
||||
@@ -570,10 +734,15 @@ impl OpenHomeQueue {
|
||||
let (keep_old_before, keep_new_before) = lcs_flags_optimized(&old_before, new_before);
|
||||
|
||||
// Delete items marked for deletion in AFTER part (reverse order)
|
||||
self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot")?;
|
||||
self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot", cancel_token)?;
|
||||
|
||||
// Delete items marked for deletion in BEFORE part (reverse order)
|
||||
self.delete_marked_items(&old_ids_before, &keep_old_before, "BEFORE pivot")?;
|
||||
self.delete_marked_items(
|
||||
&old_ids_before,
|
||||
&keep_old_before,
|
||||
"BEFORE pivot",
|
||||
cancel_token,
|
||||
)?;
|
||||
|
||||
// Rebuild the playlist: [BEFORE, PIVOT, AFTER]
|
||||
// Rebuild BEFORE part (we don't need the returned previous_id)
|
||||
@@ -584,13 +753,19 @@ impl OpenHomeQueue {
|
||||
&keep_old_before,
|
||||
OPENHOME_PLAYLIST_HEAD_ID,
|
||||
"BEFORE pivot",
|
||||
cancel_token,
|
||||
on_ready,
|
||||
)?;
|
||||
|
||||
// PIVOT keeps its ID and position - it's the anchor point
|
||||
let previous_id = pivot_id as u32;
|
||||
|
||||
// Mettre à jour les métadonnées du pivot
|
||||
self.cache_metadata(previous_id, new_items[pivot_idx_new].metadata.clone());
|
||||
self.cache_metadata(
|
||||
previous_id,
|
||||
new_items[pivot_idx_new].metadata.clone(),
|
||||
&new_items[pivot_idx_new].uri,
|
||||
);
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
@@ -609,6 +784,8 @@ impl OpenHomeQueue {
|
||||
&keep_old_after,
|
||||
previous_id,
|
||||
"AFTER pivot",
|
||||
cancel_token,
|
||||
on_ready,
|
||||
)?;
|
||||
|
||||
debug!(
|
||||
@@ -630,7 +807,11 @@ impl OpenHomeQueue {
|
||||
items: Vec<PlaybackItem>,
|
||||
snapshot: &QueueSnapshot,
|
||||
current_track_ids: &[u32],
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
current_count = snapshot.items.len(),
|
||||
@@ -656,53 +837,42 @@ impl OpenHomeQueue {
|
||||
"LCS computed: minimizing OpenHome playlist operations"
|
||||
);
|
||||
|
||||
// Get current playing track ID BEFORE any modifications
|
||||
let current_track_id = self.playlist_client.id().ok().filter(|&id| id != 0);
|
||||
|
||||
// Check if the currently playing track is in the new playlist
|
||||
// If so, we should NOT use delete_all() - we must preserve it
|
||||
let current_track_in_new_playlist = current_track_id.and_then(|current_id| {
|
||||
items
|
||||
.iter()
|
||||
.position(|item| item.backend_id as u32 == current_id)
|
||||
});
|
||||
|
||||
// If we're replacing everything (keep=0), use delete_all() BUT only if
|
||||
// there's no currently playing track, OR if the current track is not in the new playlist.
|
||||
// If current track IS in new playlist, we must preserve it using insert/delete operations.
|
||||
if items_to_keep == 0 && items_to_delete > 0 {
|
||||
if current_track_in_new_playlist.is_some() {
|
||||
// Current track is in new playlist - use insert/delete instead of delete_all
|
||||
// to preserve playback
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
current_track_in_playlist = true,
|
||||
"Preserving currently playing track - using insert/delete instead of delete_all"
|
||||
);
|
||||
// Fall through to selective deletion below
|
||||
} else {
|
||||
// No current track or not in new playlist - safe to use delete_all
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
"Using delete_all() for complete replacement (safe - no current track or not in new playlist)"
|
||||
);
|
||||
self.playlist_client.delete_all()?;
|
||||
self.metadata_cache.lock().unwrap().clear();
|
||||
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").clear();
|
||||
}
|
||||
} else {
|
||||
// Selective deletion when keeping some items
|
||||
for idx in (0..current_track_ids.len()).rev() {
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
if !keep_current[idx] {
|
||||
let track_id = current_track_ids[idx];
|
||||
// Use delete_id_if_exists() to handle cases where another control point
|
||||
// may have already modified the playlist
|
||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").remove(&track_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild by inserting new items
|
||||
let remaining_ids: Vec<u32> = current_track_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -717,8 +887,12 @@ impl OpenHomeQueue {
|
||||
|
||||
let mut remaining_idx = 0usize;
|
||||
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
|
||||
let mut first_insert_done = false;
|
||||
|
||||
for (idx, item) in items.into_iter().enumerate() {
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
if keep_desired[idx] {
|
||||
if remaining_idx >= remaining_ids.len() {
|
||||
return Err(ControlPointError::OpenHomeError(format!(
|
||||
@@ -729,19 +903,23 @@ impl OpenHomeQueue {
|
||||
remaining_idx += 1;
|
||||
previous_id = existing_id;
|
||||
|
||||
// Mettre à jour les métadonnées de l'item existant conservé
|
||||
// La fonction cache_metadata gère la protection contre la diminution de durée
|
||||
self.cache_metadata(existing_id, item.metadata);
|
||||
self.cache_metadata(existing_id, item.metadata, &item.uri);
|
||||
} else {
|
||||
let metadata = build_metadata_xml(&item);
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
|
||||
// Enregistrer les métadonnées du nouvel item
|
||||
self.cache_metadata(new_id, item.metadata);
|
||||
self.cache_metadata(new_id, item.metadata, &item.uri);
|
||||
|
||||
previous_id = new_id;
|
||||
|
||||
if !first_insert_done && on_ready.is_some() {
|
||||
first_insert_done = true;
|
||||
if let Some(f) = on_ready.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,7 +929,6 @@ impl OpenHomeQueue {
|
||||
)));
|
||||
}
|
||||
|
||||
// Invalidate cache after playlist modifications
|
||||
self.invalidate_track_caches();
|
||||
|
||||
Ok(())
|
||||
@@ -922,7 +1099,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
|
||||
// Lock the cache for the entire operation to prevent race conditions
|
||||
let mut cache = self.track_ids_cache.lock().unwrap();
|
||||
let mut cache = self.track_ids_cache.lock().expect("track_ids_cache mutex poisoned");
|
||||
|
||||
// Check if cache is valid
|
||||
if let Some(cached_ids) = cache.get() {
|
||||
@@ -970,7 +1147,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
// Hold lock during entire operation to prevent race conditions
|
||||
let mut cache = self.current_track_id_cache.lock().unwrap();
|
||||
let mut cache = self.current_track_id_cache.lock().expect("current_track_id_cache mutex poisoned");
|
||||
|
||||
// Return cached value if valid
|
||||
if let Some(cached_id) = cache.get() {
|
||||
@@ -1015,7 +1192,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
const MAX_BATCH: usize = 256;
|
||||
let mut entries = Vec::with_capacity(ids.len());
|
||||
for chunk in ids.chunks(MAX_BATCH) {
|
||||
if let Some(cached) = self.read_list_cache.lock().unwrap().get(chunk) {
|
||||
if let Some(cached) = self.read_list_cache.lock().expect("read_list_cache mutex poisoned").get(chunk) {
|
||||
trace!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
"ReadList cache hit for {} IDs",
|
||||
@@ -1106,7 +1283,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist_client.delete_all()?;
|
||||
self.metadata_cache.lock().unwrap().clear();
|
||||
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").clear();
|
||||
|
||||
// Invalidate caches after delete_all (clears queue and current track)
|
||||
self.invalidate_all_caches();
|
||||
@@ -1124,7 +1301,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
|
||||
// Enregistrer les métadonnées dans le cache
|
||||
self.cache_metadata(new_id, item.metadata);
|
||||
self.cache_metadata(new_id, item.metadata, &item.uri);
|
||||
|
||||
previous_id = new_id;
|
||||
}
|
||||
@@ -1135,10 +1312,20 @@ impl QueueBackend for OpenHomeQueue {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
self.ensure_playlist_source_selected()?;
|
||||
|
||||
// DIAGNOSTIC: Log current track state before any modifications
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
|
||||
let pre_current_track = self.playlist_client.id().ok();
|
||||
tracing::warn!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
@@ -1153,11 +1340,9 @@ impl QueueBackend for OpenHomeQueue {
|
||||
"sync_queue: Empty playlist - clearing queue with delete_all"
|
||||
);
|
||||
self.playlist_client.delete_all()?;
|
||||
self.metadata_cache.lock().unwrap().clear();
|
||||
// Invalidate caches after delete_all (clears queue and current track)
|
||||
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").clear();
|
||||
self.invalidate_all_caches();
|
||||
|
||||
// DIAGNOSTIC: Log state after delete_all
|
||||
let post_current_track = self.playlist_client.id().ok();
|
||||
tracing::warn!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
@@ -1167,9 +1352,58 @@ impl QueueBackend for OpenHomeQueue {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Synchronize local state with the actual OpenHome playlist before computing
|
||||
// differences. Without this, any drift between our cache and the renderer
|
||||
// (e.g., manual edits from another control point) would keep the stale items.
|
||||
match self.try_fast_path(&items) {
|
||||
FastPathResult::AppendOnly { new_items } => {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
new_items_count = new_items.len(),
|
||||
"sync_queue: fast path - append-only detected"
|
||||
);
|
||||
let current_ids = self.track_ids()?;
|
||||
let mut after_id = current_ids
|
||||
.last()
|
||||
.copied()
|
||||
.unwrap_or(OPENHOME_PLAYLIST_HEAD_ID);
|
||||
let mut uri_cache = self.uri_by_id.lock().expect("uri_by_id mutex poisoned");
|
||||
for item in &new_items {
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
let metadata = build_metadata_xml(item);
|
||||
let uri = item.uri.as_str();
|
||||
let metadata_xml = metadata.as_str();
|
||||
after_id = self.playlist_client.insert(after_id, uri, metadata_xml)?;
|
||||
if !item.uri.is_empty() {
|
||||
uri_cache.insert(after_id, item.uri.clone());
|
||||
}
|
||||
}
|
||||
drop(uri_cache);
|
||||
self.invalidate_track_caches();
|
||||
return Ok(());
|
||||
}
|
||||
FastPathResult::DeleteFromEnd { delete_ids } => {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
delete_count = delete_ids.len(),
|
||||
"sync_queue: fast path - delete-from-end detected"
|
||||
);
|
||||
for id in delete_ids.iter().rev() {
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
self.playlist_client.delete_id(*id)?;
|
||||
}
|
||||
self.invalidate_all_caches();
|
||||
return Ok(());
|
||||
}
|
||||
FastPathResult::NeedFullSync => {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
"sync_queue: fast path not applicable, proceeding with full sync"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
|
||||
debug!(
|
||||
@@ -1180,9 +1414,6 @@ impl QueueBackend for OpenHomeQueue {
|
||||
"sync_queue: snapshot vs new items comparison"
|
||||
);
|
||||
|
||||
// Note: current_index may point to an index that doesn't exist in items
|
||||
// if the OpenHome renderer is in an inconsistent state (e.g., IdArray returns
|
||||
// IDs but ReadList returns empty TrackList). We must bounds-check here.
|
||||
let playing_info = snapshot.current_index.and_then(|idx| {
|
||||
if idx < snapshot.items.len() {
|
||||
Some((
|
||||
@@ -1209,8 +1440,14 @@ impl QueueBackend for OpenHomeQueue {
|
||||
"OpenHome playlist state"
|
||||
);
|
||||
|
||||
let has_pivot = playing_info.is_some();
|
||||
if has_pivot {
|
||||
if let Some(f) = on_ready.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((playing_idx, playing_id, playing_uri, playing_didl_id)) = playing_info {
|
||||
// Find if the currently playing item is in the new playlist (by URI first, then by didl_id)
|
||||
let new_playing_idx = items
|
||||
.iter()
|
||||
.position(|item| item.uri == playing_uri)
|
||||
@@ -1230,8 +1467,6 @@ impl QueueBackend for OpenHomeQueue {
|
||||
);
|
||||
|
||||
if let Some(pivot_idx) = new_playing_idx {
|
||||
// CASE 2: Currently playing item IS in the new playlist
|
||||
// Use gentle double-LCS strategy: preserve the pivot and sync before/after separately
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
playing_idx,
|
||||
@@ -1248,22 +1483,24 @@ impl QueueBackend for OpenHomeQueue {
|
||||
playing_id,
|
||||
&snapshot,
|
||||
¤t_ids_for_pivot,
|
||||
cancel_token,
|
||||
&mut on_ready,
|
||||
)?;
|
||||
} else {
|
||||
// CASE 1: Currently playing item NOT in the new playlist
|
||||
// Keep it as first item and append the new playlist after it
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
playing_idx,
|
||||
"Gentle sync: currently playing item not in new playlist, preserving as first item"
|
||||
);
|
||||
|
||||
self.replace_queue_preserve_current(items, playing_id)?;
|
||||
self.replace_queue_preserve_current(
|
||||
items,
|
||||
playing_id,
|
||||
cancel_token,
|
||||
&mut on_ready,
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
// No currently playing item or can't determine it - use standard LCS
|
||||
// BUT first check if this is because the OpenHome device returned empty playlist
|
||||
// This could cause the queue to be cleared incorrectly
|
||||
if snapshot.items.is_empty() && !items.is_empty() {
|
||||
tracing::warn!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
@@ -1271,8 +1508,6 @@ impl QueueBackend for OpenHomeQueue {
|
||||
new_items = items.len(),
|
||||
"OpenHome playlist appears empty - possible stale cache or device issue, NOT clearing queue"
|
||||
);
|
||||
// Don't call replace_queue_standard_lcs with empty snapshot - it would clear our queue
|
||||
// Instead, just add the new items without deleting existing ones
|
||||
return self.enqueue_items(items, crate::queue::EnqueueMode::AppendToEnd);
|
||||
}
|
||||
|
||||
@@ -1282,10 +1517,15 @@ impl QueueBackend for OpenHomeQueue {
|
||||
);
|
||||
let current_ids_for_lcs: Vec<u32> =
|
||||
snapshot.items.iter().map(|i| i.backend_id as u32).collect();
|
||||
self.replace_queue_standard_lcs(items, &snapshot, ¤t_ids_for_lcs)?;
|
||||
self.replace_queue_standard_lcs(
|
||||
items,
|
||||
&snapshot,
|
||||
¤t_ids_for_lcs,
|
||||
cancel_token,
|
||||
&mut on_ready,
|
||||
)?;
|
||||
}
|
||||
|
||||
// DIAGNOSTIC: Log state after sync completes
|
||||
let post_current_track = self.playlist_client.id().ok();
|
||||
let post_ids = self.track_ids();
|
||||
tracing::warn!(
|
||||
@@ -1343,8 +1583,9 @@ impl QueueBackend for OpenHomeQueue {
|
||||
.insert(before_id, &item.uri, &metadata)?;
|
||||
|
||||
// Mettre à jour le cache avec les nouvelles métadonnées
|
||||
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||
self.cache_metadata(new_id, item.metadata);
|
||||
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").remove(&track_id);
|
||||
self.uri_by_id.lock().expect("uri_by_id mutex poisoned").remove(&track_id);
|
||||
self.cache_metadata(new_id, item.metadata, &item.uri);
|
||||
|
||||
if ci == Some(index) {
|
||||
self.playlist_client.seek_id(new_id)?;
|
||||
@@ -1503,6 +1744,6 @@ impl QueueFromRendererInfo for OpenHomeQueue {
|
||||
}
|
||||
|
||||
fn to_backend(self) -> MusicQueue {
|
||||
MusicQueue::OpenHome(self)
|
||||
MusicQueue::from_openhome(self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{DeviceId, model::TrackMetadata};
|
||||
use crate::{model::TrackMetadata, DeviceId};
|
||||
|
||||
/// Canonical representation of a track in a renderer queue.
|
||||
///
|
||||
|
||||
@@ -136,6 +136,14 @@ impl DeviceRegistry {
|
||||
self.devices.get(id)?.as_music_renderer().ok()
|
||||
}
|
||||
|
||||
pub fn get_renderer_queue_arc(
|
||||
&self,
|
||||
id: &DeviceId,
|
||||
) -> Option<std::sync::Arc<std::sync::Mutex<crate::queue::MusicQueue>>> {
|
||||
let renderer = self.get_renderer(id)?;
|
||||
Some(renderer.queue())
|
||||
}
|
||||
|
||||
pub fn get_server(&self, id: &DeviceId) -> Option<Arc<MusicServer>> {
|
||||
self.devices.get(id)?.as_music_server().ok()
|
||||
}
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use pmoupnp::soap::{SoapEnvelope, build_soap_request, parse_soap_envelope};
|
||||
use tracing::{debug, trace, warn};
|
||||
use pmoupnp::soap::{build_soap_request, parse_soap_envelope, SoapEnvelope};
|
||||
use tracing::{debug, trace, warn, info};
|
||||
use ureq::Agent;
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
|
||||
static SOAP_AGENT: OnceLock<Arc<Agent>> = OnceLock::new();
|
||||
|
||||
/// Number of retries for transient transport failures on control actions.
|
||||
const SOAP_CONTROL_MAX_RETRIES: usize = 2;
|
||||
/// Delay between retries (ms). Kept short: devices usually recover in <200 ms.
|
||||
const SOAP_CONTROL_RETRY_DELAY_MS: u64 = 300;
|
||||
|
||||
fn get_soap_agent() -> Arc<Agent> {
|
||||
SOAP_AGENT
|
||||
.get_or_init(|| {
|
||||
Arc::new(
|
||||
Agent::config_builder()
|
||||
.http_status_as_error(false)
|
||||
.timeout_global(Some(Duration::from_secs(30)))
|
||||
.build()
|
||||
.into(),
|
||||
)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Result of a SOAP call:
|
||||
/// - HTTP status code
|
||||
/// - raw XML body (always)
|
||||
@@ -25,19 +48,44 @@ pub fn build_soap_body(
|
||||
build_soap_request(service_type, action, args)
|
||||
}
|
||||
|
||||
/// Invoke a UPnP SOAP action on a control URL.
|
||||
/// Invoke a UPnP SOAP control action with automatic retry on transient failures.
|
||||
///
|
||||
/// - `control_url`: full HTTP URL of the service control endpoint
|
||||
/// - `service_type`: service URN
|
||||
/// - `action`: action name
|
||||
/// - `args`: list of (name, value)
|
||||
/// Use this for **control operations** (Play, Pause, Stop, SetAVTransportURI, …)
|
||||
/// where a transient TCP error should be absorbed silently.
|
||||
///
|
||||
/// Retries are attempted only on [`ControlPointError::is_transient_soap_error`]
|
||||
/// (i.e. transport-level failures). Protocol-level errors (UPnP faults,
|
||||
/// HTTP 4xx/5xx with a valid body) propagate immediately without retry.
|
||||
///
|
||||
/// For **polling reads** (GetTransportInfo, GetPositionInfo, …) prefer
|
||||
/// [`invoke_upnp_action_with_timeout`] directly so that a slow device does
|
||||
/// not hold the watcher thread for multiple retry cycles.
|
||||
pub fn invoke_upnp_action(
|
||||
control_url: &str,
|
||||
service_type: &str,
|
||||
action: &str,
|
||||
args: &[(&str, &str)],
|
||||
) -> Result<SoapCallResult, ControlPointError> {
|
||||
invoke_upnp_action_with_timeout(control_url, service_type, action, args, None)
|
||||
let retry_delay = Duration::from_millis(SOAP_CONTROL_RETRY_DELAY_MS);
|
||||
|
||||
for attempt in 0..=SOAP_CONTROL_MAX_RETRIES {
|
||||
match invoke_upnp_action_with_timeout(control_url, service_type, action, args, None) {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(e) if e.is_transient_soap_error() && attempt < SOAP_CONTROL_MAX_RETRIES => {
|
||||
info!(
|
||||
url = control_url,
|
||||
action = action,
|
||||
attempt = attempt + 1,
|
||||
error = %e,
|
||||
"Transient SOAP error, retrying"
|
||||
);
|
||||
std::thread::sleep(retry_delay);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
// Unreachable: the loop either returns Ok or propagates Err above.
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
pub fn invoke_upnp_action_with_timeout(
|
||||
@@ -62,21 +110,20 @@ pub fn invoke_upnp_action_with_timeout(
|
||||
|
||||
trace!(body = body_xml.as_str(), "SOAP request body");
|
||||
|
||||
let mut builder = Agent::config_builder();
|
||||
builder = builder.http_status_as_error(false);
|
||||
if let Some(duration) = timeout {
|
||||
builder = builder.timeout_global(Some(duration));
|
||||
}
|
||||
let request = if let Some(duration) = timeout {
|
||||
let config = Agent::config_builder()
|
||||
.http_status_as_error(false)
|
||||
.timeout_global(Some(duration))
|
||||
.build();
|
||||
let agent: Agent = config.into();
|
||||
agent.post(control_url)
|
||||
} else {
|
||||
get_soap_agent().post(control_url)
|
||||
};
|
||||
|
||||
let config = builder.build();
|
||||
let agent: Agent = config.into();
|
||||
|
||||
// 3. SOAPAction header
|
||||
let soap_action_header = format!(r#""{}#{}""#, service_type, action);
|
||||
|
||||
// 4. HTTP POST
|
||||
let mut response = agent
|
||||
.post(control_url)
|
||||
let mut response = request
|
||||
.header("Content-Type", r#"text/xml; charset="utf-8""#)
|
||||
.header("SOAPAction", &soap_action_header)
|
||||
.send(body_xml)
|
||||
|
||||
@@ -153,6 +153,14 @@ pub enum RendererEventPayload {
|
||||
renderer_id: String,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
QueueReadyToPlay {
|
||||
renderer_id: String,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
QueueSyncCancelled {
|
||||
renderer_id: String,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
BindingChanged {
|
||||
renderer_id: String,
|
||||
server_id: Option<String>,
|
||||
@@ -293,6 +301,14 @@ async fn renderer_event_to_payload(
|
||||
renderer_id: id.0,
|
||||
timestamp,
|
||||
},
|
||||
RendererEvent::QueueReadyToPlay { id } => RendererEventPayload::QueueReadyToPlay {
|
||||
renderer_id: id.0,
|
||||
timestamp,
|
||||
},
|
||||
RendererEvent::QueueSyncCancelled { id } => RendererEventPayload::QueueSyncCancelled {
|
||||
renderer_id: id.0,
|
||||
timestamp,
|
||||
},
|
||||
RendererEvent::BindingChanged { id, binding } => RendererEventPayload::BindingChanged {
|
||||
renderer_id: id.0,
|
||||
server_id: binding.as_ref().map(|b| b.server_id.0.clone()),
|
||||
|
||||
@@ -12,7 +12,11 @@ use crate::{
|
||||
use pmoupnp::soap::SoapEnvelope;
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
/// Timeout for slow/long control actions (SetAVTransportURI, SetNextAVTransportURI).
|
||||
const AVTRANSPORT_ACTION_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// Timeout for fast polling-read actions (GetTransportInfo, GetPositionInfo).
|
||||
/// Must be well below the watcher short-interval (500 ms) to avoid cascading lateness.
|
||||
const AVTRANSPORT_POLL_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AvTransportClient {
|
||||
@@ -40,11 +44,12 @@ impl AvTransportClient {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let args = [("InstanceID", instance_id_str.as_str())];
|
||||
|
||||
let call_result = invoke_upnp_action(
|
||||
let call_result = invoke_upnp_action_with_timeout(
|
||||
&self.control_url,
|
||||
&self.service_type,
|
||||
"GetTransportInfo",
|
||||
&args,
|
||||
Some(AVTRANSPORT_POLL_TIMEOUT),
|
||||
)?;
|
||||
|
||||
if !call_result.status.is_success() {
|
||||
@@ -381,11 +386,12 @@ impl AvTransportClient {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let args = [("InstanceID", instance_id_str.as_str())];
|
||||
|
||||
let call_result = invoke_upnp_action(
|
||||
let call_result = invoke_upnp_action_with_timeout(
|
||||
&self.control_url,
|
||||
&self.service_type,
|
||||
"GetPositionInfo",
|
||||
&args,
|
||||
Some(AVTRANSPORT_POLL_TIMEOUT),
|
||||
)?;
|
||||
|
||||
if !call_result.status.is_success() {
|
||||
|
||||
@@ -817,7 +817,7 @@ impl OhProductClient {
|
||||
|
||||
pub fn source_xml(&self) -> Result<Vec<OhProductSource>> {
|
||||
// Lock the cache for the entire operation to prevent race conditions
|
||||
let mut cache = self.source_xml_cache.lock().unwrap();
|
||||
let mut cache = self.source_xml_cache.lock().expect("source_xml_cache mutex poisoned");
|
||||
|
||||
// Check if cache is valid
|
||||
if let Some(cached_sources) = cache.get() {
|
||||
@@ -841,7 +841,7 @@ impl OhProductClient {
|
||||
|
||||
pub fn source_index(&self) -> Result<u32, ControlPointError> {
|
||||
// Lock the cache for the entire operation to prevent race conditions
|
||||
let mut cache = self.source_index_cache.lock().unwrap();
|
||||
let mut cache = self.source_index_cache.lock().expect("source_index_cache mutex poisoned");
|
||||
|
||||
// Check if cache is valid
|
||||
if let Some(cached_index) = cache.get() {
|
||||
@@ -881,7 +881,7 @@ impl OhProductClient {
|
||||
|
||||
// Invalidate cache after write operation
|
||||
if result.is_ok() {
|
||||
let mut cache = self.source_index_cache.lock().unwrap();
|
||||
let mut cache = self.source_index_cache.lock().expect("source_index_cache mutex poisoned");
|
||||
cache.invalidate();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
errors::ControlPointError,
|
||||
soap_client::{
|
||||
ensure_success, extract_child_text, find_child_with_suffix, handle_action_response,
|
||||
invoke_upnp_action, parse_upnp_error,
|
||||
invoke_upnp_action, invoke_upnp_action_with_timeout, parse_upnp_error,
|
||||
},
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
/// Timeout for fast polling-read actions (GetVolume, GetMute).
|
||||
const RENDERING_CONTROL_POLL_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderingControlClient {
|
||||
pub control_url: String,
|
||||
@@ -30,8 +35,13 @@ impl RenderingControlClient {
|
||||
("Channel", channel),
|
||||
];
|
||||
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "GetVolume", &args)?;
|
||||
let call_result = invoke_upnp_action_with_timeout(
|
||||
&self.control_url,
|
||||
&self.service_type,
|
||||
"GetVolume",
|
||||
&args,
|
||||
Some(RENDERING_CONTROL_POLL_TIMEOUT),
|
||||
)?;
|
||||
|
||||
ensure_success("GetVolume", &call_result)?;
|
||||
|
||||
@@ -90,8 +100,13 @@ impl RenderingControlClient {
|
||||
("Channel", channel),
|
||||
];
|
||||
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "GetMute", &args)?;
|
||||
let call_result = invoke_upnp_action_with_timeout(
|
||||
&self.control_url,
|
||||
&self.service_type,
|
||||
"GetMute",
|
||||
&args,
|
||||
Some(RENDERING_CONTROL_POLL_TIMEOUT),
|
||||
)?;
|
||||
|
||||
ensure_success("GetMute", &call_result)?;
|
||||
|
||||
|
||||
@@ -402,7 +402,7 @@ unsafe fn setup_metadata(
|
||||
use libflac_sys::*;
|
||||
|
||||
// Create a Vorbis Comment block
|
||||
let meta = FLAC__metadata_object_new(FLAC__METADATA_TYPE_VORBIS_COMMENT);
|
||||
let meta = unsafe { FLAC__metadata_object_new(FLAC__METADATA_TYPE_VORBIS_COMMENT) };
|
||||
if meta.is_null() {
|
||||
return Err(FlacError::LibFlacInit(
|
||||
"Failed to create metadata block".into(),
|
||||
@@ -420,12 +420,14 @@ unsafe fn setup_metadata(
|
||||
FlacError::LibFlacInit("Failed to create CString for field value".into())
|
||||
})?;
|
||||
|
||||
let mut entry: FLAC__StreamMetadata_VorbisComment_Entry = std::mem::zeroed();
|
||||
let success = FLAC__metadata_object_vorbiscomment_entry_from_name_value_pair(
|
||||
&mut entry as *mut _,
|
||||
c_field_name.as_ptr(),
|
||||
c_value.as_ptr(),
|
||||
);
|
||||
let mut entry: FLAC__StreamMetadata_VorbisComment_Entry = unsafe { std::mem::zeroed() };
|
||||
let success = unsafe {
|
||||
FLAC__metadata_object_vorbiscomment_entry_from_name_value_pair(
|
||||
&mut entry as *mut _,
|
||||
c_field_name.as_ptr(),
|
||||
c_value.as_ptr(),
|
||||
)
|
||||
};
|
||||
|
||||
if success == 0 {
|
||||
return Err(FlacError::LibFlacInit(format!(
|
||||
@@ -435,7 +437,7 @@ unsafe fn setup_metadata(
|
||||
}
|
||||
|
||||
let append_success =
|
||||
FLAC__metadata_object_vorbiscomment_append_comment(meta, entry, 0 /* copy */);
|
||||
unsafe { FLAC__metadata_object_vorbiscomment_append_comment(meta, entry, 0 /* copy */) };
|
||||
|
||||
if append_success == 0 {
|
||||
return Err(FlacError::LibFlacInit(format!(
|
||||
@@ -476,7 +478,7 @@ unsafe fn setup_metadata(
|
||||
|
||||
// Set the metadata on the encoder
|
||||
let mut metadata_array = [meta];
|
||||
let set_success = FLAC__stream_encoder_set_metadata(encoder, metadata_array.as_mut_ptr(), 1);
|
||||
let set_success = unsafe { FLAC__stream_encoder_set_metadata(encoder, metadata_array.as_mut_ptr(), 1) };
|
||||
|
||||
if set_success == 0 {
|
||||
return Err(FlacError::LibFlacInit(
|
||||
@@ -666,8 +668,8 @@ unsafe extern "C" fn write_callback(
|
||||
_current_frame: u32,
|
||||
client_data: *mut c_void,
|
||||
) -> libflac_sys::FLAC__StreamEncoderWriteStatus {
|
||||
let state = &mut *(client_data as *mut EncoderClientState);
|
||||
let slice = std::slice::from_raw_parts(buffer, bytes);
|
||||
let state = unsafe { &mut *(client_data as *mut EncoderClientState) };
|
||||
let slice = unsafe { std::slice::from_raw_parts(buffer, bytes) };
|
||||
match state.tx.blocking_send(Ok(slice.to_vec())) {
|
||||
Ok(_) => libflac_sys::FLAC__STREAM_ENCODER_WRITE_STATUS_OK,
|
||||
Err(_) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ pub mod paradise_streaming;
|
||||
pub use content_handler::ContentHandler;
|
||||
pub use device::MEDIA_SERVER;
|
||||
pub use device_ext::MediaServerDeviceExt;
|
||||
pub use server_ext::{MediaServerExt, MusicSourceExt, get_source_registry};
|
||||
pub use server_ext::{MediaServerExt, MusicSourceExt};
|
||||
pub use source_registry::SourceRegistry;
|
||||
pub use sources::{SourceInitError, SourcesExt};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ use pmoaudiocache::{AudioCacheExt, get_audio_cache, register_audio_cache};
|
||||
use pmocovers::{CoverCacheExt, get_cover_cache, register_cover_cache};
|
||||
use pmoparadise::{
|
||||
ParadiseChannelManager, ParadiseHistoryBuilder,
|
||||
channels::{ALL_CHANNELS, ChannelDescriptor},
|
||||
channels::{ChannelDescriptor, channels},
|
||||
stream_channel::register_global_channel_manager,
|
||||
};
|
||||
use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
|
||||
@@ -47,7 +47,7 @@ pub trait ParadiseStreamingExt {
|
||||
///
|
||||
/// # Routes créées
|
||||
///
|
||||
/// Pour chaque canal (main, mellow, rock, eclectic) :
|
||||
/// Pour chaque canal connu du registre (main, mellow, rock, eclectic, beyond, ...) :
|
||||
/// - `/radioparadise/stream/{slug}/flac` - Stream FLAC live
|
||||
/// - `/radioparadise/stream/{slug}/ogg` - Stream OGG live
|
||||
/// - `/radioparadise/stream/{slug}/historic/{client_id}/flac` - Historique FLAC
|
||||
@@ -157,10 +157,10 @@ impl ParadiseStreamingExt for pmoserver::Server {
|
||||
manager: manager.clone(),
|
||||
});
|
||||
|
||||
// Ajouter les routes pour chaque canal
|
||||
// Ajouter les routes pour chaque canal (registre rafraîchi par le manager)
|
||||
info!("🌐 Registering streaming routes...");
|
||||
for descriptor in ALL_CHANNELS.iter() {
|
||||
let slug = descriptor.slug;
|
||||
for descriptor in channels().iter() {
|
||||
let slug = descriptor.slug.as_str();
|
||||
let channel_id = descriptor.id;
|
||||
|
||||
// Route FLAC live
|
||||
@@ -243,7 +243,7 @@ impl ParadiseStreamingExt for pmoserver::Server {
|
||||
|
||||
async fn stream_flac(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
let stream = channel.subscribe_flac();
|
||||
@@ -259,7 +259,7 @@ async fn stream_flac(
|
||||
|
||||
async fn stream_ogg(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
let stream = channel.subscribe_ogg();
|
||||
@@ -275,7 +275,7 @@ async fn stream_ogg(
|
||||
|
||||
async fn get_metadata(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
let metadata = channel.metadata().await;
|
||||
@@ -284,7 +284,7 @@ async fn get_metadata(
|
||||
|
||||
async fn stream_history_flac(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
client_id: String,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
@@ -307,7 +307,7 @@ async fn stream_history_flac(
|
||||
|
||||
async fn stream_history_ogg(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
client_id: String,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
@@ -347,10 +347,8 @@ fn spawn_playlist_event_handler(manager: Arc<ParadiseChannelManager>) {
|
||||
});
|
||||
}
|
||||
|
||||
fn channel_from_live_playlist(playlist_id: &str) -> Option<&'static ChannelDescriptor> {
|
||||
fn channel_from_live_playlist(playlist_id: &str) -> Option<ChannelDescriptor> {
|
||||
const PREFIX: &str = "radio-paradise-live-";
|
||||
let slug = playlist_id.strip_prefix(PREFIX)?;
|
||||
ALL_CHANNELS
|
||||
.iter()
|
||||
.find(|descriptor| descriptor.slug == slug)
|
||||
pmoparadise::channels::channel_by_slug(slug)
|
||||
}
|
||||
|
||||
@@ -14,23 +14,6 @@ use std::sync::Arc;
|
||||
// Réexporter le trait de base de pmosource
|
||||
pub use pmosource::MusicSourceExt;
|
||||
|
||||
/// Récupère le registre global de sources (délègue à pmosource)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use pmomediaserver::server_ext::get_source_registry;
|
||||
///
|
||||
/// let sources = pmosource::api::list_all_sources().await;
|
||||
/// ```
|
||||
#[deprecated(
|
||||
since = "0.2.0",
|
||||
note = "Use pmosource::api::list_all_sources() directly"
|
||||
)]
|
||||
pub async fn get_source_registry() -> Vec<Arc<dyn MusicSource>> {
|
||||
pmosource::api::list_all_sources().await
|
||||
}
|
||||
|
||||
/// Trait d'extension pour le serveur MediaServer UPnP
|
||||
///
|
||||
/// Ce trait ajoute des méthodes spécifiques au MediaServer UPnP.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -53,7 +53,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let channel_id: u8 = match args[1].parse() {
|
||||
let channel_id: u16 = match args[1].parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
eprintln!("Error: channel_id must be a number between 0 and 3");
|
||||
|
||||
@@ -74,7 +74,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let channel_id: u8 = match args[1].parse() {
|
||||
let channel_id: u16 = match args[1].parse() {
|
||||
Ok(id) if id <= 3 => id,
|
||||
_ => {
|
||||
eprintln!("Error: channel_id must be a number between 0 and 3");
|
||||
|
||||
@@ -26,7 +26,7 @@ use pmoaudiocache::{
|
||||
register_audio_cache as register_global_audio_cache,
|
||||
};
|
||||
use pmocovers::{new_cache_with_consolidation as new_cover_cache, register_cover_cache};
|
||||
use pmoparadise::{channels::ALL_CHANNELS, ParadiseChannelManager, ParadiseHistoryBuilder};
|
||||
use pmoparadise::{channels::channels, ParadiseChannelManager, ParadiseHistoryBuilder};
|
||||
use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
|
||||
use pmoserver::{init_logging, ServerBuilder};
|
||||
use tokio_util::io::ReaderStream;
|
||||
@@ -80,8 +80,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let mut server = ServerBuilder::new("RadioParadiseChannels", "http://localhost", 8080).build();
|
||||
|
||||
for descriptor in ALL_CHANNELS.iter() {
|
||||
let slug = descriptor.slug;
|
||||
for descriptor in channels().iter() {
|
||||
let slug = descriptor.slug.as_str();
|
||||
let flac_path = format!("/radioparadise/stream/{}/flac", slug);
|
||||
let ogg_path = format!("/radioparadise/stream/{}/ogg", slug);
|
||||
let icy_path = format!("/radioparadise/stream/{}/icy", slug);
|
||||
@@ -161,7 +161,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
info!("========================================");
|
||||
info!("Radio Paradise streaming server running on http://localhost:8080");
|
||||
info!("Available channels:");
|
||||
for descriptor in ALL_CHANNELS.iter() {
|
||||
for descriptor in channels().iter() {
|
||||
info!(
|
||||
" {}: /radioparadise/stream/{}/flac (also /ogg, /icy, metadata, /historic/<client_id>/(flac|ogg))",
|
||||
descriptor.display_name, descriptor.slug
|
||||
@@ -177,7 +177,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
async fn stream_flac(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
let stream = channel.subscribe_flac();
|
||||
@@ -193,7 +193,7 @@ async fn stream_flac(
|
||||
|
||||
async fn stream_ogg(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
let stream = channel.subscribe_ogg();
|
||||
@@ -209,7 +209,7 @@ async fn stream_ogg(
|
||||
|
||||
async fn stream_icy(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
let stream = channel.subscribe_icy();
|
||||
@@ -226,7 +226,7 @@ async fn stream_icy(
|
||||
|
||||
async fn get_metadata(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
let metadata = channel.metadata().await;
|
||||
@@ -235,7 +235,7 @@ async fn get_metadata(
|
||||
|
||||
async fn stream_history_flac(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
client_id: String,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
@@ -258,7 +258,7 @@ async fn stream_history_flac(
|
||||
|
||||
async fn stream_history_ogg(
|
||||
manager: Arc<ParadiseChannelManager>,
|
||||
channel_id: u8,
|
||||
channel_id: u16,
|
||||
client_id: String,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let channel = manager.get(channel_id).ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
@@ -25,7 +25,7 @@ use pmocovers::{
|
||||
new_cache_with_consolidation as new_cover_cache, register_cover_cache, Cache as CoverCache,
|
||||
};
|
||||
use pmoparadise::{
|
||||
channels::{ChannelDescriptor, ALL_CHANNELS},
|
||||
channels::{channels, resolve_channel, ChannelDescriptor},
|
||||
ParadiseHistoryBuilder, ParadiseStreamChannel, ParadiseStreamChannelConfig,
|
||||
};
|
||||
use pmoplaylist::register_audio_cache as register_playlist_audio_cache;
|
||||
@@ -90,7 +90,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let channel = Arc::new(
|
||||
ParadiseStreamChannel::new(
|
||||
descriptor,
|
||||
descriptor.clone(),
|
||||
channel_config,
|
||||
Some(cover_cache.clone()),
|
||||
Some(history_opts),
|
||||
@@ -227,15 +227,8 @@ async fn get_cover(
|
||||
|
||||
fn pick_descriptor(arg: Option<String>) -> anyhow::Result<ChannelDescriptor> {
|
||||
if let Some(token) = arg {
|
||||
if let Some(desc) = ALL_CHANNELS.iter().find(|c| c.slug == token) {
|
||||
return Ok(*desc);
|
||||
}
|
||||
if let Ok(id) = token.parse::<u8>() {
|
||||
if let Some(desc) = ALL_CHANNELS.iter().find(|c| c.id == id) {
|
||||
return Ok(*desc);
|
||||
}
|
||||
}
|
||||
anyhow::bail!("Unknown channel identifier: {token}");
|
||||
return resolve_channel(&token)
|
||||
.ok_or_else(|| anyhow::anyhow!("Unknown channel identifier: {token}"));
|
||||
}
|
||||
Ok(ALL_CHANNELS[0])
|
||||
Ok(channels()[0].clone())
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let channel_id: u8 = match args[1].parse() {
|
||||
let channel_id: u16 = match args[1].parse() {
|
||||
Ok(id) if id <= 3 => id,
|
||||
_ => {
|
||||
eprintln!("Error: channel_id must be a number between 0 and 3");
|
||||
|
||||
@@ -1,103 +1,187 @@
|
||||
//! Radio Paradise channel definitions
|
||||
//!
|
||||
//! This module defines the available Radio Paradise channels and their metadata.
|
||||
//! This module maintains a dynamic registry of the available Radio Paradise
|
||||
//! channels. The registry is initialized with a built-in default list and can
|
||||
//! be refreshed at runtime from the `list_chan` API endpoint via
|
||||
//! [`refresh_channels`], so newly added channels (Beyond, Serenity, KFAT, ...)
|
||||
//! are picked up without a code change.
|
||||
//!
|
||||
//! Channel IDs are not contiguous (0, 1, 2, 3, 5, 42, 945...): never iterate
|
||||
//! over an ID range, always go through [`channels`].
|
||||
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// Logical identifier for a Radio Paradise channel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ParadiseChannelKind {
|
||||
Main,
|
||||
Mellow,
|
||||
Rock,
|
||||
Eclectic,
|
||||
}
|
||||
|
||||
impl ParadiseChannelKind {
|
||||
pub const fn id(self) -> u8 {
|
||||
match self {
|
||||
Self::Main => 0,
|
||||
Self::Mellow => 1,
|
||||
Self::Rock => 2,
|
||||
Self::Eclectic => 3,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::Main => "main",
|
||||
Self::Mellow => "mellow",
|
||||
Self::Rock => "rock",
|
||||
Self::Eclectic => "eclectic",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Main => "Main Mix",
|
||||
Self::Mellow => "Mellow Mix",
|
||||
Self::Rock => "Rock Mix",
|
||||
Self::Eclectic => "Eclectic Mix",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::Main => "Eclectic mix of rock, world, electronica, and more",
|
||||
Self::Mellow => "Mellower, less aggressive music",
|
||||
Self::Rock => "Heavier, more guitar-driven music",
|
||||
Self::Eclectic => "Curated worldwide selection",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ParadiseChannelKind {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"main" | "0" => Ok(Self::Main),
|
||||
"mellow" | "1" => Ok(Self::Mellow),
|
||||
"rock" | "2" => Ok(Self::Rock),
|
||||
"eclectic" | "3" => Ok(Self::Eclectic),
|
||||
other => Err(anyhow::anyhow!("Unknown Radio Paradise channel: {}", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Metadata descriptor for a channel.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ChannelDescriptor {
|
||||
pub kind: ParadiseChannelKind,
|
||||
pub id: u8,
|
||||
pub slug: &'static str,
|
||||
pub display_name: &'static str,
|
||||
pub description: &'static str,
|
||||
/// Channel ID as used by the RP API (`chan` parameter). Not contiguous.
|
||||
pub id: u16,
|
||||
/// Stable identifier used in playlist IDs, config paths, routes and UPnP
|
||||
/// object IDs. Legacy slugs are preserved for channels 0-3 so existing
|
||||
/// persisted playlists and configuration keep working.
|
||||
pub slug: String,
|
||||
/// Human-readable channel name.
|
||||
pub display_name: String,
|
||||
/// Short description of the channel.
|
||||
pub description: String,
|
||||
/// Cover image URL provided by the API, if any.
|
||||
pub image: Option<String>,
|
||||
}
|
||||
|
||||
impl ChannelDescriptor {
|
||||
pub const fn new(kind: ParadiseChannelKind) -> Self {
|
||||
fn new_static(id: u16, slug: &str, display_name: &str, description: &str) -> Self {
|
||||
Self {
|
||||
id: kind.id(),
|
||||
slug: kind.slug(),
|
||||
display_name: kind.display_name(),
|
||||
description: kind.description(),
|
||||
kind,
|
||||
id,
|
||||
slug: slug.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
description: description.to_string(),
|
||||
// Stable URL pattern observed on img.radioparadise.com; the value
|
||||
// is overwritten by the API-provided one after refresh_channels().
|
||||
image: Some(format!(
|
||||
"https://img.radioparadise.com/channels/0/{}/cover_512x512/0.jpg",
|
||||
id
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All available Radio Paradise channels
|
||||
pub const ALL_CHANNELS: [ChannelDescriptor; 4] = [
|
||||
ChannelDescriptor::new(ParadiseChannelKind::Main),
|
||||
ChannelDescriptor::new(ParadiseChannelKind::Mellow),
|
||||
ChannelDescriptor::new(ParadiseChannelKind::Rock),
|
||||
ChannelDescriptor::new(ParadiseChannelKind::Eclectic),
|
||||
];
|
||||
/// Legacy slugs for the historical channels (0-3).
|
||||
///
|
||||
/// Playlist IDs, config paths and UPnP object IDs are derived from the slug,
|
||||
/// so the original slugs must be preserved even though the API now reports
|
||||
/// different `stream_name`s ("main-mix", "global", ...).
|
||||
fn legacy_slug(id: u16) -> Option<&'static str> {
|
||||
match id {
|
||||
0 => Some("main"),
|
||||
1 => Some("mellow"),
|
||||
2 => Some("rock"),
|
||||
3 => Some("eclectic"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the maximum valid channel ID
|
||||
pub const fn max_channel_id() -> u8 {
|
||||
(ALL_CHANNELS.len() - 1) as u8
|
||||
/// Built-in channel list, used as fallback when the API cannot be reached.
|
||||
///
|
||||
/// Snapshot of the `list_chan` endpoint (2026-07), with legacy slugs for 0-3.
|
||||
pub fn default_channels() -> Vec<ChannelDescriptor> {
|
||||
vec![
|
||||
ChannelDescriptor::new_static(
|
||||
0,
|
||||
"main",
|
||||
"The Main Mix",
|
||||
"Eclectic mix of rock, world, electronica, and more",
|
||||
),
|
||||
ChannelDescriptor::new_static(1, "mellow", "Mellow Mix", "Mellower, less aggressive music"),
|
||||
ChannelDescriptor::new_static(2, "rock", "RockIt!", "Heavier, more guitar-driven music"),
|
||||
ChannelDescriptor::new_static(3, "eclectic", "The Globe", "Curated worldwide selection"),
|
||||
ChannelDescriptor::new_static(5, "beyond", "Beyond...", "Adventurous, exploratory music"),
|
||||
ChannelDescriptor::new_static(
|
||||
42,
|
||||
"serenity",
|
||||
"Serenity",
|
||||
"Generative ambient soundscapes",
|
||||
),
|
||||
ChannelDescriptor::new_static(945, "kfat", "KFAT", "Americana, blues and country"),
|
||||
]
|
||||
}
|
||||
|
||||
static CHANNEL_REGISTRY: Lazy<RwLock<Arc<Vec<ChannelDescriptor>>>> =
|
||||
Lazy::new(|| RwLock::new(Arc::new(default_channels())));
|
||||
|
||||
/// Snapshot of the currently known channels.
|
||||
///
|
||||
/// Returns the built-in defaults until [`refresh_channels`] has succeeded.
|
||||
pub fn channels() -> Arc<Vec<ChannelDescriptor>> {
|
||||
CHANNEL_REGISTRY
|
||||
.read()
|
||||
.expect("channel registry poisoned")
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Look up a channel by its API ID.
|
||||
pub fn channel_by_id(id: u16) -> Option<ChannelDescriptor> {
|
||||
channels().iter().find(|ch| ch.id == id).cloned()
|
||||
}
|
||||
|
||||
/// Look up a channel by its slug.
|
||||
pub fn channel_by_slug(slug: &str) -> Option<ChannelDescriptor> {
|
||||
channels().iter().find(|ch| ch.slug == slug).cloned()
|
||||
}
|
||||
|
||||
/// Resolve a channel from a user-supplied string: slug or numeric ID.
|
||||
pub fn resolve_channel(s: &str) -> Option<ChannelDescriptor> {
|
||||
let s = s.trim();
|
||||
if let Ok(id) = s.parse::<u16>() {
|
||||
return channel_by_id(id);
|
||||
}
|
||||
channel_by_slug(&s.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Raw channel entry as returned by the `list_chan` API endpoint.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ApiChannel {
|
||||
pub chan: String,
|
||||
pub title: String,
|
||||
pub stream_name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub channel_type: String,
|
||||
#[serde(default)]
|
||||
pub image: Option<String>,
|
||||
}
|
||||
|
||||
impl ApiChannel {
|
||||
/// Convert to a descriptor. Returns `None` for entries our block-based
|
||||
/// pipeline cannot play (non-"block" channels) or with an unparsable ID.
|
||||
pub(crate) fn into_descriptor(self) -> Option<ChannelDescriptor> {
|
||||
if self.channel_type != "block" {
|
||||
return None;
|
||||
}
|
||||
let id: u16 = self.chan.parse().ok()?;
|
||||
let slug = legacy_slug(id)
|
||||
.map(str::to_string)
|
||||
.unwrap_or(self.stream_name);
|
||||
Some(ChannelDescriptor {
|
||||
id,
|
||||
slug,
|
||||
// The API provides no description; reuse the title.
|
||||
description: self.title.clone(),
|
||||
display_name: self.title,
|
||||
image: self.image,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the channel registry from the Radio Paradise API.
|
||||
///
|
||||
/// On success the registry is replaced with the fetched list and the new
|
||||
/// snapshot is returned. On failure the registry is left untouched (built-in
|
||||
/// defaults or previous successful fetch).
|
||||
pub async fn refresh_channels(
|
||||
client: &crate::client::RadioParadiseClient,
|
||||
) -> crate::error::Result<Arc<Vec<ChannelDescriptor>>> {
|
||||
let fetched = client.list_channels().await?;
|
||||
if fetched.is_empty() {
|
||||
return Err(crate::error::Error::other(
|
||||
"list_chan returned no playable channel",
|
||||
));
|
||||
}
|
||||
let snapshot = Arc::new(fetched);
|
||||
*CHANNEL_REGISTRY
|
||||
.write()
|
||||
.expect("channel registry poisoned") = snapshot.clone();
|
||||
tracing::info!(
|
||||
"Radio Paradise channel registry refreshed: {} channels ({})",
|
||||
snapshot.len(),
|
||||
snapshot
|
||||
.iter()
|
||||
.map(|ch| ch.slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Default maximum number of tracks to keep in history
|
||||
@@ -111,33 +195,63 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_channel_ids() {
|
||||
assert_eq!(ParadiseChannelKind::Main.id(), 0);
|
||||
assert_eq!(ParadiseChannelKind::Mellow.id(), 1);
|
||||
assert_eq!(ParadiseChannelKind::Rock.id(), 2);
|
||||
assert_eq!(ParadiseChannelKind::Eclectic.id(), 3);
|
||||
fn test_default_channels_have_legacy_slugs() {
|
||||
let channels = default_channels();
|
||||
assert_eq!(channels[0].slug, "main");
|
||||
assert_eq!(channels[1].slug, "mellow");
|
||||
assert_eq!(channels[2].slug, "rock");
|
||||
assert_eq!(channels[3].slug, "eclectic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_channel_id() {
|
||||
assert_eq!(max_channel_id(), 3);
|
||||
fn test_default_channels_include_new_channels() {
|
||||
let channels = default_channels();
|
||||
assert!(channels.iter().any(|ch| ch.id == 5 && ch.slug == "beyond"));
|
||||
assert!(channels.iter().any(|ch| ch.id == 42 && ch.slug == "serenity"));
|
||||
assert!(channels.iter().any(|ch| ch.id == 945 && ch.slug == "kfat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_channels_length() {
|
||||
assert_eq!(ALL_CHANNELS.len(), 4);
|
||||
fn test_resolve_channel() {
|
||||
assert_eq!(resolve_channel("main").map(|ch| ch.id), Some(0));
|
||||
assert_eq!(resolve_channel("0").map(|ch| ch.id), Some(0));
|
||||
assert_eq!(resolve_channel("MELLOW").map(|ch| ch.id), Some(1));
|
||||
assert_eq!(resolve_channel("945").map(|ch| ch.slug), Some("kfat".to_string()));
|
||||
assert!(resolve_channel("invalid").is_none());
|
||||
// IDs are sparse: 4 is not a channel
|
||||
assert!(resolve_channel("4").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_from_str() {
|
||||
assert!(matches!(
|
||||
"main".parse::<ParadiseChannelKind>(),
|
||||
Ok(ParadiseChannelKind::Main)
|
||||
));
|
||||
assert!(matches!(
|
||||
"0".parse::<ParadiseChannelKind>(),
|
||||
Ok(ParadiseChannelKind::Main)
|
||||
));
|
||||
assert!("invalid".parse::<ParadiseChannelKind>().is_err());
|
||||
fn test_api_channel_conversion() {
|
||||
let api = ApiChannel {
|
||||
chan: "3".to_string(),
|
||||
title: "The Globe".to_string(),
|
||||
stream_name: "global".to_string(),
|
||||
channel_type: "block".to_string(),
|
||||
image: None,
|
||||
};
|
||||
let desc = api.into_descriptor().unwrap();
|
||||
// Legacy slug preserved for channel 3
|
||||
assert_eq!(desc.slug, "eclectic");
|
||||
assert_eq!(desc.display_name, "The Globe");
|
||||
|
||||
let api = ApiChannel {
|
||||
chan: "945".to_string(),
|
||||
title: "KFAT".to_string(),
|
||||
stream_name: "kfat".to_string(),
|
||||
channel_type: "block".to_string(),
|
||||
image: None,
|
||||
};
|
||||
assert_eq!(api.into_descriptor().unwrap().slug, "kfat");
|
||||
|
||||
let api = ApiChannel {
|
||||
chan: "7".to_string(),
|
||||
title: "Live Stream".to_string(),
|
||||
stream_name: "live".to_string(),
|
||||
channel_type: "live".to_string(),
|
||||
image: None,
|
||||
};
|
||||
assert!(api.into_descriptor().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ pub const DEFAULT_BLOCK_TIMEOUT_SECS: u64 = 7200; // 2 hours
|
||||
pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0";
|
||||
|
||||
/// Default channel (0 = main mix)
|
||||
pub const DEFAULT_CHANNEL: u8 = 0;
|
||||
pub const DEFAULT_CHANNEL: u16 = 0;
|
||||
|
||||
/// Radio Paradise HTTP client
|
||||
///
|
||||
@@ -55,7 +55,7 @@ pub const DEFAULT_CHANNEL: u8 = 0;
|
||||
pub struct RadioParadiseClient {
|
||||
pub(crate) client: Client,
|
||||
api_base: String,
|
||||
channel: u8,
|
||||
channel: u16,
|
||||
pub(crate) request_timeout: Duration,
|
||||
pub(crate) block_timeout: Duration,
|
||||
next_block_url: Option<String>,
|
||||
@@ -92,7 +92,7 @@ impl RadioParadiseClient {
|
||||
}
|
||||
|
||||
/// Get the current channel (0 = main mix)
|
||||
pub fn channel(&self) -> u8 {
|
||||
pub fn channel(&self) -> u16 {
|
||||
self.channel
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ impl RadioParadiseClient {
|
||||
}
|
||||
|
||||
/// Clone the client with a different channel while preserving other settings.
|
||||
pub fn clone_with_channel(&self, channel: u8) -> Self {
|
||||
pub fn clone_with_channel(&self, channel: u16) -> Self {
|
||||
let mut cloned = self.clone();
|
||||
cloned.channel = channel;
|
||||
cloned.next_block_url = None;
|
||||
@@ -234,6 +234,36 @@ impl RadioParadiseClient {
|
||||
pub fn http_client(&self) -> &Client {
|
||||
&self.client
|
||||
}
|
||||
|
||||
/// List the channels currently advertised by the Radio Paradise API
|
||||
///
|
||||
/// Only block-based channels (playable by this crate) are returned.
|
||||
/// Use `channels::refresh_channels()` to update the global registry.
|
||||
pub async fn list_channels(&self) -> Result<Vec<crate::channels::ChannelDescriptor>> {
|
||||
let url = Url::parse(&format!("{}/list_chan", self.api_base))?;
|
||||
|
||||
debug!("Fetching channel list: {}", url);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.timeout(self.request_timeout)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(Error::other(format!(
|
||||
"API returned error status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let raw: Vec<crate::channels::ApiChannel> = response.json().await?;
|
||||
Ok(raw
|
||||
.into_iter()
|
||||
.filter_map(|ch| ch.into_descriptor())
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for configuring a RadioParadiseClient
|
||||
@@ -241,7 +271,7 @@ impl RadioParadiseClient {
|
||||
pub struct ClientBuilder {
|
||||
client: Option<Client>,
|
||||
api_base: String,
|
||||
channel: u8,
|
||||
channel: u16,
|
||||
request_timeout: Duration,
|
||||
block_timeout: Duration,
|
||||
user_agent: String,
|
||||
@@ -280,8 +310,8 @@ impl ClientBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the channel (0 = main mix, 1 = mellow, 2 = rock, 3 = world/etc)
|
||||
pub fn channel(mut self, channel: u8) -> Self {
|
||||
/// Set the channel (see `channels::channels()` for the available IDs)
|
||||
pub fn channel(mut self, channel: u16) -> Self {
|
||||
self.channel = channel;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use crate::{channels::ParadiseChannelKind, client::DEFAULT_CHANNEL};
|
||||
use crate::{
|
||||
channels::{channel_by_id, resolve_channel},
|
||||
client::DEFAULT_CHANNEL,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use pmoconfig::Config;
|
||||
use serde_yaml::Value;
|
||||
@@ -94,11 +97,10 @@ pub trait RadioParadiseConfigExt {
|
||||
///
|
||||
/// # Channels disponibles
|
||||
///
|
||||
/// Peut être configuré comme chaîne de caractères ou nombre :
|
||||
/// - "main" ou 0 = Main Mix (eclectic, diverse mix)
|
||||
/// - "mellow" ou 1 = Mellow Mix (smooth, chilled music)
|
||||
/// - "rock" ou 2 = Rock Mix (classic & modern rock)
|
||||
/// - "eclectic" ou 3 = Eclectic Mix (global sounds)
|
||||
/// Peut être configuré comme chaîne de caractères (slug) ou nombre (ID).
|
||||
/// La liste des canaux est dynamique (voir `channels::channels()`) :
|
||||
/// par exemple "main"/0, "mellow"/1, "rock"/2, "eclectic"/3, "beyond"/5,
|
||||
/// "serenity"/42, "kfat"/945.
|
||||
///
|
||||
/// # Exemple de configuration YAML
|
||||
///
|
||||
@@ -114,13 +116,13 @@ pub trait RadioParadiseConfigExt {
|
||||
/// let channel = config.get_paradise_default_channel()?;
|
||||
/// let client = RadioParadiseClient::builder().channel(channel).build().await?;
|
||||
/// ```
|
||||
fn get_paradise_default_channel(&self) -> Result<u8>;
|
||||
fn get_paradise_default_channel(&self) -> Result<u16>;
|
||||
|
||||
/// Définit le channel par défaut
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `channel` - Le channel (0-3)
|
||||
/// * `channel` - L'ID du channel (doit exister dans le registre de canaux)
|
||||
///
|
||||
/// La valeur est stockée sous forme de nom convivial ("main", "mellow", etc.)
|
||||
/// dans le fichier de configuration.
|
||||
@@ -128,14 +130,10 @@ pub trait RadioParadiseConfigExt {
|
||||
/// # Exemple
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use pmoparadise::channels::ParadiseChannelKind;
|
||||
///
|
||||
/// // Use Mellow Mix by default
|
||||
/// config.set_paradise_default_channel(ParadiseChannelKind::Mellow.id())?;
|
||||
/// // Or simply:
|
||||
/// config.set_paradise_default_channel(1)?;
|
||||
/// ```
|
||||
fn set_paradise_default_channel(&self, channel: u8) -> Result<()>;
|
||||
fn set_paradise_default_channel(&self, channel: u16) -> Result<()>;
|
||||
}
|
||||
|
||||
impl RadioParadiseConfigExt for Config {
|
||||
@@ -157,13 +155,13 @@ impl RadioParadiseConfigExt for Config {
|
||||
)
|
||||
}
|
||||
|
||||
fn get_paradise_default_channel(&self) -> Result<u8> {
|
||||
fn get_paradise_default_channel(&self) -> Result<u16> {
|
||||
match self.get_value(&["sources", "radio_paradise", "default_channel"]) {
|
||||
Ok(Value::String(s)) => {
|
||||
// Try to parse as channel name (e.g., "main", "mellow", etc.)
|
||||
match s.parse::<ParadiseChannelKind>() {
|
||||
Ok(kind) => Ok(kind.id()),
|
||||
Err(_) => {
|
||||
// Slug ("main", "mellow", ...) ou ID numérique en chaîne
|
||||
match resolve_channel(&s) {
|
||||
Some(descriptor) => Ok(descriptor.id),
|
||||
None => {
|
||||
// Invalid channel name, use default
|
||||
self.set_paradise_default_channel(DEFAULT_CHANNEL)?;
|
||||
Ok(DEFAULT_CHANNEL)
|
||||
@@ -171,19 +169,18 @@ impl RadioParadiseConfigExt for Config {
|
||||
}
|
||||
}
|
||||
Ok(Value::Number(n)) => {
|
||||
// Accept numeric channel ID (0-3)
|
||||
if let Some(ch) = n.as_u64() {
|
||||
if ch <= 3 {
|
||||
Ok(ch as u8)
|
||||
} else {
|
||||
// Accept numeric channel ID (must exist in the registry)
|
||||
match n
|
||||
.as_u64()
|
||||
.and_then(|ch| u16::try_from(ch).ok())
|
||||
.and_then(channel_by_id)
|
||||
{
|
||||
Some(descriptor) => Ok(descriptor.id),
|
||||
None => {
|
||||
// Invalid channel number, use default
|
||||
self.set_paradise_default_channel(DEFAULT_CHANNEL)?;
|
||||
Ok(DEFAULT_CHANNEL)
|
||||
}
|
||||
} else {
|
||||
// Not a valid number, use default
|
||||
self.set_paradise_default_channel(DEFAULT_CHANNEL)?;
|
||||
Ok(DEFAULT_CHANNEL)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -197,19 +194,14 @@ impl RadioParadiseConfigExt for Config {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_paradise_default_channel(&self, channel: u8) -> Result<()> {
|
||||
// Convert channel ID to user-friendly string name
|
||||
let channel_name = match channel {
|
||||
0 => "main",
|
||||
1 => "mellow",
|
||||
2 => "rock",
|
||||
3 => "eclectic",
|
||||
_ => return Err(anyhow::anyhow!("Invalid channel ID: {}", channel)),
|
||||
};
|
||||
fn set_paradise_default_channel(&self, channel: u16) -> Result<()> {
|
||||
// Convert channel ID to user-friendly slug
|
||||
let descriptor = channel_by_id(channel)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid channel ID: {}", channel))?;
|
||||
|
||||
self.set_value(
|
||||
&["sources", "radio_paradise", "default_channel"],
|
||||
Value::String(channel_name.to_string()),
|
||||
Value::String(descriptor.slug),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Ce module fournit un trait d'extension pour ajouter facilement l'API Radio Paradise
|
||||
//! à un serveur pmoserver.
|
||||
|
||||
use crate::channels::{max_channel_id, ChannelDescriptor, ALL_CHANNELS};
|
||||
use crate::channels::{channel_by_id, channels, refresh_channels, ChannelDescriptor};
|
||||
use crate::{Block, NowPlaying, RadioParadiseClient};
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
@@ -26,7 +26,7 @@ pub struct RadioParadiseState {
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct ParadiseQuery {
|
||||
channel: Option<u8>,
|
||||
channel: Option<u16>,
|
||||
}
|
||||
|
||||
impl RadioParadiseState {
|
||||
@@ -35,6 +35,15 @@ impl RadioParadiseState {
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create RadioParadise client: {}", e))?;
|
||||
|
||||
// Mettre à jour le registre de canaux depuis l'API (fallback sur les
|
||||
// canaux par défaut en cas d'échec réseau)
|
||||
if let Err(e) = refresh_channels(&client).await {
|
||||
tracing::warn!(
|
||||
"Failed to refresh Radio Paradise channel list, using defaults: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
client: Arc::new(RwLock::new(client)),
|
||||
})
|
||||
@@ -52,7 +61,7 @@ impl RadioParadiseState {
|
||||
let mut client = base_client;
|
||||
|
||||
if let Some(channel) = params.channel {
|
||||
if channel > max_channel_id() {
|
||||
if channel_by_id(channel).is_none() {
|
||||
tracing::warn!("Invalid Radio Paradise channel requested: {}", channel);
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
@@ -66,20 +75,26 @@ impl RadioParadiseState {
|
||||
/// Information sur un canal Radio Paradise
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ChannelInfo {
|
||||
/// ID du canal (0-3)
|
||||
pub id: u8,
|
||||
/// ID du canal (attention : IDs non contigus, ex. 0, 1, 2, 3, 5, 42, 945)
|
||||
pub id: u16,
|
||||
/// Slug du canal ("main", "mellow", "beyond", ...)
|
||||
pub slug: String,
|
||||
/// Nom du canal
|
||||
pub name: String,
|
||||
/// Description
|
||||
pub description: String,
|
||||
/// Route locale de l'image du canal (servie par le cache covers)
|
||||
pub image: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&ChannelDescriptor> for ChannelInfo {
|
||||
fn from(descriptor: &ChannelDescriptor) -> Self {
|
||||
Self {
|
||||
id: descriptor.id,
|
||||
name: descriptor.display_name.to_string(),
|
||||
description: descriptor.description.to_string(),
|
||||
slug: descriptor.slug.clone(),
|
||||
name: descriptor.display_name.clone(),
|
||||
description: descriptor.description.clone(),
|
||||
image: descriptor.image.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,7 +266,7 @@ impl From<NowPlaying> for NowPlayingResponse {
|
||||
get,
|
||||
path = "/now-playing",
|
||||
params(
|
||||
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
|
||||
("channel" = Option<u16>, Query, description = "Channel ID (voir /channels)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Morceau en cours", body = NowPlayingResponse),
|
||||
@@ -277,7 +292,7 @@ async fn get_now_playing(
|
||||
get,
|
||||
path = "/block/current",
|
||||
params(
|
||||
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
|
||||
("channel" = Option<u16>, Query, description = "Channel ID (voir /channels)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Block actuel", body = BlockResponse),
|
||||
@@ -304,7 +319,7 @@ async fn get_current_block(
|
||||
path = "/block/{event_id}",
|
||||
params(
|
||||
("event_id" = u64, Path, description = "Event ID du block"),
|
||||
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
|
||||
("channel" = Option<u16>, Query, description = "Channel ID (voir /channels)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Block demandé", body = BlockResponse),
|
||||
@@ -340,8 +355,27 @@ async fn get_block_by_id(
|
||||
tag = "Radio Paradise"
|
||||
)]
|
||||
async fn get_channels() -> Json<Vec<ChannelInfo>> {
|
||||
let channels: Vec<ChannelInfo> = ALL_CHANNELS.iter().map(Into::into).collect();
|
||||
Json(channels)
|
||||
let cover_cache = pmocovers::get_cover_cache();
|
||||
let mut list = Vec::new();
|
||||
for descriptor in channels().iter() {
|
||||
let mut info: ChannelInfo = descriptor.into();
|
||||
// Toutes les images transitent par le cache covers local : on expose
|
||||
// la route du cache, jamais l'URL externe img.radioparadise.com
|
||||
info.image = match (&descriptor.image, &cover_cache) {
|
||||
(Some(url), Some(cache)) => {
|
||||
match cache.add_from_url(url, Some("radioparadise-channels")).await {
|
||||
Ok(pk) => Some(pmocache::covers_route_for(&pk, None)),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to cache channel image {}: {}", url, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
list.push(info);
|
||||
}
|
||||
Json(list)
|
||||
}
|
||||
|
||||
/// GET /block/{event_id}/song/{index} - Récupère un morceau spécifique d'un block
|
||||
@@ -351,7 +385,7 @@ async fn get_channels() -> Json<Vec<ChannelInfo>> {
|
||||
params(
|
||||
("event_id" = u64, Path, description = "Event ID du block"),
|
||||
("index" = usize, Path, description = "Index du morceau (0-based)"),
|
||||
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
|
||||
("channel" = Option<u16>, Query, description = "Channel ID (voir /channels)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Morceau demandé", body = SongInfo),
|
||||
@@ -404,7 +438,7 @@ async fn get_song_by_index(
|
||||
params(
|
||||
("event_id" = u64, Path, description = "Event ID du block"),
|
||||
("song_index" = usize, Path, description = "Index du morceau (0-based)"),
|
||||
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
|
||||
("channel" = Option<u16>, Query, description = "Channel ID (voir /channels)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "URL de la pochette avec fallback automatique", body = CoverUrlResponse),
|
||||
@@ -456,7 +490,7 @@ async fn get_cover_url(
|
||||
path = "/stream-url/{event_id}",
|
||||
params(
|
||||
("event_id" = u64, Path, description = "Event ID du block (None pour le block actuel)"),
|
||||
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
|
||||
("channel" = Option<u16>, Query, description = "Channel ID (voir /channels)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "URL de streaming", body = StreamUrlResponse),
|
||||
@@ -500,17 +534,15 @@ Cette API permet d'accéder aux métadonnées et flux de Radio Paradise.
|
||||
## Fonctionnalités
|
||||
|
||||
- **Métadonnées en temps réel** : Récupération du morceau en cours et des blocks
|
||||
- **Multi-canaux** : Support des 4 canaux Radio Paradise (Main, Mellow, Rock, Eclectic)
|
||||
- **Multi-canaux** : Support de tous les canaux Radio Paradise (liste dynamique)
|
||||
- **Streaming FLAC** : Accès direct aux URLs de streaming haute qualité
|
||||
- **Pochettes d'albums** : URLs complètes des couvertures (petite et grande taille)
|
||||
- **Historique** : Accès aux blocks passés via event_id
|
||||
|
||||
## Canaux disponibles
|
||||
|
||||
- **0: Main Mix** - Eclectic mix of rock, world, electronica, and more
|
||||
- **1: Mellow Mix** - Mellower, less aggressive music
|
||||
- **2: Rock Mix** - Heavier, more guitar-driven music
|
||||
- **3: Eclectic Mix** - Curated worldwide selection
|
||||
La liste des canaux est récupérée dynamiquement depuis l'API Radio Paradise
|
||||
(`GET /channels`). Attention : les IDs ne sont pas contigus (ex. 0, 1, 2, 3, 5, 42, 945).
|
||||
|
||||
## Format des données
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! RadioParadiseSource - Implementation of MusicSource for Radio Paradise
|
||||
//!
|
||||
//! This module provides a UPnP ContentDirectory source for Radio Paradise,
|
||||
//! exposing live streams and historical playlists for all 4 channels.
|
||||
//! exposing live streams and historical playlists for every channel known
|
||||
//! to the dynamic channel registry (see `crate::channels`).
|
||||
|
||||
use crate::channels::{ChannelDescriptor, ALL_CHANNELS};
|
||||
use crate::channels::{channel_by_slug, channels, ChannelDescriptor};
|
||||
use pmosource::pmodidl::{Container, Item, Resource};
|
||||
use pmosource::{
|
||||
async_trait, AudioFormat, BrowseResult, MusicSource, MusicSourceError, Result,
|
||||
@@ -27,7 +28,7 @@ const LIVE_PLAYLIST_READY_POLL: Duration = Duration::from_millis(200);
|
||||
/// RadioParadiseSource - UPnP ContentDirectory source for Radio Paradise
|
||||
///
|
||||
/// Provides access to:
|
||||
/// - Live FLAC streams for all 4 channels (Main, Mellow, Rock, Eclectic)
|
||||
/// - Live FLAC streams for every channel in the registry (Main, Mellow, Rock, Eclectic, Beyond, ...)
|
||||
/// - Historical playlists (FIFO) for each channel
|
||||
///
|
||||
/// # Object ID Schema
|
||||
@@ -116,12 +117,12 @@ impl RadioParadiseSource {
|
||||
use pmoplaylist::PlaylistManager;
|
||||
|
||||
// Préparer les IDs de playlists à surveiller (live + history pour chaque canal)
|
||||
let ids: Vec<String> = ALL_CHANNELS
|
||||
let ids: Vec<String> = channels()
|
||||
.iter()
|
||||
.flat_map(|ch| {
|
||||
vec![
|
||||
Self::live_playlist_id(ch.slug),
|
||||
Self::history_playlist_id(ch.slug),
|
||||
Self::live_playlist_id(&ch.slug),
|
||||
Self::history_playlist_id(&ch.slug),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
@@ -151,20 +152,21 @@ impl RadioParadiseSource {
|
||||
tokio::spawn(async move {
|
||||
strong.bump_update_counter().await;
|
||||
// Notifier ContentDirectory des conteneurs concernés
|
||||
let known_channels = channels();
|
||||
let containers: Vec<String> = if pid.contains("history") {
|
||||
// history playlist -> container history
|
||||
ALL_CHANNELS
|
||||
known_channels
|
||||
.iter()
|
||||
.find(|ch| pid.ends_with(ch.slug))
|
||||
.find(|ch| pid.ends_with(&ch.slug))
|
||||
.map(|ch| {
|
||||
vec![format!("radio-paradise:channel:{}:history", ch.slug)]
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
// live playlist -> container liveplaylist
|
||||
ALL_CHANNELS
|
||||
known_channels
|
||||
.iter()
|
||||
.find(|ch| pid.ends_with(ch.slug))
|
||||
.find(|ch| pid.ends_with(&ch.slug))
|
||||
.map(|ch| {
|
||||
vec![format!(
|
||||
"radio-paradise:channel:{}:liveplaylist",
|
||||
@@ -192,6 +194,26 @@ impl RadioParadiseSource {
|
||||
format!("{}/api/sources/{}/image", self.base_url, self.id())
|
||||
}
|
||||
|
||||
/// Résout l'image d'un canal en URL locale servie par le cache covers.
|
||||
///
|
||||
/// Toutes les images transitent par pmocovers : aucune URL externe ne doit
|
||||
/// apparaître dans les métadonnées UPnP. En cas de cache indisponible ou
|
||||
/// d'échec de téléchargement, fallback sur l'image par défaut de la source.
|
||||
async fn channel_art_url(&self, descriptor: &ChannelDescriptor) -> String {
|
||||
if let (Some(url), Some(cache)) = (descriptor.image.as_ref(), pmocovers::get_cover_cache())
|
||||
{
|
||||
match cache.add_from_url(url, Some("radioparadise-channels")).await {
|
||||
Ok(pk) => {
|
||||
return format!("{}{}", self.base_url, pmocache::covers_route_for(&pk, None));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to cache channel image {}: {}", url, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.default_cover_url()
|
||||
}
|
||||
|
||||
/// Fetch current metadata from the live stream
|
||||
async fn fetch_live_metadata(&self, slug: &str) -> Result<Option<Item>> {
|
||||
let metadata_url = format!("{}/radioparadise/metadata/{}", self.base_url, slug);
|
||||
@@ -212,11 +234,27 @@ impl RadioParadiseSource {
|
||||
// Préférer l'URL de cache si cover_pk est fourni par le pipeline
|
||||
let cover_pk = json["cover_pk"].as_str().map(|s| s.to_string());
|
||||
// Stocker la route relative (le handler REST appliquera base_url.url_for())
|
||||
let cover_url = cover_pk
|
||||
let mut cover_url = cover_pk
|
||||
.as_ref()
|
||||
.map(|pk| pmocache::covers_route_for(pk, None))
|
||||
.or_else(|| json["cover_url"].as_str().map(|s| s.to_string()))
|
||||
.or_else(|| Some(self.default_cover_url()));
|
||||
.map(|pk| pmocache::covers_route_for(pk, None));
|
||||
if cover_url.is_none() {
|
||||
// Pas de pk : faire transiter l'URL externe par le cache covers
|
||||
if let (Some(remote), Some(cache)) =
|
||||
(json["cover_url"].as_str(), pmocovers::get_cover_cache())
|
||||
{
|
||||
match cache.add_from_url(remote, Some("radioparadise")).await {
|
||||
Ok(pk) => {
|
||||
cover_url = Some(pmocache::covers_route_for(&pk, None))
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
"Failed to cache live cover {}: {}",
|
||||
remote,
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
let cover_url = cover_url.or_else(|| Some(self.default_cover_url()));
|
||||
|
||||
// Parse duration from JSON (in seconds as a float)
|
||||
let duration = json["duration"]
|
||||
@@ -318,8 +356,8 @@ impl RadioParadiseSource {
|
||||
}
|
||||
|
||||
/// Get channel descriptor by slug
|
||||
fn get_channel_by_slug(slug: &str) -> Option<&'static ChannelDescriptor> {
|
||||
ALL_CHANNELS.iter().find(|ch| ch.slug == slug)
|
||||
fn get_channel_by_slug(slug: &str) -> Option<ChannelDescriptor> {
|
||||
channel_by_slug(slug)
|
||||
}
|
||||
|
||||
/// Parse an object ID into its components
|
||||
@@ -356,7 +394,7 @@ impl RadioParadiseSource {
|
||||
}
|
||||
|
||||
/// Build a channel container
|
||||
fn build_channel_container(&self, descriptor: &ChannelDescriptor) -> Container {
|
||||
async fn build_channel_container(&self, descriptor: &ChannelDescriptor) -> Container {
|
||||
Container {
|
||||
id: format!("radio-paradise:channel:{}", descriptor.slug),
|
||||
parent_id: "radio-paradise".to_string(),
|
||||
@@ -366,14 +404,14 @@ impl RadioParadiseSource {
|
||||
title: descriptor.display_name.to_string(),
|
||||
class: "object.container".to_string(),
|
||||
artist: None,
|
||||
album_art: None,
|
||||
album_art: Some(self.channel_art_url(descriptor).await),
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the live playlist container for a channel
|
||||
fn build_live_playlist_container(&self, descriptor: &ChannelDescriptor) -> Container {
|
||||
async fn build_live_playlist_container(&self, descriptor: &ChannelDescriptor) -> Container {
|
||||
Container {
|
||||
id: format!("radio-paradise:channel:{}:liveplaylist", descriptor.slug),
|
||||
parent_id: format!("radio-paradise:channel:{}", descriptor.slug),
|
||||
@@ -383,15 +421,15 @@ impl RadioParadiseSource {
|
||||
title: format!("{} - Live Playlist", descriptor.display_name),
|
||||
class: "object.container.playlistContainer".to_string(),
|
||||
artist: None,
|
||||
album_art: None,
|
||||
album_art: Some(self.channel_art_url(descriptor).await),
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a live stream item for a channel
|
||||
fn build_live_stream_item(&self, descriptor: &ChannelDescriptor) -> Item {
|
||||
let stream_url = self.build_live_url(descriptor.slug);
|
||||
async fn build_live_stream_item(&self, descriptor: &ChannelDescriptor) -> Item {
|
||||
let stream_url = self.build_live_url(&descriptor.slug);
|
||||
|
||||
Item {
|
||||
id: format!("radio-paradise:channel:{}:live", descriptor.slug),
|
||||
@@ -403,7 +441,7 @@ impl RadioParadiseSource {
|
||||
artist: Some("Radio Paradise".to_string()),
|
||||
album: Some(descriptor.display_name.to_string()),
|
||||
genre: Some("Radio".to_string()),
|
||||
album_art: Some(self.default_cover_url()),
|
||||
album_art: Some(self.channel_art_url(descriptor).await),
|
||||
album_art_pk: None,
|
||||
date: None,
|
||||
original_track_number: None,
|
||||
@@ -422,7 +460,7 @@ impl RadioParadiseSource {
|
||||
sample_frequency: Some("44100".to_string()),
|
||||
nr_audio_channels: Some("2".to_string()),
|
||||
duration: None,
|
||||
url: self.build_live_ogg_url(descriptor.slug),
|
||||
url: self.build_live_ogg_url(&descriptor.slug),
|
||||
},
|
||||
],
|
||||
descriptions: vec![],
|
||||
@@ -430,7 +468,7 @@ impl RadioParadiseSource {
|
||||
}
|
||||
|
||||
/// Build a history container for a channel
|
||||
fn build_history_container(&self, descriptor: &ChannelDescriptor) -> Container {
|
||||
async fn build_history_container(&self, descriptor: &ChannelDescriptor) -> Container {
|
||||
Container {
|
||||
id: format!("radio-paradise:channel:{}:history", descriptor.slug),
|
||||
parent_id: format!("radio-paradise:channel:{}", descriptor.slug),
|
||||
@@ -441,7 +479,7 @@ impl RadioParadiseSource {
|
||||
// Expose l'historique comme une playlist jouable
|
||||
class: "object.container.playlistContainer".to_string(),
|
||||
artist: None,
|
||||
album_art: None,
|
||||
album_art: Some(self.channel_art_url(descriptor).await),
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
}
|
||||
@@ -453,10 +491,10 @@ impl RadioParadiseSource {
|
||||
&self,
|
||||
descriptor: &ChannelDescriptor,
|
||||
) -> Container {
|
||||
let mut container = self.build_history_container(descriptor);
|
||||
let mut container = self.build_history_container(descriptor).await;
|
||||
|
||||
// Try to get actual count from playlist
|
||||
let playlist_id = Self::history_playlist_id(descriptor.slug);
|
||||
let playlist_id = Self::history_playlist_id(&descriptor.slug);
|
||||
let manager = pmoplaylist::PlaylistManager();
|
||||
|
||||
if let Ok(reader) = manager.get_read_handle(&playlist_id).await {
|
||||
@@ -663,11 +701,11 @@ impl MusicSource for RadioParadiseSource {
|
||||
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
|
||||
match Self::parse_object_id(object_id) {
|
||||
ObjectIdType::Root => {
|
||||
// Return the 4 channel containers
|
||||
let containers: Vec<Container> = ALL_CHANNELS
|
||||
.iter()
|
||||
.map(|ch| self.build_channel_container(ch))
|
||||
.collect();
|
||||
// Return one container per known channel
|
||||
let mut containers = Vec::new();
|
||||
for ch in channels().iter() {
|
||||
containers.push(self.build_channel_container(ch).await);
|
||||
}
|
||||
|
||||
Ok(BrowseResult::Containers(containers))
|
||||
}
|
||||
@@ -678,13 +716,13 @@ impl MusicSource for RadioParadiseSource {
|
||||
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
|
||||
})?;
|
||||
|
||||
let live_item = self.build_live_stream_item(descriptor);
|
||||
let live_playlist_container = self.build_live_playlist_container(descriptor);
|
||||
let live_item = self.build_live_stream_item(&descriptor).await;
|
||||
let live_playlist_container = self.build_live_playlist_container(&descriptor).await;
|
||||
|
||||
#[cfg(feature = "playlist")]
|
||||
let history_container = self.build_history_container_with_count(descriptor).await;
|
||||
let history_container = self.build_history_container_with_count(&descriptor).await;
|
||||
#[cfg(not(feature = "playlist"))]
|
||||
let history_container = self.build_history_container(descriptor);
|
||||
let history_container = self.build_history_container(&descriptor).await;
|
||||
|
||||
Ok(BrowseResult::Mixed {
|
||||
containers: vec![live_playlist_container, history_container],
|
||||
@@ -702,7 +740,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
#[cfg(feature = "playlist")]
|
||||
{
|
||||
let history_container =
|
||||
self.build_history_container_with_count(descriptor).await;
|
||||
self.build_history_container_with_count(&descriptor).await;
|
||||
let items = self.get_history_items(&slug, 0, 100).await?;
|
||||
Ok(BrowseResult::Mixed {
|
||||
containers: vec![history_container],
|
||||
@@ -713,7 +751,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
#[cfg(not(feature = "playlist"))]
|
||||
{
|
||||
// If playlist feature is disabled, return just the container
|
||||
let history_container = self.build_history_container(descriptor);
|
||||
let history_container = self.build_history_container(&descriptor).await;
|
||||
Ok(BrowseResult::Containers(vec![history_container]))
|
||||
}
|
||||
}
|
||||
@@ -723,7 +761,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| {
|
||||
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
|
||||
})?;
|
||||
let item = self.build_live_stream_item(descriptor);
|
||||
let item = self.build_live_stream_item(&descriptor).await;
|
||||
Ok(BrowseResult::Items(vec![item]))
|
||||
}
|
||||
|
||||
@@ -735,7 +773,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
|
||||
#[cfg(feature = "playlist")]
|
||||
{
|
||||
let container = self.build_live_playlist_container(descriptor);
|
||||
let container = self.build_live_playlist_container(&descriptor).await;
|
||||
let items = self.get_live_playlist_items(&slug, 0, 100).await?;
|
||||
Ok(BrowseResult::Mixed {
|
||||
containers: vec![container],
|
||||
@@ -745,7 +783,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
|
||||
#[cfg(not(feature = "playlist"))]
|
||||
{
|
||||
let container = self.build_live_playlist_container(descriptor);
|
||||
let container = self.build_live_playlist_container(&descriptor).await;
|
||||
Ok(BrowseResult::Containers(vec![container]))
|
||||
}
|
||||
}
|
||||
@@ -816,6 +854,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
supports_multiple_formats: true,
|
||||
supports_advanced_search: false,
|
||||
supports_pagination: false,
|
||||
handles_url_input: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -874,7 +913,7 @@ impl MusicSource for RadioParadiseSource {
|
||||
let descriptor = Self::get_channel_by_slug(&slug).ok_or_else(|| {
|
||||
MusicSourceError::ObjectNotFound(format!("Unknown channel: {}", slug))
|
||||
})?;
|
||||
Ok(self.build_live_stream_item(descriptor))
|
||||
Ok(self.build_live_stream_item(&descriptor).await)
|
||||
}
|
||||
|
||||
ObjectIdType::HistoryTrack { slug, pk } => {
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
channels::{ChannelDescriptor, ParadiseChannelKind, ALL_CHANNELS},
|
||||
channels::{channels, refresh_channels, ChannelDescriptor},
|
||||
client::RadioParadiseClient,
|
||||
models::{Block, EventId},
|
||||
playlist_feeder::RadioParadisePlaylistFeeder,
|
||||
@@ -133,13 +133,13 @@ impl Default for ParadiseHistoryBuilder {
|
||||
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
impl ParadiseStreamChannelConfig {
|
||||
pub fn from_config(cfg: &pmoconfig::Config, channel: ParadiseChannelKind) -> Self {
|
||||
pub fn from_config(cfg: &pmoconfig::Config, channel_slug: &str) -> Self {
|
||||
use serde_yaml::Value;
|
||||
let path = [
|
||||
"sources",
|
||||
"radio_paradise",
|
||||
"channels",
|
||||
channel.slug(),
|
||||
channel_slug,
|
||||
"max_lead_seconds",
|
||||
];
|
||||
match cfg.get_value(&path) {
|
||||
@@ -303,10 +303,10 @@ impl ParadiseStreamChannel {
|
||||
// 6. Lancer le pipeline audio
|
||||
let stop_token = CancellationToken::new();
|
||||
let pipeline_stop = stop_token.clone();
|
||||
let channel_display_name = descriptor.display_name;
|
||||
let channel_display_name = descriptor.display_name.clone();
|
||||
|
||||
let state = Arc::new(ChannelState {
|
||||
descriptor,
|
||||
descriptor: descriptor.clone(),
|
||||
config,
|
||||
client,
|
||||
feeder: feeder.clone(),
|
||||
@@ -401,7 +401,7 @@ impl ParadiseStreamChannel {
|
||||
}
|
||||
|
||||
pub fn descriptor(&self) -> ChannelDescriptor {
|
||||
self.descriptor
|
||||
self.descriptor.clone()
|
||||
}
|
||||
|
||||
/// Lance un pipeline dédié pour rejouer l'historique (FLAC pur) pour un client.
|
||||
@@ -888,11 +888,11 @@ impl Drop for HistoryOggStream {
|
||||
|
||||
/// Gestionnaire multi-canaux.
|
||||
pub struct ParadiseChannelManager {
|
||||
channels: HashMap<u8, Arc<ParadiseStreamChannel>>,
|
||||
channels: HashMap<u16, Arc<ParadiseStreamChannel>>,
|
||||
}
|
||||
|
||||
impl ParadiseChannelManager {
|
||||
pub fn new(channels: HashMap<u8, Arc<ParadiseStreamChannel>>) -> Self {
|
||||
pub fn new(channels: HashMap<u16, Arc<ParadiseStreamChannel>>) -> Self {
|
||||
Self { channels }
|
||||
}
|
||||
|
||||
@@ -901,13 +901,32 @@ impl ParadiseChannelManager {
|
||||
history_builder: Option<ParadiseHistoryBuilder>,
|
||||
server_base_url: Option<String>,
|
||||
) -> Result<Self> {
|
||||
// Rafraîchir la liste des canaux depuis l'API avant d'initialiser les
|
||||
// pipelines (fallback sur le registre courant en cas d'échec réseau)
|
||||
match RadioParadiseClient::new().await {
|
||||
Ok(client) => {
|
||||
if let Err(e) = refresh_channels(&client).await {
|
||||
tracing::warn!(
|
||||
"Failed to refresh Radio Paradise channel list, using current registry: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to create Radio Paradise client for channel discovery: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
let channel_list = channels();
|
||||
tracing::info!(
|
||||
"➡️ Entering with_defaults_with_cover_cache ({} channels, base_url={:?})",
|
||||
ALL_CHANNELS.len(),
|
||||
channel_list.len(),
|
||||
server_base_url
|
||||
);
|
||||
let mut map = HashMap::new();
|
||||
for descriptor in ALL_CHANNELS.iter().copied() {
|
||||
for descriptor in channel_list.iter().cloned() {
|
||||
let mut config = ParadiseStreamChannelConfig::default();
|
||||
config.server_base_url = server_base_url.clone();
|
||||
|
||||
@@ -940,7 +959,12 @@ impl ParadiseChannelManager {
|
||||
);
|
||||
let channel = match tokio::time::timeout(
|
||||
Duration::from_secs(20),
|
||||
ParadiseStreamChannel::new(descriptor, config, cover_cache.clone(), history_opts),
|
||||
ParadiseStreamChannel::new(
|
||||
descriptor.clone(),
|
||||
config,
|
||||
cover_cache.clone(),
|
||||
history_opts,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -980,7 +1004,7 @@ impl ParadiseChannelManager {
|
||||
Self::with_defaults_with_cover_cache(None, None, None).await
|
||||
}
|
||||
|
||||
pub fn get(&self, id: u8) -> Option<Arc<ParadiseStreamChannel>> {
|
||||
pub fn get(&self, id: u16) -> Option<Arc<ParadiseStreamChannel>> {
|
||||
self.channels.get(&id).cloned()
|
||||
}
|
||||
|
||||
@@ -988,7 +1012,7 @@ impl ParadiseChannelManager {
|
||||
self.channels.values()
|
||||
}
|
||||
|
||||
pub async fn prefetch_until_horizon(&self, channel_id: u8) -> Result<()> {
|
||||
pub async fn prefetch_until_horizon(&self, channel_id: u16) -> Result<()> {
|
||||
let channel = self
|
||||
.get(channel_id)
|
||||
.ok_or_else(|| anyhow!("Unknown channel id {}", channel_id))?;
|
||||
|
||||
@@ -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 }
|
||||
@@ -29,9 +30,17 @@ sha1 = "0.10"
|
||||
hex = "0.4"
|
||||
md-5 = "0.10"
|
||||
|
||||
# Crypto CMAF (AES-CTR déchiffrement frames, AES-CBC dérobage clé, HKDF dérivation)
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
ctr = "0.9"
|
||||
hkdf = "0.12"
|
||||
sha2 = "0.10"
|
||||
|
||||
# Cache en mémoire avec TTL
|
||||
moka = { version = "0.12", features = ["future"] }
|
||||
|
||||
|
||||
# Logging
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -53,6 +62,7 @@ pmodidl = { path = "../pmodidl" }
|
||||
# Intégration avec pmoserver pour l'API HTTP
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
axum = { version = "0.8", optional = true }
|
||||
tokio-util = { workspace = true, optional = true }
|
||||
rusqlite = { version = "0.37", features = ["bundled"], optional = true }
|
||||
|
||||
# Documentation OpenAPI
|
||||
@@ -68,7 +78,7 @@ pmocache = { path = "../pmocache" }
|
||||
[features]
|
||||
default = ["async-trait-support"]
|
||||
# Feature pour activer les extensions pmoserver
|
||||
pmoserver = ["dep:pmoserver", "dep:axum", "dep:utoipa"]
|
||||
pmoserver = ["dep:pmoserver", "dep:axum", "dep:utoipa", "dep:tokio-util"]
|
||||
# Feature pour activer le support serveur (cache registry)
|
||||
server = ["pmosource/server"]
|
||||
# Feature cache (deprecated - toujours actif maintenant)
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::QobuzApi;
|
||||
use crate::error::{QobuzError, Result};
|
||||
use crate::models::*;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use tracing::debug;
|
||||
|
||||
/// Réponse paginée de l'API
|
||||
@@ -182,6 +183,17 @@ struct FileUrlResponse {
|
||||
format_id: u8,
|
||||
}
|
||||
|
||||
/// Réponse de l'endpoint track/getList
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TrackListResponse {
|
||||
tracks: TrackListItems,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TrackListItems {
|
||||
items: Vec<TrackResponse>,
|
||||
}
|
||||
|
||||
fn default_streamable() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -213,6 +225,66 @@ impl QobuzApi {
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les détails de plusieurs tracks en une ou plusieurs requêtes batch.
|
||||
///
|
||||
/// Utilise l'endpoint `track/getList` (max 50 IDs par appel). Les fenêtres
|
||||
/// supérieures à 50 sont découpées et appellées en série. L'ordre de sortie
|
||||
/// correspond à l'ordre des `track_ids` en entrée.
|
||||
///
|
||||
/// Requiert que le secret s4 soit configuré.
|
||||
pub async fn get_tracks_batch(&self, track_ids: &[&str]) -> Result<Vec<Track>> {
|
||||
const MAX_PER_CALL: usize = 50;
|
||||
if track_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if track_ids.len() <= MAX_PER_CALL {
|
||||
return self.get_tracks_batch_chunk(track_ids).await;
|
||||
}
|
||||
debug!("get_tracks_batch: {} IDs en fenêtres de {}", track_ids.len(), MAX_PER_CALL);
|
||||
let mut all = Vec::with_capacity(track_ids.len());
|
||||
for chunk in track_ids.chunks(MAX_PER_CALL) {
|
||||
let mut tracks = self.get_tracks_batch_chunk(chunk).await?;
|
||||
all.append(&mut tracks);
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
async fn get_tracks_batch_chunk(&self, track_ids: &[&str]) -> Result<Vec<Track>> {
|
||||
use super::signing;
|
||||
|
||||
let secret = self.secret().ok_or_else(|| {
|
||||
QobuzError::Configuration("Secret s4 requis pour track/getList".to_string())
|
||||
})?;
|
||||
|
||||
let ids_csv = track_ids.join(",");
|
||||
let timestamp = signing::get_timestamp();
|
||||
let signature = signing::sign_track_get_list(&ids_csv, ×tamp, &secret);
|
||||
|
||||
let query_params = [
|
||||
("request_ts", timestamp.as_str()),
|
||||
("request_sig", signature.as_str()),
|
||||
];
|
||||
|
||||
// Les IDs sont envoyés comme tableau d'entiers dans le body JSON
|
||||
let ids_as_numbers: Vec<u64> = track_ids
|
||||
.iter()
|
||||
.filter_map(|id| id.parse().ok())
|
||||
.collect();
|
||||
let body = serde_json::json!({ "tracks_id": ids_as_numbers });
|
||||
|
||||
debug!("get_tracks_batch_chunk POST {} IDs", track_ids.len());
|
||||
let response: TrackListResponse = self
|
||||
.post_json_with_query("/track/getList", &query_params, body)
|
||||
.await?;
|
||||
|
||||
Ok(response
|
||||
.tracks
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|t| Self::parse_track(t, None))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Récupère les détails d'une track
|
||||
pub async fn get_track(&self, track_id: &str) -> Result<Track> {
|
||||
debug!("Fetching track {}", track_id);
|
||||
@@ -339,39 +411,121 @@ impl QobuzApi {
|
||||
Ok(Self::parse_playlist(response))
|
||||
}
|
||||
|
||||
/// Récupère les tracks d'une playlist
|
||||
/// Récupère les tracks d'une playlist.
|
||||
///
|
||||
/// 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 all_tracks = Vec::new();
|
||||
let mut offset = 0u32;
|
||||
|
||||
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;
|
||||
all_tracks.extend(tracks.items.into_iter().map(|t| 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 {} tracks total for playlist {}", all_tracks.len(), playlist_id);
|
||||
Ok(all_tracks)
|
||||
// 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());
|
||||
}
|
||||
|
||||
// Phase 2 : enrichissement via track/getList pour métadonnées complètes
|
||||
let id_refs: Vec<&str> = ordered_ids.iter().map(|s| s.as_str()).collect();
|
||||
let full_tracks = self.get_tracks_batch(&id_refs).await?;
|
||||
let mut track_map: HashMap<String, Track> =
|
||||
full_tracks.into_iter().map(|t| (t.id.clone(), t)).collect();
|
||||
let enriched: Vec<Track> = ordered_ids
|
||||
.iter()
|
||||
.filter_map(|id| track_map.remove(id.as_str()))
|
||||
.collect();
|
||||
debug!(
|
||||
"Fetched {} tracks for playlist {} via track/getList",
|
||||
enriched.len(), playlist_id
|
||||
);
|
||||
Ok(enriched)
|
||||
}
|
||||
|
||||
/// Récupère la liste des genres
|
||||
@@ -465,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_);
|
||||
@@ -508,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 {
|
||||
|
||||
261
pmoqobuz/src/api/cmaf.rs
Normal file
261
pmoqobuz/src/api/cmaf.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
//! Endpoints API CMAF de Qobuz : session/start et file/url.
|
||||
//!
|
||||
//! Ces endpoints implémentent le nouveau pipeline de streaming Qobuz
|
||||
//! qui remplace progressivement `/track/getFileUrl`.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::info;
|
||||
|
||||
use crate::cmaf::crypto::{compute_request_sig, CMAF_SEED};
|
||||
use crate::error::{QobuzError, Result};
|
||||
|
||||
/// État d'une session CMAF active.
|
||||
pub struct CmafSession {
|
||||
pub session_id: String,
|
||||
pub infos: String,
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
/// Réponse de l'endpoint /session/start.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SessionStartResponse {
|
||||
session_id: String,
|
||||
#[serde(default)]
|
||||
infos: Option<String>,
|
||||
expires_at: u64,
|
||||
}
|
||||
|
||||
/// Réponse de l'endpoint /file/url (CMAF).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CmafFileUrlResponse {
|
||||
/// Modèle d'URL avec le placeholder `$SEGMENT$`.
|
||||
#[serde(default)]
|
||||
pub url_template: Option<String>,
|
||||
/// Clé de contenu enveloppée, format `"qbz-1.wrapped_b64url.iv_b64url"`.
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
/// Nombre de segments audio (hors segment init).
|
||||
pub n_segments: u8,
|
||||
#[serde(default)]
|
||||
pub format_id: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub mime_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sampling_rate: Option<u32>,
|
||||
/// Profondeur de bits (champ v1 de l'API).
|
||||
#[serde(default)]
|
||||
pub bits_depth: Option<u32>,
|
||||
/// Profondeur de bits (champ v2 de l'API).
|
||||
#[serde(default)]
|
||||
pub bit_depth: Option<u32>,
|
||||
}
|
||||
|
||||
impl CmafFileUrlResponse {
|
||||
/// Retourne la profondeur de bits en préférant `bits_depth` puis `bit_depth`.
|
||||
pub fn resolved_bit_depth(&self) -> Option<u32> {
|
||||
self.bits_depth.or(self.bit_depth)
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_URL: &str = "https://www.qobuz.com/api.json/0.2";
|
||||
|
||||
fn current_timestamp() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
/// Signe une requête /session/start.
|
||||
fn sign_session_start(timestamp: u64) -> String {
|
||||
let mut args = std::collections::BTreeMap::new();
|
||||
args.insert("profile", "qbz-1".to_string());
|
||||
compute_request_sig("sessionstart", &args, ×tamp.to_string(), CMAF_SEED)
|
||||
}
|
||||
|
||||
/// Signe une requête /file/url.
|
||||
fn sign_file_url(track_id: &str, format_id: u32, timestamp: u64) -> String {
|
||||
let mut args = std::collections::BTreeMap::new();
|
||||
args.insert("format_id", format_id.to_string());
|
||||
args.insert("intent", "stream".to_string());
|
||||
args.insert("track_id", track_id.to_string());
|
||||
compute_request_sig("fileurl", &args, ×tamp.to_string(), CMAF_SEED)
|
||||
}
|
||||
|
||||
/// Gestionnaire de session CMAF avec renouvellement automatique.
|
||||
///
|
||||
/// Utilise le pattern double-checked lock : fast path sous read guard,
|
||||
/// slow path sous write guard exclusif pour éviter les renouvellements
|
||||
/// concurrents qui produiraient des `infos` incohérents avec les `key`
|
||||
/// retournées par `/file/url`.
|
||||
pub struct CmafSessionManager {
|
||||
session: RwLock<Option<CmafSession>>,
|
||||
}
|
||||
|
||||
impl CmafSessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self { session: RwLock::new(None) }
|
||||
}
|
||||
|
||||
/// Retourne `(session_id, infos)` d'une session valide, en en démarrant
|
||||
/// une nouvelle si la session courante est absente ou expire dans < 60s.
|
||||
pub async fn ensure_session(
|
||||
&self,
|
||||
http: &reqwest::Client,
|
||||
app_id: &str,
|
||||
auth_token: &str,
|
||||
) -> Result<(String, String)> {
|
||||
let now = current_timestamp();
|
||||
|
||||
// Fast path : session existante avec > 60s restants.
|
||||
{
|
||||
let guard = self.session.read().await;
|
||||
if let Some(ref cs) = *guard {
|
||||
if cs.expires_at > now + 60 {
|
||||
return Ok((cs.session_id.clone(), cs.infos.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path : prend le verrou d'écriture et vérifie à nouveau.
|
||||
let mut guard = self.session.write().await;
|
||||
if let Some(ref cs) = *guard {
|
||||
if cs.expires_at > now + 60 {
|
||||
return Ok((cs.session_id.clone(), cs.infos.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
info!("[CMAF] Démarrage d'une nouvelle session");
|
||||
let timestamp = current_timestamp();
|
||||
let sig = sign_session_start(timestamp);
|
||||
|
||||
let url = format!("{}/session/start", BASE_URL);
|
||||
let response = http
|
||||
.post(&url)
|
||||
.header("X-App-Id", app_id)
|
||||
.header("X-User-Auth-Token", auth_token)
|
||||
.form(&[
|
||||
("profile", "qbz-1"),
|
||||
("request_ts", ×tamp.to_string()),
|
||||
("request_sig", &sig),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(QobuzError::Http)?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(QobuzError::ApiError {
|
||||
code: status.as_u16(),
|
||||
message: format!("session/start échoué: {}", body),
|
||||
});
|
||||
}
|
||||
|
||||
let resp: SessionStartResponse = response.json().await.map_err(|e| {
|
||||
QobuzError::Other(format!("parse session/start: {}", e))
|
||||
})?;
|
||||
|
||||
let infos = resp.infos.unwrap_or_default();
|
||||
info!(
|
||||
"[CMAF] Session démarrée: id={}..., expires_at={}",
|
||||
&resp.session_id[..resp.session_id.len().min(8)],
|
||||
resp.expires_at,
|
||||
);
|
||||
|
||||
let session_id = resp.session_id.clone();
|
||||
let infos_clone = infos.clone();
|
||||
|
||||
*guard = Some(CmafSession {
|
||||
session_id: resp.session_id,
|
||||
infos,
|
||||
expires_at: resp.expires_at,
|
||||
});
|
||||
|
||||
Ok((session_id, infos_clone))
|
||||
}
|
||||
|
||||
/// Invalide la session courante (utile en cas d'erreur de déchiffrement).
|
||||
pub async fn invalidate(&self) {
|
||||
*self.session.write().await = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère l'URL de fichier CMAF pour un track.
|
||||
///
|
||||
/// Inclut le retry sur les erreurs transitoires (5xx, 429, erreurs réseau).
|
||||
/// Un 404 retourne immédiatement une erreur `NotFound`.
|
||||
pub async fn get_file_url(
|
||||
http: &reqwest::Client,
|
||||
app_id: &str,
|
||||
auth_token: &str,
|
||||
session_id: &str,
|
||||
track_id: &str,
|
||||
format_id: u32,
|
||||
) -> Result<CmafFileUrlResponse> {
|
||||
use crate::retry::{classify_reqwest, classify_status, retry_transient, DEFAULT_MAX_ATTEMPTS};
|
||||
|
||||
let url = format!("{}/file/url", BASE_URL);
|
||||
|
||||
let result = retry_transient(
|
||||
DEFAULT_MAX_ATTEMPTS,
|
||||
"CMAF file/url",
|
||||
|e: &QobuzError| matches!(e, QobuzError::RateLimitExceeded | QobuzError::ApiError { code: 500..=599, .. }),
|
||||
|_attempt| {
|
||||
let url = url.clone();
|
||||
async move {
|
||||
let timestamp = current_timestamp();
|
||||
let sig = sign_file_url(track_id, format_id, timestamp);
|
||||
|
||||
let response = http
|
||||
.get(&url)
|
||||
.header("X-App-Id", app_id)
|
||||
.header("X-User-Auth-Token", auth_token)
|
||||
.header("X-Session-Id", session_id)
|
||||
.query(&[
|
||||
("track_id", track_id),
|
||||
("format_id", &format_id.to_string()),
|
||||
("intent", "stream"),
|
||||
("request_ts", ×tamp.to_string()),
|
||||
("request_sig", &sig),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(QobuzError::Http)?;
|
||||
|
||||
let status = response.status();
|
||||
tracing::info!("[CMAF] file/url track_id={} format_id={} status={}", track_id, format_id, status);
|
||||
|
||||
if !status.is_success() {
|
||||
let code = status.as_u16();
|
||||
return Err(match code {
|
||||
404 => QobuzError::NotFound(format!("track {} non disponible", track_id)),
|
||||
429 => QobuzError::RateLimitExceeded,
|
||||
_ => QobuzError::ApiError {
|
||||
code,
|
||||
message: format!("file/url status {}", code),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let file_url: CmafFileUrlResponse = response.json().await.map_err(|e| {
|
||||
QobuzError::Other(format!("parse file/url: {}", e))
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"[CMAF] file/url: n_segments={}, mime={:?}, sampling_rate={:?}",
|
||||
file_url.n_segments,
|
||||
file_url.mime_type,
|
||||
file_url.sampling_rate,
|
||||
);
|
||||
|
||||
Ok(file_url)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -4,10 +4,12 @@
|
||||
|
||||
pub mod auth;
|
||||
pub mod catalog;
|
||||
pub mod cmaf;
|
||||
pub mod signing;
|
||||
pub mod spoofer;
|
||||
pub mod user;
|
||||
|
||||
use crate::api::cmaf::CmafSessionManager;
|
||||
use crate::error::{QobuzError, Result};
|
||||
use crate::models::AudioFormat;
|
||||
use reqwest::{Client, Response};
|
||||
@@ -95,6 +97,10 @@ pub struct QobuzApi {
|
||||
user_id: RwLock<Option<String>>,
|
||||
/// Format audio par défaut
|
||||
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 {
|
||||
@@ -114,6 +120,8 @@ impl QobuzApi {
|
||||
user_auth_token: RwLock::new(None),
|
||||
user_id: RwLock::new(None),
|
||||
format_id: AudioFormat::default(),
|
||||
cmaf_session: CmafSessionManager::new(),
|
||||
page_concurrency: 3,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -206,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;
|
||||
@@ -269,6 +282,37 @@ impl QobuzApi {
|
||||
self.request("POST", endpoint, params).await
|
||||
}
|
||||
|
||||
/// Effectue un POST avec signature en query params et données en JSON body.
|
||||
///
|
||||
/// Utilisé par les endpoints qui attendent une structure JSON complexe
|
||||
/// (ex: `track/getList` avec `{"tracks_id": [...]}`).
|
||||
pub(crate) async fn post_json_with_query<T: DeserializeOwned>(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
query_params: &[(&str, &str)],
|
||||
body: serde_json::Value,
|
||||
) -> Result<T> {
|
||||
let url = format!("{}{}", API_BASE_URL, endpoint);
|
||||
debug!("POST JSON {} with {} query params", url, query_params.len());
|
||||
|
||||
let app_id = self.app_id.read().unwrap().clone();
|
||||
let mut builder = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("X-App-Id", &app_id)
|
||||
.header("Accept-Language", "en,en-US;q=0.8,ko;q=0.6,zh;q=0.4,zh-CN;q=0.2")
|
||||
.header("Access-Control-Request-Headers", "x-user-auth-token,x-app-id")
|
||||
.query(query_params)
|
||||
.json(&body);
|
||||
|
||||
if let Some(token) = self.auth_token() {
|
||||
builder = builder.header("X-User-Auth-Token", token);
|
||||
}
|
||||
|
||||
let response = builder.send().await?;
|
||||
self.handle_response(response, endpoint, query_params).await
|
||||
}
|
||||
|
||||
/// Effectue une requête à l'API (générique)
|
||||
async fn request<T: DeserializeOwned>(
|
||||
&self,
|
||||
@@ -316,6 +360,57 @@ impl QobuzApi {
|
||||
self.handle_response(response, endpoint, params).await
|
||||
}
|
||||
|
||||
/// Retourne le client HTTP interne (utilisé par les endpoints CMAF).
|
||||
pub(crate) fn http_client(&self) -> &Client {
|
||||
&self.client
|
||||
}
|
||||
|
||||
/// Assure qu'une session CMAF valide existe et retourne `(session_id, infos)`.
|
||||
///
|
||||
/// Crée ou renouvelle automatiquement la session si elle est absente ou expire
|
||||
/// dans moins de 60 secondes. Les appels concurrents sont sérialisés pour
|
||||
/// éviter des sessions incohérentes.
|
||||
pub async fn ensure_cmaf_session(&self) -> Result<(String, String)> {
|
||||
let app_id = self.app_id.read().unwrap().clone();
|
||||
let auth_token = self
|
||||
.user_auth_token
|
||||
.read()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.ok_or_else(|| QobuzError::Unauthorized("Token d'authentification manquant pour CMAF".into()))?;
|
||||
|
||||
self.cmaf_session
|
||||
.ensure_session(&self.client, &app_id, &auth_token)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Récupère l'URL de fichier CMAF pour un track avec le format donné.
|
||||
pub async fn get_cmaf_file_url(
|
||||
&self,
|
||||
track_id: &str,
|
||||
format_id: u32,
|
||||
) -> Result<crate::api::cmaf::CmafFileUrlResponse> {
|
||||
let app_id = self.app_id.read().unwrap().clone();
|
||||
let auth_token = self
|
||||
.user_auth_token
|
||||
.read()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.ok_or_else(|| QobuzError::Unauthorized("Token d'authentification manquant pour CMAF".into()))?;
|
||||
|
||||
let (session_id, _infos) = self.ensure_cmaf_session().await?;
|
||||
|
||||
crate::api::cmaf::get_file_url(
|
||||
&self.client,
|
||||
&app_id,
|
||||
&auth_token,
|
||||
&session_id,
|
||||
track_id,
|
||||
format_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Traite la réponse HTTP
|
||||
async fn handle_response<T: DeserializeOwned>(
|
||||
&self,
|
||||
|
||||
@@ -88,6 +88,20 @@ pub fn sign_track_get_file_url(
|
||||
/// # Returns
|
||||
///
|
||||
/// Signature MD5 hexadécimale
|
||||
/// Signe une requête track/getList
|
||||
///
|
||||
/// Chaîne signée : `"trackgetList" + "tracks_id" + ids_csv + timestamp + secret`
|
||||
/// où `ids_csv` est la liste des IDs séparés par des virgules.
|
||||
pub fn sign_track_get_list(ids_csv: &str, timestamp: &str, secret: &[u8]) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(b"trackgetList");
|
||||
hasher.update(b"tracks_id");
|
||||
hasher.update(ids_csv.as_bytes());
|
||||
hasher.update(timestamp.as_bytes());
|
||||
hasher.update(secret);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn sign_userlib_get_albums(timestamp: &str, secret: &[u8]) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
|
||||
|
||||
@@ -3,36 +3,40 @@ use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use indexmap::IndexMap;
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Timeout par requête HTTP — le bundle fait ~7 MB et le CDN Qobuz peut être lent.
|
||||
const BUNDLE_FETCH_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
|
||||
/// Tentatives supplémentaires après un échec d'extraction.
|
||||
const BUNDLE_EXTRACTION_RETRIES: usize = 2;
|
||||
|
||||
pub struct Spoofer {
|
||||
bundle: String,
|
||||
/// Version du bundle extrait, ex. `"8.1.0-b019"`.
|
||||
bundle_version: String,
|
||||
seed_timezone_regex: Regex,
|
||||
info_extras_regex_template: String,
|
||||
app_id_regex: Regex,
|
||||
}
|
||||
|
||||
impl Spoofer {
|
||||
/// Crée un nouveau Spoofer et télécharge le bundle.js
|
||||
pub async fn new() -> Result<Self> {
|
||||
// Expressions régulières (équivalent Python)
|
||||
let seed_timezone_regex = Regex::new(
|
||||
r#"[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[a-z]+)\)"#,
|
||||
)?;
|
||||
/// Version du bundle Qobuz actuellement chargé.
|
||||
pub fn bundle_version(&self) -> &str {
|
||||
&self.bundle_version
|
||||
}
|
||||
|
||||
let info_extras_regex_template =
|
||||
r#"name:"\w+/(?P<timezone>{timezones})",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)""#
|
||||
.to_string();
|
||||
|
||||
let app_id_regex = Regex::new(
|
||||
r#"production:\{api:\{appId:"(?P<app_id>\d{9})",appSecret:"(?P<secret>\w{32})"\},braze:.\(.\(\{\},.\),\{\},\{apiKey:"([-0-9a-fA-F]{36})"\}\),extra:.\}"#,
|
||||
)?;
|
||||
|
||||
// Créer un client HTTP
|
||||
/// Récupère uniquement la version du bundle courant sans télécharger les 7 MB.
|
||||
///
|
||||
/// Utile pour savoir si le bundle a changé avant de déclencher une extraction
|
||||
/// complète. Ne télécharge que la page de login (~5 KB).
|
||||
pub async fn fetch_current_bundle_version() -> Result<String> {
|
||||
let client = Client::builder()
|
||||
.user_agent("Mozilla/5.0 (compatible; PMOMusic/1.0)")
|
||||
.timeout(BUNDLE_FETCH_TIMEOUT)
|
||||
.build()?;
|
||||
|
||||
println!("Récupération de la page de login...");
|
||||
let login_page = client
|
||||
.get("https://play.qobuz.com/login")
|
||||
.send()
|
||||
@@ -40,31 +44,80 @@ impl Spoofer {
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
// Extraire l'URL du bundle
|
||||
let bundle_url_regex = Regex::new(
|
||||
r#"<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>"#,
|
||||
let re = Regex::new(
|
||||
r#"<script src="/resources/(\d+\.\d+\.\d+-[a-z]\d{3})/bundle\.js"></script>"#,
|
||||
)?;
|
||||
let bundle_url = bundle_url_regex
|
||||
.captures(&login_page)
|
||||
re.captures(&login_page)
|
||||
.and_then(|cap| cap.get(1))
|
||||
.ok_or_else(|| anyhow::anyhow!("Impossible de trouver l'URL du bundle"))?
|
||||
.as_str();
|
||||
|
||||
println!("Téléchargement du bundle depuis: {}", bundle_url);
|
||||
let bundle_full_url = format!("https://play.qobuz.com{}", bundle_url);
|
||||
let bundle = client.get(&bundle_full_url).send().await?.text().await?;
|
||||
|
||||
println!("Bundle téléchargé ({} bytes)", bundle.len());
|
||||
|
||||
Ok(Self {
|
||||
bundle,
|
||||
seed_timezone_regex,
|
||||
info_extras_regex_template,
|
||||
app_id_regex,
|
||||
})
|
||||
.map(|m| m.as_str().to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("Version bundle introuvable dans la page de login"))
|
||||
}
|
||||
|
||||
/// Extrait l'App ID depuis le bundle
|
||||
/// Télécharge et parse le bundle Qobuz. Retry jusqu'à `BUNDLE_EXTRACTION_RETRIES`
|
||||
/// fois en cas d'échec réseau ou d'extraction.
|
||||
pub async fn new() -> Result<Self> {
|
||||
let seed_timezone_regex = Regex::new(
|
||||
r#"[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[a-z]+)\)"#,
|
||||
)?;
|
||||
let info_extras_regex_template =
|
||||
r#"name:"\w+/(?P<timezone>{timezones})",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)""#
|
||||
.to_string();
|
||||
let app_id_regex = Regex::new(
|
||||
r#"production:\{api:\{appId:"(?P<app_id>\d{9})",appSecret:"(?P<secret>\w{32})"\},braze:.\(.\(\{\},.\),\{\},\{apiKey:"([-0-9a-fA-F]{36})"\}\),extra:.\}"#,
|
||||
)?;
|
||||
|
||||
let client = Client::builder()
|
||||
.user_agent("Mozilla/5.0 (compatible; PMOMusic/1.0)")
|
||||
.timeout(BUNDLE_FETCH_TIMEOUT)
|
||||
.build()?;
|
||||
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for attempt in 1..=(BUNDLE_EXTRACTION_RETRIES + 1) {
|
||||
match Self::fetch_bundle(&client).await {
|
||||
Ok((bundle, bundle_version)) => {
|
||||
info!("[Spoofer] Bundle {} téléchargé ({} bytes)", bundle_version, bundle.len());
|
||||
return Ok(Self {
|
||||
bundle,
|
||||
bundle_version,
|
||||
seed_timezone_regex,
|
||||
info_extras_regex_template,
|
||||
app_id_regex,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[Spoofer] Tentative {}/{} échouée : {}", attempt, BUNDLE_EXTRACTION_RETRIES + 1, e);
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("Échec téléchargement bundle")))
|
||||
}
|
||||
|
||||
async fn fetch_bundle(client: &Client) -> Result<(String, String)> {
|
||||
let login_page = client
|
||||
.get("https://play.qobuz.com/login")
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
let bundle_url_regex = Regex::new(
|
||||
r#"<script src="(/resources/(\d+\.\d+\.\d+-[a-z]\d{3})/bundle\.js)"></script>"#,
|
||||
)?;
|
||||
let caps = bundle_url_regex
|
||||
.captures(&login_page)
|
||||
.ok_or_else(|| anyhow::anyhow!("URL bundle introuvable dans la page de login"))?;
|
||||
let bundle_path = caps.get(1).unwrap().as_str();
|
||||
let bundle_version = caps.get(2).unwrap().as_str().to_string();
|
||||
|
||||
let bundle_url = format!("https://play.qobuz.com{}", bundle_path);
|
||||
debug!("[Spoofer] Téléchargement bundle depuis {}", bundle_url);
|
||||
|
||||
let bundle = client.get(&bundle_url).send().await?.text().await?;
|
||||
Ok((bundle, bundle_version))
|
||||
}
|
||||
|
||||
/// Extrait l'App ID depuis le bundle.
|
||||
pub fn get_app_id(&self) -> Result<String> {
|
||||
let captures = self
|
||||
.app_id_regex
|
||||
@@ -78,10 +131,7 @@ impl Spoofer {
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Extrait l'appSecret depuis le bundle (secret MD5 à 32 caractères)
|
||||
///
|
||||
/// Ce secret est utilisé directement par Qobuz (nouvelle méthode)
|
||||
/// au lieu d'être XORé avec l'app_id
|
||||
/// Extrait l'appSecret depuis le bundle (secret MD5 à 32 caractères).
|
||||
pub fn get_app_secret(&self) -> Result<String> {
|
||||
let captures = self
|
||||
.app_id_regex
|
||||
@@ -95,37 +145,25 @@ impl Spoofer {
|
||||
.to_string())
|
||||
}
|
||||
|
||||
/// Extrait les secrets depuis le bundle
|
||||
/// Extrait les secrets timezone depuis le bundle.
|
||||
pub fn get_secrets(&self) -> Result<IndexMap<String, String>> {
|
||||
// Étape 1: Extraire tous les seed/timezone pairs
|
||||
let mut secrets: IndexMap<String, Vec<String>> = IndexMap::new();
|
||||
|
||||
for captures in self.seed_timezone_regex.captures_iter(&self.bundle) {
|
||||
let seed = captures
|
||||
.name("seed")
|
||||
.ok_or_else(|| anyhow::anyhow!("Groupe seed non trouvé"))?
|
||||
.as_str();
|
||||
let timezone = captures
|
||||
.name("timezone")
|
||||
.ok_or_else(|| anyhow::anyhow!("Groupe timezone non trouvé"))?
|
||||
.as_str();
|
||||
|
||||
let seed = captures.name("seed").unwrap().as_str();
|
||||
let timezone = captures.name("timezone").unwrap().as_str();
|
||||
secrets
|
||||
.entry(timezone.to_string())
|
||||
.or_insert_with(Vec::new)
|
||||
.or_default()
|
||||
.push(seed.to_string());
|
||||
}
|
||||
|
||||
println!("Timezones trouvées: {:?}", secrets.keys());
|
||||
debug!("[Spoofer] Timezones trouvées : {:?}", secrets.keys().collect::<Vec<_>>());
|
||||
|
||||
// Étape 2: Réordonner - on met la deuxième timezone en premier
|
||||
// (comme le fait le code Python avec move_to_end)
|
||||
if secrets.len() >= 2 {
|
||||
let keys: Vec<String> = secrets.keys().cloned().collect();
|
||||
let second_key = keys[1].clone();
|
||||
let second_value = secrets.get(&second_key).unwrap().clone();
|
||||
|
||||
// Retirer et réinsérer pour le mettre en premier
|
||||
secrets.shift_remove(&second_key);
|
||||
let mut new_secrets = IndexMap::new();
|
||||
new_secrets.insert(second_key, second_value);
|
||||
@@ -135,7 +173,6 @@ impl Spoofer {
|
||||
secrets = new_secrets;
|
||||
}
|
||||
|
||||
// Étape 3: Construire la regex pour info/extras
|
||||
let timezones_capitalized: Vec<String> = secrets
|
||||
.keys()
|
||||
.map(|tz| {
|
||||
@@ -150,24 +187,12 @@ impl Spoofer {
|
||||
let info_extras_regex_str = self
|
||||
.info_extras_regex_template
|
||||
.replace("{timezones}", &timezones_capitalized.join("|"));
|
||||
|
||||
let info_extras_regex = Regex::new(&info_extras_regex_str)?;
|
||||
|
||||
// Étape 4: Extraire info et extras pour chaque timezone
|
||||
for captures in info_extras_regex.captures_iter(&self.bundle) {
|
||||
let timezone_cap = captures
|
||||
.name("timezone")
|
||||
.ok_or_else(|| anyhow::anyhow!("Groupe timezone non trouvé"))?
|
||||
.as_str();
|
||||
let info = captures
|
||||
.name("info")
|
||||
.ok_or_else(|| anyhow::anyhow!("Groupe info non trouvé"))?
|
||||
.as_str();
|
||||
let extras = captures
|
||||
.name("extras")
|
||||
.ok_or_else(|| anyhow::anyhow!("Groupe extras non trouvé"))?
|
||||
.as_str();
|
||||
|
||||
let timezone_cap = captures.name("timezone").unwrap().as_str();
|
||||
let info = captures.name("info").unwrap().as_str();
|
||||
let extras = captures.name("extras").unwrap().as_str();
|
||||
let timezone_lower = timezone_cap.to_lowercase();
|
||||
if let Some(vec) = secrets.get_mut(&timezone_lower) {
|
||||
vec.push(info.to_string());
|
||||
@@ -175,31 +200,19 @@ impl Spoofer {
|
||||
}
|
||||
}
|
||||
|
||||
// Étape 5: Décoder les secrets en base64
|
||||
let mut decoded_secrets = IndexMap::new();
|
||||
for (timezone, parts) in secrets {
|
||||
let concatenated = parts.join("");
|
||||
|
||||
// Retirer les 44 derniers caractères (comme Python [:-44])
|
||||
if concatenated.len() > 44 {
|
||||
let trimmed = &concatenated[..concatenated.len() - 44];
|
||||
|
||||
// Décoder en base64
|
||||
match STANDARD.decode(trimmed) {
|
||||
Ok(decoded_bytes) => match String::from_utf8(decoded_bytes) {
|
||||
Ok(decoded_str) => {
|
||||
decoded_secrets.insert(timezone, decoded_str);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Erreur UTF-8 pour timezone {}: {}", timezone, e);
|
||||
}
|
||||
Err(e) => warn!("[Spoofer] UTF-8 invalide pour timezone {}: {}", timezone, e),
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Erreur de décodage base64 pour timezone {}: {}",
|
||||
timezone, e
|
||||
);
|
||||
}
|
||||
Err(e) => warn!("[Spoofer] Base64 invalide pour timezone {}: {}", timezone, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use super::QobuzApi;
|
||||
use crate::error::{QobuzError, Result};
|
||||
use crate::models::*;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use tracing::debug;
|
||||
|
||||
/// Réponse paginée
|
||||
@@ -86,7 +87,11 @@ impl QobuzApi {
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les tracks favorites de l'utilisateur
|
||||
/// Récupère les tracks favorites de l'utilisateur.
|
||||
///
|
||||
/// Si le secret s4 est disponible, les données de base retournées par
|
||||
/// `/favorite/getUserFavorites` sont enrichies via `track/getList` pour
|
||||
/// obtenir les métadonnées complètes (performer, sample_rate, bit_depth).
|
||||
pub async fn get_favorite_tracks(&self) -> Result<Vec<Track>> {
|
||||
let user_id = self.ensure_authenticated()?;
|
||||
debug!("Fetching favorite tracks for user {}", user_id);
|
||||
@@ -99,16 +104,32 @@ impl QobuzApi {
|
||||
|
||||
let response: FavoritesResponse = self.get("/favorite/getUserFavorites", ¶ms).await?;
|
||||
|
||||
if let Some(tracks) = response.tracks {
|
||||
Ok(tracks
|
||||
let base_tracks: Vec<Track> = match response.tracks {
|
||||
Some(tracks) => tracks
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|t| QobuzApi::parse_track(t, None))
|
||||
.filter(|t| t.streamable)
|
||||
.collect())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
.collect(),
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
if base_tracks.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Enrichissement via track/getList pour métadonnées complètes
|
||||
let ordered_ids: Vec<String> = base_tracks.iter().map(|t| t.id.clone()).collect();
|
||||
let id_refs: Vec<&str> = ordered_ids.iter().map(|s| s.as_str()).collect();
|
||||
let full_tracks = self.get_tracks_batch(&id_refs).await?;
|
||||
let mut track_map: HashMap<String, Track> =
|
||||
full_tracks.into_iter().map(|t| (t.id.clone(), t)).collect();
|
||||
let enriched: Vec<Track> = ordered_ids
|
||||
.iter()
|
||||
.filter_map(|id| track_map.remove(id.as_str()))
|
||||
.collect();
|
||||
debug!("Fetched {} favorite tracks via track/getList", enriched.len());
|
||||
Ok(enriched)
|
||||
}
|
||||
|
||||
/// Récupère les playlists de l'utilisateur
|
||||
|
||||
@@ -74,6 +74,7 @@ pub fn create_router(state: QobuzState) -> Router {
|
||||
// Tracks
|
||||
.route("/tracks/:id", axum::routing::get(get_track))
|
||||
.route("/tracks/:id/stream", axum::routing::get(get_stream_url))
|
||||
.route("/tracks/:id/flac", axum::routing::get(get_flac_stream))
|
||||
// Artists
|
||||
.route("/artists/:id/albums", axum::routing::get(get_artist_albums))
|
||||
.route(
|
||||
@@ -154,6 +155,35 @@ async fn get_stream_url(
|
||||
Ok(Json(serde_json::json!({ "url": url })))
|
||||
}
|
||||
|
||||
/// `GET /tracks/:id/flac` — flux FLAC déchiffré en streaming progressif via CMAF.
|
||||
///
|
||||
/// Retourne les bytes FLAC directement dans le corps de la réponse HTTP, au fur
|
||||
/// et à mesure que les segments CMAF sont téléchargés et déchiffrés. Le client
|
||||
/// (typiquement `pmocache::add_from_url`) peut commencer à consommer le contenu
|
||||
/// avant la fin du téléchargement, préservant le progressive caching.
|
||||
///
|
||||
/// `Content-Length` est une estimation calculée depuis la table des segments du
|
||||
/// segment init — la valeur réelle peut différer de quelques octets.
|
||||
#[cfg(feature = "pmoserver")]
|
||||
async fn get_flac_stream(
|
||||
State(state): State<QobuzState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
use axum::http::header;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
let (reader, estimated_size) = state.client.open_cmaf_stream(&id).await?;
|
||||
let stream = ReaderStream::new(reader);
|
||||
let body = axum::body::Body::from_stream(stream);
|
||||
|
||||
axum::response::Response::builder()
|
||||
.status(axum::http::StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "audio/flac")
|
||||
.header(header::CONTENT_LENGTH, estimated_size)
|
||||
.body(body)
|
||||
.map_err(|e| AppError(QobuzError::Other(e.to_string())))
|
||||
}
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
async fn get_artist_albums(
|
||||
State(state): State<QobuzState>,
|
||||
|
||||
@@ -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)))
|
||||
@@ -236,14 +238,32 @@ impl QobuzClient {
|
||||
/// - Quand aucun appid/secret n'est configuré
|
||||
/// - Quand les credentials configurés sont invalides/expirés
|
||||
async fn try_spoofer_fallback(config: &Config) -> Result<QobuzApi> {
|
||||
// Vérification bon marché : si la version du bundle n'a pas changé,
|
||||
// re-télécharger les 7 MB ne donnera pas de meilleurs secrets.
|
||||
// On court-circuite l'extraction et on passe directement au DEFAULT_APP_ID.
|
||||
if let Ok(Some(cached_version)) = config.get_qobuz_bundle_version() {
|
||||
match crate::api::Spoofer::fetch_current_bundle_version().await {
|
||||
Ok(current) if current == cached_version => {
|
||||
info!(
|
||||
"[Spoofer] Bundle inchangé ({}) — secret invalide pour une autre raison, skip extraction",
|
||||
current
|
||||
);
|
||||
return QobuzApi::new(DEFAULT_APP_ID);
|
||||
}
|
||||
Ok(new_version) => {
|
||||
info!("[Spoofer] Bundle rotaté : {} → {}, re-extraction", cached_version, new_version);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("[Spoofer] Impossible de vérifier la version du bundle : {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((app_id, secret)) = Self::fetch_spoofer_credentials(config).await? {
|
||||
// Use raw secret from Spoofer (no XOR)
|
||||
return QobuzApi::with_raw_secret(app_id, &secret);
|
||||
}
|
||||
|
||||
info!(
|
||||
"✗ No valid secret found from Spoofer, falling back to DEFAULT_APP_ID without secret"
|
||||
);
|
||||
info!("✗ No valid secret found from Spoofer, falling back to DEFAULT_APP_ID without secret");
|
||||
QobuzApi::new(DEFAULT_APP_ID)
|
||||
}
|
||||
|
||||
@@ -288,18 +308,15 @@ impl QobuzClient {
|
||||
timezone
|
||||
);
|
||||
|
||||
// Save both appid and the working secret
|
||||
if let Err(e) = config.set_qobuz_appid(&app_id)
|
||||
{
|
||||
// Save appid, secret, and bundle version
|
||||
if let Err(e) = config.set_qobuz_appid(&app_id) {
|
||||
debug!("Could not save appid: {}", e);
|
||||
}
|
||||
if let Err(e) =
|
||||
config.set_qobuz_spoofer_secret(secret)
|
||||
{
|
||||
debug!(
|
||||
"Could not save spoofer secret: {}",
|
||||
e
|
||||
);
|
||||
if let Err(e) = config.set_qobuz_spoofer_secret(secret) {
|
||||
debug!("Could not save spoofer secret: {}", e);
|
||||
}
|
||||
if let Err(e) = config.set_qobuz_bundle_version(spoofer.bundle_version()) {
|
||||
debug!("Could not save bundle version: {}", e);
|
||||
}
|
||||
|
||||
return Ok(Some((
|
||||
@@ -625,6 +642,44 @@ impl QobuzClient {
|
||||
Ok(track)
|
||||
}
|
||||
|
||||
/// Récupère les détails d'un batch de tracks via track/getList.
|
||||
///
|
||||
/// Les tracks retournées sont mises en cache individuellement.
|
||||
/// Voir `QobuzApi::get_tracks_batch` pour le détail du comportement.
|
||||
pub async fn get_tracks_batch(&self, track_ids: &[&str]) -> Result<Vec<Track>> {
|
||||
// Séparer les IDs déjà en cache de ceux à récupérer
|
||||
let mut cached: std::collections::HashMap<String, Track> =
|
||||
std::collections::HashMap::new();
|
||||
let mut missing_ids: Vec<&str> = Vec::new();
|
||||
|
||||
for &id in track_ids {
|
||||
if let Some(track) = self.cache.get_track(id).await {
|
||||
cached.insert(id.to_string(), track);
|
||||
} else {
|
||||
missing_ids.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if !missing_ids.is_empty() {
|
||||
let fetched = self
|
||||
.call_with_auth_repair("get_tracks_batch", || {
|
||||
self.api.get_tracks_batch(&missing_ids)
|
||||
})
|
||||
.await?;
|
||||
|
||||
for track in fetched {
|
||||
self.cache.put_track(track.id.clone(), track.clone()).await;
|
||||
cached.insert(track.id.clone(), track);
|
||||
}
|
||||
}
|
||||
|
||||
// Restituer dans l'ordre d'entrée
|
||||
Ok(track_ids
|
||||
.iter()
|
||||
.filter_map(|id| cached.remove(*id))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Récupère l'URL de streaming d'une track
|
||||
pub async fn get_stream_url(&self, track_id: &str) -> Result<String> {
|
||||
// Vérifier le cache d'abord
|
||||
@@ -772,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
|
||||
@@ -794,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 ============
|
||||
@@ -973,6 +1065,152 @@ impl QobuzClient {
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// ============ CMAF (streaming moderne) ============
|
||||
|
||||
/// Prépare le streaming CMAF pour un track : dérive les clés et fetche
|
||||
/// le segment d'initialisation. Retourne une `CmafStreamInfo` prête à l'emploi.
|
||||
///
|
||||
/// Pour télécharger la totalité du FLAC déchiffré d'un coup, utilisez
|
||||
/// plutôt `download_cmaf_full`. Pour streamer segment par segment, utilisez
|
||||
/// les données de `CmafStreamInfo` avec `cmaf::fetch_all_segments`.
|
||||
///
|
||||
/// # Erreurs
|
||||
///
|
||||
/// * `QobuzError::NotFound` — track non disponible sur Qobuz
|
||||
/// * `QobuzError::Unauthorized` — session expirée (réparée automatiquement)
|
||||
/// * `QobuzError::Other` — erreur CMAF (clé invalide, segment init corrompu, etc.)
|
||||
pub async fn get_cmaf_stream_info(
|
||||
&self,
|
||||
track_id: &str,
|
||||
) -> Result<crate::models::CmafStreamInfo> {
|
||||
let format_id = self.api.format().id() as u32;
|
||||
|
||||
let file_url = self
|
||||
.call_with_auth_repair("get_cmaf_file_url", || {
|
||||
self.api.get_cmaf_file_url(track_id, format_id)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let bit_depth = file_url.resolved_bit_depth();
|
||||
let url_template = file_url
|
||||
.url_template
|
||||
.ok_or_else(|| QobuzError::Other("CMAF file/url: url_template absent".into()))?;
|
||||
let key_str = file_url
|
||||
.key
|
||||
.ok_or_else(|| QobuzError::Other("CMAF file/url: key absent".into()))?;
|
||||
|
||||
let (_session_id, infos) = self
|
||||
.call_with_auth_repair("ensure_cmaf_session", || {
|
||||
self.api.ensure_cmaf_session()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let setup = crate::cmaf::setup_streaming(
|
||||
url_template,
|
||||
&key_str,
|
||||
&infos,
|
||||
file_url.n_segments,
|
||||
file_url.format_id.unwrap_or(format_id),
|
||||
file_url.sampling_rate,
|
||||
bit_depth,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(crate::models::CmafStreamInfo {
|
||||
url_template: setup.url_template,
|
||||
n_segments: setup.n_segments,
|
||||
content_key: setup.content_key,
|
||||
flac_header: setup.flac_header,
|
||||
segment_table: setup.segment_table,
|
||||
format_id: setup.format_id,
|
||||
sampling_rate: setup.sampling_rate,
|
||||
bit_depth: setup.bit_depth,
|
||||
})
|
||||
}
|
||||
|
||||
/// Ouvre un flux FLAC déchiffré en mode progressif pour un track CMAF.
|
||||
///
|
||||
/// Retourne un `(AsyncRead, taille_estimée)`. Le flux commence à produire
|
||||
/// du FLAC dès que le premier segment est déchiffré — le cache peut commencer
|
||||
/// à servir avant la fin du téléchargement (progressive caching préservé).
|
||||
///
|
||||
/// C'est la méthode à utiliser pour alimenter `pmocache::add_from_reader`.
|
||||
pub async fn open_cmaf_stream(
|
||||
&self,
|
||||
track_id: &str,
|
||||
) -> Result<(impl tokio::io::AsyncRead + Send + Unpin + 'static, u64)> {
|
||||
let format_id = self.api.format().id() as u32;
|
||||
|
||||
let file_url = self
|
||||
.call_with_auth_repair("get_cmaf_file_url", || {
|
||||
self.api.get_cmaf_file_url(track_id, format_id)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let bit_depth = file_url.resolved_bit_depth();
|
||||
let url_template = file_url
|
||||
.url_template
|
||||
.ok_or_else(|| QobuzError::Other("CMAF: url_template absent".into()))?;
|
||||
let key_str = file_url
|
||||
.key
|
||||
.ok_or_else(|| QobuzError::Other("CMAF: key absent".into()))?;
|
||||
|
||||
let (_session_id, infos) = self
|
||||
.call_with_auth_repair("ensure_cmaf_session", || self.api.ensure_cmaf_session())
|
||||
.await?;
|
||||
|
||||
crate::cmaf::open_flac_stream(
|
||||
url_template,
|
||||
key_str,
|
||||
infos,
|
||||
file_url.n_segments,
|
||||
file_url.format_id.unwrap_or(format_id),
|
||||
file_url.sampling_rate,
|
||||
bit_depth,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Télécharge un track complet via CMAF et retourne les bytes FLAC déchiffrés.
|
||||
///
|
||||
/// Bloquant jusqu'à la fin du téléchargement. Pour les gros fichiers Hi-Res,
|
||||
/// préférer `open_cmaf_stream` qui préserve le progressive caching.
|
||||
pub async fn download_cmaf_full(&self, track_id: &str) -> Result<Vec<u8>> {
|
||||
let format_id = self.api.format().id() as u32;
|
||||
|
||||
let file_url = self
|
||||
.call_with_auth_repair("get_cmaf_file_url", || {
|
||||
self.api.get_cmaf_file_url(track_id, format_id)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let bit_depth = file_url.resolved_bit_depth();
|
||||
let url_template = file_url
|
||||
.url_template
|
||||
.ok_or_else(|| QobuzError::Other("CMAF file/url: url_template absent".into()))?;
|
||||
let key_str = file_url
|
||||
.key
|
||||
.ok_or_else(|| QobuzError::Other("CMAF file/url: key absent".into()))?;
|
||||
|
||||
let (_session_id, infos) = self
|
||||
.call_with_auth_repair("ensure_cmaf_session", || {
|
||||
self.api.ensure_cmaf_session()
|
||||
})
|
||||
.await?;
|
||||
|
||||
crate::cmaf::download_full(
|
||||
url_template,
|
||||
&key_str,
|
||||
&infos,
|
||||
file_url.n_segments,
|
||||
file_url.format_id.unwrap_or(format_id),
|
||||
file_url.sampling_rate,
|
||||
bit_depth,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user