Compare commits
662 Commits
push-xsnmp
...
version-0.
| Author | SHA1 | Date | |
|---|---|---|---|
| cdd0e79be8 | |||
| 02cff8e913 | |||
| 7b5eb5a4da | |||
| c524ba57e5 | |||
| 8e9dd7601f | |||
| 50bc0570ba | |||
| f3274fcc10 | |||
| 2cdc7109d1 | |||
| c250801a9f | |||
| 2ef4ebf020 | |||
| 82bfab233f | |||
| 93f5900456 | |||
| af003292bc | |||
| c1bf488ab2 | |||
| 6f25b8ef53 | |||
| 01abf5d8bc | |||
| 258d97bd1f | |||
| 646633af6b | |||
| 7d2cb75e58 | |||
| da12cc82b3 | |||
| 7ce91bc079 | |||
| f074f4d657 | |||
| 8fb135541d | |||
| 5a30731854 | |||
| 36a05a2575 | |||
| 293daf5b3b | |||
| 700c8f2fc9 | |||
| c3a9e3ea7b | |||
| 7f10f55086 | |||
| 220eeb1244 | |||
| c9f15289d0 | |||
| c745179200 | |||
| 56e1f4c0fb | |||
| 4324e1499a | |||
| bd8f87fbe4 | |||
| 58ac28489c | |||
| 256495b483 | |||
| 2ebbed2ffc | |||
| 38f49d4439 | |||
| d708bb6035 | |||
| 3d533e1989 | |||
| 60785745aa | |||
| 9ff85bbd10 | |||
| 7546072d2b | |||
| c53ccf2f93 | |||
| 048718c895 | |||
| 7e14d36230 | |||
| a143e39422 | |||
| f5d055be6f | |||
| e42a7b1ef2 | |||
| 5320545d56 | |||
| 9c90e706f1 | |||
| a4301140d8 | |||
| 64450185c5 | |||
| 000d378789 | |||
| 02a44e9e75 | |||
| 2f77913caa | |||
| 2353cd736d | |||
| 572f6e4301 | |||
| 5cb8cc2f31 | |||
| 2b69350162 | |||
| 0c64ed1a9a | |||
| 876b1e0147 | |||
| b915481595 | |||
| 9fe4819640 | |||
| 7976ce096e | |||
| 0cbd0c9b30 | |||
| 0abe66f7fb | |||
| ed46ee8109 | |||
| dec48a3130 | |||
| 46625c20ec | |||
| 125b5bbc2f | |||
| 0a50ca38ac | |||
| 40cce9f894 | |||
| 6d9c50d454 | |||
| 6b5282903e | |||
| d6f8d94ca5 | |||
| 86711c1e48 | |||
| 18d12fa088 | |||
| 7c901ace41 | |||
| 3e980af484 | |||
| ea6ddf90c0 | |||
| d4917e159a | |||
| d35e17ae95 | |||
| 4124fe2083 | |||
| 0348397173 | |||
| bfb8f588b8 | |||
| 9574ccbe0b | |||
| 090ab9060b | |||
| eab891aec7 | |||
| 7084707a4a | |||
| c3588de340 | |||
| a753fbf910 | |||
| 4713b7b121 | |||
| 1a7e5dd790 | |||
| 5f7ffe1bbe | |||
| 3f9798be9b | |||
| 0264e0c2e1 | |||
| 7de0008cc8 | |||
| 1bf34fe949 | |||
| ca36a102e5 | |||
| d6920703a1 | |||
| 68e6f528e5 | |||
| d2d8111668 | |||
| 90399b408a | |||
| 4bc80dfd08 | |||
| d2f6abf4bb | |||
| 50693aaf7a | |||
| cd19d68703 | |||
| 7389c55970 | |||
| 76132086b6 | |||
| 31ee25c7f2 | |||
| 565e3f81e5 | |||
| 380e105873 | |||
| daef8d6150 | |||
| 4d79c6d883 | |||
| 27aa70d81f | |||
| 2ffd3b5fb5 | |||
| 9f62191830 | |||
| b38d2593b3 | |||
| 75878be564 | |||
| 1432f2507c | |||
| 23705c9c21 | |||
| 72bf73ef10 | |||
| 825254a2bb | |||
| f99ab2a41e | |||
| aeb9332387 | |||
| ea1774f216 | |||
| f434c521e8 | |||
| 7b1d35cebc | |||
| 14f6b0b379 | |||
| 0e70a91ff9 | |||
| f096a8cd8f | |||
| 2108e7ce39 | |||
| cf2636a992 | |||
| bed0eca7f6 | |||
| 3ec692ea6f | |||
| 6cbf4791cd | |||
| 0a03f72467 | |||
| cf3f0afde4 | |||
| 8f043a4d80 | |||
| 6ae06b38e1 | |||
| a559375176 | |||
| 503b8bc4ff | |||
| 7562801989 | |||
| 128fa823a9 | |||
| 4e979a2d79 | |||
| b04a90818f | |||
| 216fdfa744 | |||
| bd0544820a | |||
| 6589279bb3 | |||
| c972182b3b | |||
| f2dcd55549 | |||
| 996a2096d0 | |||
| 9e8491710c | |||
| 3d1673157b | |||
| 0b31d75021 | |||
| ccd2112305 | |||
| e21fa5948e | |||
| d55c22a267 | |||
| d1ca476c4f | |||
| aeddcc9c64 | |||
| 3957e17f64 | |||
| b7995a14a1 | |||
| 17edc082b1 | |||
| 792ac5cb95 | |||
| a51d7c551e | |||
| e00d0ce4d8 | |||
| f7f2364da6 | |||
| f7c89d8000 | |||
| 75e4ad28b7 | |||
| 7fd8d50b8b | |||
| 12389dd7c1 | |||
| ac93d09212 | |||
| a0f723e2d2 | |||
| 1ef9a8a932 | |||
| 77e5eb047e | |||
| cd47266fc8 | |||
| 9be6835ddc | |||
| 9bc0c2544d | |||
| cbe197da9c | |||
| 765070c4b0 | |||
| 66416dafa8 | |||
| c81a4651d6 | |||
| 97a383c079 | |||
| 58c4383023 | |||
| d9ad056933 | |||
| 4e27255305 | |||
| 1c2d30cbe9 | |||
|
|
de84cbafbb | ||
|
|
fd5cead8d4 | ||
|
|
9b36dfd5ab | ||
|
|
9a7e4d9f00 | ||
|
|
6eef3ab91c | ||
|
|
46dee1de7b | ||
|
|
ba3ef23e67 | ||
|
|
4a3cefa2fd | ||
|
|
0129fd49cc | ||
|
|
4cdc65dac1 | ||
|
|
3219a8beda | ||
|
|
ae5c65db4a | ||
|
|
1e8eedc253 | ||
|
|
4d03ce9f74 | ||
|
|
4e42ccbb03 | ||
|
|
290a55a25f | ||
|
|
f783244477 | ||
|
|
2ee08d4650 | ||
|
|
216d2b9205 | ||
|
|
8a8843bbf1 | ||
|
|
69cb3eb80a | ||
|
|
8eafff0f0c | ||
|
|
dbb809261a | ||
|
|
e44bef2021 | ||
|
|
ac2d5c9501 | ||
|
|
215b097f4b | ||
|
|
b3f22d1b61 | ||
|
|
dc50e5d12b | ||
|
|
8ba6c1365a | ||
|
|
4bae8b00dd | ||
|
|
81b990a828 | ||
|
|
a8d31353c3 | ||
|
|
fd421cfc80 | ||
|
|
7fb953c5a3 | ||
|
|
49630f4e54 | ||
|
|
684187b6ce | ||
|
|
3ad6f1ec61 | ||
|
|
d4508e603f | ||
|
|
acf504aaec | ||
|
|
8518740f3e | ||
|
|
79254ea4a3 | ||
|
|
d9bc1cfc03 | ||
|
|
1f5884627c | ||
|
|
5c29f55942 | ||
|
|
aceb9ab2a1 | ||
|
|
2b47f851b6 | ||
|
|
fe428301d0 | ||
|
|
98a07cf737 | ||
|
|
d595476aba | ||
|
|
55c66f0462 | ||
|
|
9043e54076 | ||
|
|
941fbbed71 | ||
|
|
4884fddf0e | ||
|
|
b25a4f9fb3 | ||
|
|
419bc35a46 | ||
|
|
07dcc5bef1 | ||
|
|
ea62d11f64 | ||
|
|
cdf24b0143 | ||
|
|
befdd90149 | ||
|
|
c9a71df250 | ||
|
|
96ee568840 | ||
|
|
56abb68c0d | ||
|
|
b6723e529d | ||
|
|
074f943a6a | ||
|
|
f1d74d1ca6 | ||
|
|
dfd71e4d37 | ||
|
|
25eb705f59 | ||
|
|
f1224516e0 | ||
|
|
413047cce5 | ||
|
|
483ec26dfe | ||
|
|
03bc9c9ae7 | ||
|
|
019c0124e0 | ||
|
|
9e1ab7198a | ||
|
|
175a280b78 | ||
|
|
018d689189 | ||
|
|
dba9f668f6 | ||
|
|
4ac81fecac | ||
|
|
7e81a8e777 | ||
|
|
58e6753a81 | ||
|
|
d8594e72ad | ||
|
|
0df18fc637 | ||
|
|
a8f9e19f4a | ||
|
|
ed0bbfbf69 | ||
|
|
d9b1f8cf59 | ||
|
|
ff859372cf | ||
|
|
a5a2ea1181 | ||
|
|
b0c08c3c8c | ||
|
|
78004b0327 | ||
|
|
a0dd3278ad | ||
|
|
64586721b9 | ||
|
|
3bd2a33497 | ||
|
|
c92ad696de | ||
|
|
7fbb2c418b | ||
|
|
f23e43b5ea | ||
|
|
f3d56f4150 | ||
|
|
427c527810 | ||
|
|
06f514e6c6 | ||
|
|
fdd07842df | ||
|
|
3b229eab28 | ||
|
|
46d99bd96c | ||
|
|
818d7ce31a | ||
|
|
ac99176ee6 | ||
|
|
05920b52f6 | ||
|
|
e4e3e91ecb | ||
|
|
ad3c92eadc | ||
|
|
15eb4da669 | ||
|
|
590cd655d6 | ||
|
|
b629d57c34 | ||
|
|
4803c6ab73 | ||
|
|
4e5b8a9148 | ||
|
|
e06a8d95df | ||
|
|
a6cb7ac5e1 | ||
|
|
9d1aa3ea3d | ||
|
|
123bac1fdf | ||
|
|
4e775e2119 | ||
|
|
a0b6463273 | ||
|
|
95b7a9015c | ||
|
|
df06bc74fd | ||
|
|
50fed84a1b | ||
|
|
5337615c6a | ||
|
|
c047e625d8 | ||
|
|
9871cfcb70 | ||
|
|
e60535ed3c | ||
|
|
282b4cfad6 | ||
|
|
06a2797479 | ||
|
|
30d30739bd | ||
|
|
8076ed5a48 | ||
|
|
bf1de53952 | ||
|
|
6b3851de19 | ||
|
|
ff4ebcdfa5 | ||
|
|
971f0ba9d6 | ||
|
|
83e9cca756 | ||
|
|
6a7ba01102 | ||
|
|
aa916fa1ac | ||
|
|
33fefa7d33 | ||
|
|
4110b3875a | ||
|
|
98bf45cd27 | ||
|
|
bac4a94cad | ||
|
|
0a54db5963 | ||
|
|
cc3e31dbd0 | ||
|
|
27f7b0a304 | ||
|
|
23af037f36 | ||
|
|
6dd3ef6c61 | ||
|
|
7cefc51020 | ||
|
|
8ff74da191 | ||
|
|
78cbded701 | ||
|
|
010d233920 | ||
|
|
69831a17df | ||
|
|
9b45be87d6 | ||
|
|
74d8788463 | ||
|
|
f80ebd9f3d | ||
|
|
23e07fb6cf | ||
|
|
f8e09939ba | ||
|
|
1817d1becc | ||
|
|
77469d5ee8 | ||
|
|
ce63cbffb3 | ||
|
|
28e33dc26f | ||
|
|
094c4af082 | ||
|
|
dbfa392429 | ||
|
|
fc5b0288b7 | ||
|
|
17dec3351e | ||
|
|
ec61af7b71 | ||
|
|
c2b78fa040 | ||
|
|
1d8bdfa30c | ||
|
|
e084e75faa | ||
|
|
8b0317f88d | ||
|
|
92c53ed3a5 | ||
|
|
5d88d3a25e | ||
|
|
701decfc55 | ||
|
|
3b1b81a893 | ||
| 4332aa9b89 | |||
| 134d769374 | |||
| 88349e7797 | |||
| 8389d1a78e | |||
| 83d7520840 | |||
| 0603da2998 | |||
| 40950164e9 | |||
| 157baadcfd | |||
| 1eff9a57a5 | |||
| 7d907e7429 | |||
| 6bce26c4fc | |||
| 5f29496d79 | |||
| ec0a0e675e | |||
| e743c8affe | |||
| a5f0859c43 | |||
| 0cd2b6a64a | |||
| 8fe110eeb7 | |||
| a14210345c | |||
| 579109374d | |||
| 56b3ec8285 | |||
| 27a5279378 | |||
| 0351d17fee | |||
| 785f8d52a5 | |||
| a6ed30e0c7 | |||
| 8245fa6f41 | |||
| b4a8925281 | |||
| eb59aacc23 | |||
| b004c8a8bf | |||
| d5aa26edff | |||
| a1170a8689 | |||
| a8a5c2db9f | |||
| f77376e07c | |||
| 56366ec678 | |||
| 61b15f290d | |||
| 28381bf19e | |||
| 5b60fdbe1d | |||
| 5a785a445a | |||
| e82979561a | |||
| 0313492978 | |||
| 7c1485ae72 | |||
| ddf4e6812f | |||
| 107e657666 | |||
| b7661b5767 | |||
| bc1d0ef275 | |||
| 6b52b6ffee | |||
| 162265c661 | |||
| 0e26ebea31 | |||
| da2d45c12d | |||
| ca908fbe69 | |||
| 36949a1da8 | |||
| a383980412 | |||
| dd5ed5e890 | |||
| 3cb90b72ac | |||
| fe984edb7b | |||
| 92c35116f8 | |||
| 93db9c25e7 | |||
| a0e5d92bf9 | |||
| 09cdd3f516 | |||
| fee4c706da | |||
| 24502ff53b | |||
| ea6dd03ac0 | |||
| dea7062038 | |||
| 078d6cb5f8 | |||
| aae941b7cb | |||
| 217ebb84c9 | |||
| c5569cdcf2 | |||
| bbecd8efb5 | |||
| 4d510431d3 | |||
| 79271514b4 | |||
| 1d0a1c22f4 | |||
| 80dd1aa2d6 | |||
| d49a1e54d6 | |||
| bfaddfce0f | |||
| 058db96ade | |||
| 6c9192b6ea | |||
| 55be74bd94 | |||
| 261ac885c1 | |||
| d2b9cbf93b | |||
| 1e0a0e2acb | |||
| e5fbf372c4 | |||
| 1268c24faf | |||
| de40b5b90c | |||
| 33761f1cef | |||
| fc093743c1 | |||
| 9d15d2a127 | |||
| e3c17bf131 | |||
| 998205b0db | |||
| df138565af | |||
| fbef57890d | |||
| 4b29d12859 | |||
| 9e4a8410e1 | |||
| 882b6e2886 | |||
| e17722a99c | |||
| a64a5f6a6a | |||
| d78acc254d | |||
| 2ab526464d | |||
| 2290ae3cd7 | |||
| 6c40b93089 | |||
| ecb362ae48 | |||
| 37c3d9daf6 | |||
| 180241a315 | |||
| ffaa56fac9 | |||
| 2bb2e7b7db | |||
| 1567beee2e | |||
| b95bebdb6a | |||
| f510e59b1a | |||
| 28b3888498 | |||
| f5f6bcf3ab | |||
| a04c79a1f8 | |||
| b6e439dcf6 | |||
| 79940e685c | |||
| 2632233dbd | |||
| 45599787ba | |||
| ec89a227f2 | |||
| a809fca1ac | |||
| f3e6c59143 | |||
| 455fc4ed21 | |||
| 1371f5d1d8 | |||
| 0c648a9765 | |||
| 31dba7d84a | |||
| 208fe8be76 | |||
| 776c535862 | |||
| 756ae7f82c | |||
| 90a7b8b6fa | |||
| d2fe0a1bf6 | |||
| da9684812a | |||
| 2c814bd7c0 | |||
| 749f96c168 | |||
| efa4855555 | |||
| 3ab0c58e4a | |||
| 34822bef1e | |||
| 8be1e50681 | |||
| 8d62e50a20 | |||
| 4d15fb6ea2 | |||
| 336329a4ed | |||
| 4c9233e249 | |||
| 9932007bba | |||
| e9109a8a0c | |||
| 23c6d8b7a7 | |||
| 7a8562fe8e | |||
| ee9dca75ba | |||
| 032b55a6f1 | |||
| 5fe1624f26 | |||
| f00260ac4d | |||
| 10724430ce | |||
| 3ae20d0e3a | |||
| 1d2e25368c | |||
| 5c36116092 | |||
| 8cd14da8ec | |||
| 203907dfaf | |||
| fb0394b427 | |||
| cf12567ae5 | |||
| f27d5d3483 | |||
| 4cd6b125a4 | |||
| ff515e22bd | |||
| ebc15b0518 | |||
| d86cfe46df | |||
| c1e0151a11 | |||
| 0559a210ea | |||
| d768ab7fd5 | |||
| 993ef18ac6 | |||
| 2e4b69e758 | |||
| 9bd0cd173b | |||
| fdd6cb6401 | |||
| 082914cf8c | |||
| 2f9abff6f1 | |||
| 2eec78ab4f | |||
| f94a600ff8 | |||
| e33f8a7d54 | |||
| 8b3cb719a9 | |||
| 823617c5f3 | |||
| e67bca60e1 | |||
| 1f263c2295 | |||
| c496dd86ed | |||
| 218e527e24 | |||
| 0ddf7d9637 | |||
| 622c1d952e | |||
| 4e5f86730d | |||
| 6a87046845 | |||
| f9dc2ecb12 | |||
| 552d8d90fb | |||
| d657ea2315 | |||
| d64b96cef4 | |||
| bd61fdba81 | |||
| ee1072a491 | |||
| fef3b6f645 | |||
| b22a82bb50 | |||
| 5c06a0f0e9 | |||
| 2149d1a797 | |||
| e0fbb11475 | |||
| 3f85067426 | |||
| 68b0c89130 | |||
| 015cc69a31 | |||
| 5eef699a60 | |||
| e7e6727123 | |||
| 8ccb3a26ec | |||
| 1c83416be4 | |||
| 447af737a6 | |||
| 664be97ea6 | |||
| ffecc219b5 | |||
| b8154a4837 | |||
| 11187db71b | |||
| 3ca6fa9884 | |||
| fb94886c53 | |||
| 24068b6eb0 | |||
| a17b77a840 | |||
| 6a98fcc2b9 | |||
| 3fa53c1ba8 | |||
| e70537ed1b | |||
| d29b6cc1fb | |||
| fa0206a3f9 | |||
| bdb4d72f50 | |||
| 1abe80f74a | |||
| 708cefd5ab | |||
| 777cc0b3fe | |||
| 4a8deae567 | |||
| aded870495 | |||
| 2c8a1325b4 | |||
| ff2e998794 | |||
| dca6fcb565 | |||
| 3c1b1a449d | |||
| 83fa3287f4 | |||
| 5d13255155 | |||
| 4431a3b50b | |||
| 05aa1634e9 | |||
| 982e5317fb | |||
| 913da8a5a3 | |||
| 5ace866ee0 | |||
| 22dc0c7374 | |||
| fd1a44b6fa | |||
| c7d7ed749a | |||
| e5c83d01c0 | |||
| cd4954111f | |||
| 2ec567ebf3 | |||
| 3154c11845 | |||
| 115658bf6a | |||
| 24be870e4a | |||
| c409c6eb1b | |||
| 9bf5bc7fe2 | |||
| ff2d6b23bd | |||
| e260330877 | |||
| adb6555aa6 | |||
| c57ef5df30 | |||
| adeb37ce83 | |||
| c4ace53ea0 | |||
| 428d54641e | |||
| dd5299f59a | |||
| adb316a6ac | |||
| 8a55dafe8d | |||
| abcf4ebaf9 | |||
| f87f467680 | |||
| 02c2788ecc | |||
| 05a9c7100a | |||
| 87859397df | |||
| a648931960 | |||
| aaab49274c | |||
| 4b65795972 | |||
| 73342ea3ec | |||
| 57239e2d1f | |||
| 2a46e4574f | |||
| bcb95c6be1 | |||
| 2776aac2a6 | |||
| 7e193fb230 | |||
| 24da9eb418 | |||
| f6503e4d22 | |||
| fffb26cd56 | |||
| ae3fed8217 | |||
| 1a6b52fd35 | |||
| c06f7d8028 | |||
| 7e5353abf8 | |||
| c316e7f1fe | |||
| 921a29fa9f | |||
| a4001ae691 | |||
| d10c61913a | |||
| 98bafd4e93 | |||
| 79cfc6d3c9 | |||
| f5eba06476 | |||
| e8b42aa284 | |||
| 26a57b1b50 | |||
| 3385c72d1a | |||
| 5f65b8c1ed | |||
| 96d2a95798 | |||
| 590df8f2a2 | |||
| 29f0add92b | |||
| e4b56b75cb | |||
| b8370a552a | |||
| 85e053aad0 | |||
| a5e99ccfd3 | |||
| cf24262084 | |||
| 6f1207e15f | |||
| 9deccabb98 | |||
| aefdee92b8 | |||
| 5266d68d2f | |||
| 437aeb84e5 | |||
| ac21d5fd9e | |||
| cbe5a4e1f4 | |||
| f7f14d9f62 | |||
| 40d791103d | |||
| 6024f2db60 | |||
| 96a48ffa54 | |||
| d728a47224 | |||
| 022b178b3f | |||
| 21caf3cac2 |
3
.claude-env
Normal file
3
.claude-env
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Configuration PATH pour Claude Code
|
||||||
|
# Ce fichier sera lu automatiquement pour configurer l'environnement
|
||||||
|
export PATH="/Users/coissac/mamba/condabin:/opt/homebrew/lib/ruby/gems/3.4.0/bin:/opt/homebrew/opt/ruby/bin:/Users/coissac/go/bin:/Users/coissac/.cargo/bin:/Users/coissac/.modular/pkg/packages.modular.com_mojo/bin:/Applications/quarto/bin:/Users/coissac/.vscode-oss/extensions/vadimcn.vscode-lldb-1.12.0/bin:/Library/Frameworks/Python.framework/Versions/3.12/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/opt/X11/bin:/usr/local/dorado/dorado-0.9.5-osx-arm64/bin:/usr/local/go/bin:/usr/local/src/last-main/bin:/Users/coissac/travail/__MOI__/GO/obitools4/build:/opt/podman/bin:/Applications/quarto/bin:/Users/coissac/.cargo/bin:/Users/coissac/.vscode-oss/extensions/vadimcn.vscode-lldb-1.12.0/bin:/Users/coissac/.vscode-oss/extensions/ms-python.debugpy-2025.14.1-darwin-arm64/bundled/scripts/noConfigScripts:/Users/coissac/.orbstack/bin"
|
||||||
31
.claude/CLAUDE.md
Normal file
31
.claude/CLAUDE.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# PMOMusic Project Configuration
|
||||||
|
|
||||||
|
## Version Control
|
||||||
|
Ce projet utilise **Jujutsu (jj)** pour le contrôle de version, PAS git.
|
||||||
|
- Utiliser les commandes `jj` au lieu des commandes `git`
|
||||||
|
- Bookmark principal : `main`
|
||||||
|
- Ne jamais suggérer de commandes git
|
||||||
|
|
||||||
|
## Environnement
|
||||||
|
Le PATH et les variables d'environnement sont configurés dans `.claude-env` à la racine du projet.
|
||||||
|
|
||||||
|
## Configuration de l'application
|
||||||
|
- Fichier de configuration principal : `.pmomusic/config.yaml`
|
||||||
|
- Configuration UPNP personnalisable pour différencier les instances en développement
|
||||||
|
|
||||||
|
## Développement
|
||||||
|
Pendant le développement, plusieurs serveurs PMOMusic peuvent tourner en parallèle. Utiliser la configuration UPNP dans `.pmomusic/config.yaml` pour différencier les instances :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
host:
|
||||||
|
upnp:
|
||||||
|
manufacturer: "PMOMusic-Dev1"
|
||||||
|
udn_prefix: "pmomusic-dev1"
|
||||||
|
model_name_prefix: "PMOMusic-Dev1"
|
||||||
|
friendly_name_prefix: "PMOMusic-Dev1"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- Projet Rust multi-crates avec workspaces
|
||||||
|
- Crates principales : pmoupnp, pmomediaserver, pmomediarenderer, pmoconfig
|
||||||
|
- Pattern d'extension de configuration via traits (voir pmocache/src/config_ext.rs)
|
||||||
70
.dockerignore
Normal file
70
.dockerignore
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Build artifacts
|
||||||
|
target/
|
||||||
|
**/target/
|
||||||
|
|
||||||
|
# Webapp build artifacts and dependencies
|
||||||
|
pmoapp/webapp/node_modules/
|
||||||
|
pmoapp/webapp/dist/
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.jj/
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Cache directories
|
||||||
|
cache/
|
||||||
|
.pmomusic/
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
doc/
|
||||||
|
*.md
|
||||||
|
!Readme.md
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
test_upnp/
|
||||||
|
examples/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.pcap
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
db/
|
||||||
|
|
||||||
|
# Old code and backups
|
||||||
|
old_code/
|
||||||
|
*.txt
|
||||||
|
!Cargo.lock
|
||||||
|
|
||||||
|
# Development tools
|
||||||
|
tools/
|
||||||
|
gupnp-tools/
|
||||||
|
|
||||||
|
# Build scripts (we have them in the Dockerfile)
|
||||||
|
Makefile
|
||||||
|
setup-deps.sh
|
||||||
|
setup-env.sh
|
||||||
|
|
||||||
|
# SVG and other assets not needed for runtime
|
||||||
|
*.svg
|
||||||
|
|
||||||
|
# Audio test files
|
||||||
|
*.ogg
|
||||||
|
*.flac
|
||||||
|
*.wav
|
||||||
|
*.mp3
|
||||||
25
.gitea/workflows/build-push.yaml
Normal file
25
.gitea/workflows/build-push.yaml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main # Changez cela si votre branche principale a un autre nom
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Setup cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: dont-cache-${{ github.run_id }}
|
||||||
|
- name: Build and push image
|
||||||
|
uses: https://gargoton.petite-maison-orange.fr/pmo-actions/build-push-image@main
|
||||||
|
with:
|
||||||
|
image_name: public/pmomusic
|
||||||
|
image_tags: latest
|
||||||
|
no_cache: true
|
||||||
|
version_file: version.txt
|
||||||
|
check_uuid: 82a30d23-b3bd-4199-9237-776965831d20
|
||||||
29
.gitignore
vendored
29
.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
|
.ollama
|
||||||
|
.ollamacode
|
||||||
/bin/
|
/bin/
|
||||||
/pkg/
|
/pkg/
|
||||||
/vendor/
|
/vendor/
|
||||||
@@ -6,14 +8,24 @@
|
|||||||
**/*.o
|
**/*.o
|
||||||
**/*.o.d
|
**/*.o.d
|
||||||
**/*.a
|
**/*.a
|
||||||
|
**/*.flac
|
||||||
|
**/*.aif
|
||||||
|
**/*.aiff
|
||||||
|
**/*.wav
|
||||||
|
**/*.opus
|
||||||
|
**/*.mp4
|
||||||
|
**/*.mp3
|
||||||
|
**/*.ogg
|
||||||
xxx
|
xxx
|
||||||
/dcai/
|
/dcai/
|
||||||
**/.pmomusic.yml
|
**/.pmomusic.yml
|
||||||
**/.pmomusic_covers/**
|
**/.pmomusic_covers/**
|
||||||
**/.DS_Strore/**
|
**/.pmomusic_audio/**
|
||||||
**/.DS_Strore
|
/.pmomusic
|
||||||
/target/
|
.DS_Store
|
||||||
.pmomusic_covers
|
target
|
||||||
|
/.pmomusic_covers
|
||||||
|
/.pmomusic_audio/**
|
||||||
C/src/soxr-0.1.3/Release/tests
|
C/src/soxr-0.1.3/Release/tests
|
||||||
**/Release/
|
**/Release/
|
||||||
**/Debug/
|
**/Debug/
|
||||||
@@ -22,3 +34,12 @@ xxx
|
|||||||
xx
|
xx
|
||||||
all.txt
|
all.txt
|
||||||
pmo_src.txt
|
pmo_src.txt
|
||||||
|
upmpdcli/
|
||||||
|
/*.xml
|
||||||
|
test_upnp*.cargo/
|
||||||
|
.cargo/
|
||||||
|
setup-env.sh
|
||||||
|
cache
|
||||||
|
gupnp-tools
|
||||||
|
pmo*_[0_9]*.txt
|
||||||
|
webapp_[0_9]*.txt
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
devices:
|
|
||||||
mediarenderer:
|
|
||||||
fakerenderer:
|
|
||||||
udn: d7eaad15-7d21-4411-926a-bc1eea0713db
|
|
||||||
mediaserver:
|
|
||||||
qobuz:
|
|
||||||
udn: 28963b75-4c5f-4da7-b10e-ffafd
|
|
||||||
host:
|
|
||||||
http_port: '8080'
|
|
||||||
28
.pmomusic.yml.example
Normal file
28
.pmomusic.yml.example
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
host:
|
||||||
|
http_port: '8080'
|
||||||
|
cover_cache:
|
||||||
|
directory: ./.pmomusic_covers
|
||||||
|
size: 2000
|
||||||
|
audio_cache:
|
||||||
|
directory: ./.pmomusic_audio
|
||||||
|
size: 500
|
||||||
|
logger:
|
||||||
|
buffer_capacity: 200
|
||||||
|
enable_console: true
|
||||||
|
min_level: TRACE
|
||||||
|
mediarenderer:
|
||||||
|
mpd_renderer: null
|
||||||
|
mediaserver:
|
||||||
|
qobuz:
|
||||||
|
udn: uuid:28963b75-4c5f-4da7-b10e-ffafd
|
||||||
|
accounts:
|
||||||
|
qobuz:
|
||||||
|
username: your-email@example.com
|
||||||
|
password: 'YOUR_PASSWORD_HERE'
|
||||||
|
devices:
|
||||||
|
mediarenderer:
|
||||||
|
pmo_mediarenderer:
|
||||||
|
udn: 15a13316-daac-47f0-b64e-47e56f5e3b51
|
||||||
|
mediaserver:
|
||||||
|
pmo_mediaserver:
|
||||||
|
udn: 23df0bfa-cfef-4724-b731-00f66fadf176
|
||||||
42
.vscode/settings.json
vendored
42
.vscode/settings.json
vendored
@@ -1,7 +1,37 @@
|
|||||||
{
|
{
|
||||||
"makefile.configureOnOpen": false,
|
"makefile.configureOnOpen": false,
|
||||||
"git.enabled": false,
|
"git.enabled": false,
|
||||||
"claude-code.environmentVariables": [
|
"claude-code.environmentVariables": [],
|
||||||
|
|
||||||
]
|
// Exclusions de fichiers/dossiers inutiles pour Rust Analyzer
|
||||||
}
|
"files.exclude": {
|
||||||
|
"target": true,
|
||||||
|
"**/target": true,
|
||||||
|
"node_modules": true,
|
||||||
|
"tests/huge_benchmarks": true,
|
||||||
|
"examples": true,
|
||||||
|
"docs": true
|
||||||
|
},
|
||||||
|
// Rust Analyzer settings
|
||||||
|
"rust-analyzer.rustupPath": "/Users/coissac/.cargo/bin/rustup",
|
||||||
|
"rust-analyzer.cargoPath": "/Users/coissac/.cargo/bin/cargo",
|
||||||
|
"rust-analyzer.rustcSource": "discover",
|
||||||
|
"rust-analyzer.procMacro.enable": true,
|
||||||
|
"rust-analyzer.numThreads": 4,
|
||||||
|
"rust-analyzer.cargo.loadOutDirsFromCheck": false,
|
||||||
|
"rust-analyzer.checkOnSave.enable": true,
|
||||||
|
"rust-analyzer.checkOnSave.command": "check",
|
||||||
|
"rust-analyzer.checkOnSave.extraArgs": ["--all-features"],
|
||||||
|
"rust-analyzer.exclude": [
|
||||||
|
"target",
|
||||||
|
"tests/huge_benchmarks",
|
||||||
|
"examples",
|
||||||
|
"docs"
|
||||||
|
],
|
||||||
|
"rust-analyzer.server.extraEnv": {
|
||||||
|
"RA_LARGE_PROJECT": "1"
|
||||||
|
},
|
||||||
|
"rust-analyzer.runnables.command": null,
|
||||||
|
"rust-analyzer.server.path": null,
|
||||||
|
"rust-analyzer.cargo.allTargets": true,
|
||||||
|
}
|
||||||
|
|||||||
121
BROWSEMETADATA_FIX.md
Normal file
121
BROWSEMETADATA_FIX.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# BrowseMetadata Fix for Radio Paradise - PMO Music
|
||||||
|
|
||||||
|
**Date:** 2025-11-27
|
||||||
|
**Issue:** gupnp-av-cp failed to get metadata for live streams and history containers
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
UPnP clients (like gupnp-av-cp) were unable to get metadata for:
|
||||||
|
- Live stream items (e.g., `radio-paradise:channel:mellow:live`)
|
||||||
|
- History containers (e.g., `radio-paradise:channel:mellow:history`)
|
||||||
|
|
||||||
|
Error:
|
||||||
|
```
|
||||||
|
Failed to get metadata for 'radio-paradise:channel:mellow:live'
|
||||||
|
Failed to get metadata for 'radio-paradise:channel:mellow:history'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
The UPnP ContentDirectory service has two browse modes:
|
||||||
|
- **BrowseMetadata**: Get metadata for a specific object (item or container)
|
||||||
|
- **BrowseDirectChildren**: Get the children of a container
|
||||||
|
|
||||||
|
The `ContentHandler::browse_metadata()` was calling `source.browse()` for all objects, but:
|
||||||
|
1. The `MusicSource::browse()` trait method is designed to return children, not object metadata
|
||||||
|
2. For leaf items (LiveStream, HistoryTrack), `RadioParadiseSource::browse()` was rejecting them as "cannot be browsed"
|
||||||
|
3. The trait doesn't provide a way to distinguish between BrowseMetadata and BrowseDirectChildren requests
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
### 1. Modified ContentHandler ([pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs))
|
||||||
|
|
||||||
|
- `browse_metadata()` now tries `get_item()` first for leaf items before falling back to `browse()`
|
||||||
|
- This allows proper metadata retrieval for items (LiveStream, HistoryTrack)
|
||||||
|
|
||||||
|
### 2. Modified RadioParadiseSource ([pmoparadise/src/source.rs](pmoparadise/src/source.rs))
|
||||||
|
|
||||||
|
**For LiveStream items:**
|
||||||
|
- `browse()` now returns `BrowseResult::Items([live_item])` with the item's metadata
|
||||||
|
- This supports both BrowseMetadata (via ContentHandler) and direct browse calls
|
||||||
|
|
||||||
|
**For HistoryTrack items:**
|
||||||
|
- `browse()` now returns `BrowseResult::Items([track])` using `get_item()` internally
|
||||||
|
- Properly retrieves track metadata from the history playlist
|
||||||
|
|
||||||
|
**For History containers:**
|
||||||
|
- `browse()` now returns `BrowseResult::Mixed { containers: [history_container], items: [tracks] }`
|
||||||
|
- Provides both container metadata and its children in one result
|
||||||
|
|
||||||
|
### 3. Added Container Filtering ([pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs))
|
||||||
|
|
||||||
|
- `browse_result_to_didl()` now filters out containers that match the browsed `object_id`
|
||||||
|
- Prevents containers from appearing as children of themselves
|
||||||
|
- For History: BrowseDirectChildren returns only tracks, not the container
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. ✅ [pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs)
|
||||||
|
- Lines 120-135: Try get_item() first in browse_metadata()
|
||||||
|
- Lines 290-310: Added object_id parameter and container filtering in browse_result_to_didl()
|
||||||
|
- Lines 212, 286: Updated callers to pass object_id
|
||||||
|
|
||||||
|
2. ✅ [pmoparadise/src/source.rs](pmoparadise/src/source.rs)
|
||||||
|
- Lines 345-369: Modified History browse to return Mixed (container + items)
|
||||||
|
- Lines 371-377: Modified LiveStream browse to return item metadata
|
||||||
|
- Lines 379-383: Modified HistoryTrack browse to return track metadata
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
### Live Stream Metadata ✅
|
||||||
|
```bash
|
||||||
|
curl -X POST -H "SOAPAction: ..." BrowseMetadata radio-paradise:channel:mellow:live
|
||||||
|
```
|
||||||
|
Returns:
|
||||||
|
```xml
|
||||||
|
<item id="radio-paradise:channel:mellow:live" parentID="radio-paradise:channel:mellow">
|
||||||
|
<dc:title>Unknown Title</dc:title>
|
||||||
|
<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>
|
||||||
|
<res protocolInfo="http-get:*:audio/flac:*">http://.../radioparadise/stream/mellow/flac</res>
|
||||||
|
</item>
|
||||||
|
```
|
||||||
|
|
||||||
|
### History Container Metadata ✅
|
||||||
|
```bash
|
||||||
|
curl -X POST -H "SOAPAction: ..." BrowseMetadata radio-paradise:channel:mellow:history
|
||||||
|
```
|
||||||
|
Returns:
|
||||||
|
```xml
|
||||||
|
<container id="radio-paradise:channel:mellow:history" parentID="radio-paradise:channel:mellow">
|
||||||
|
<dc:title>Mellow Mix - History</dc:title>
|
||||||
|
<upnp:class>object.container.playlistContainer</upnp:class>
|
||||||
|
</container>
|
||||||
|
```
|
||||||
|
|
||||||
|
### History Children ✅
|
||||||
|
```bash
|
||||||
|
curl -X POST -H "SOAPAction: ..." BrowseDirectChildren radio-paradise:channel:mellow:history
|
||||||
|
```
|
||||||
|
Returns only track items (not the container itself)
|
||||||
|
|
||||||
|
## Design Notes
|
||||||
|
|
||||||
|
This solution works around a fundamental limitation in the `MusicSource` trait:
|
||||||
|
- The `browse()` method doesn't receive the `browse_flag` parameter
|
||||||
|
- It can't distinguish between BrowseMetadata and BrowseDirectChildren
|
||||||
|
- We use `get_item()` for items and `browse()` for containers
|
||||||
|
- Container filtering ensures correct BrowseDirectChildren behavior
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [x] BrowseMetadata works for LiveStream items
|
||||||
|
- [x] BrowseMetadata works for History containers
|
||||||
|
- [x] BrowseDirectChildren works for History (returns only tracks)
|
||||||
|
- [x] Container filtering prevents self-reference
|
||||||
|
- [ ] Test with BubbleUPnP (user to verify)
|
||||||
|
- [ ] Test with gupnp-av-cp (user to verify)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Original issue report: [UPNP_FIX_SUMMARY.md](UPNP_FIX_SUMMARY.md)
|
||||||
|
- UPnP AV Architecture: https://openconnectivity.org/developer/specifications/upnp-resources/upnp/
|
||||||
921
Blackboard/Architecture/music_source.md
Normal file
921
Blackboard/Architecture/music_source.md
Normal file
@@ -0,0 +1,921 @@
|
|||||||
|
# Guide d'implémentation d'une nouvelle MusicSource
|
||||||
|
|
||||||
|
Ce document décrit comment implémenter une nouvelle source musicale dans l'écosystème PMOMusic en suivant le trait `MusicSource` défini dans le crate `pmosource`.
|
||||||
|
|
||||||
|
## Table des matières
|
||||||
|
|
||||||
|
1. [Vue d'ensemble](#vue-densemble)
|
||||||
|
2. [Structure d'une MusicSource](#structure-dune-musicsource)
|
||||||
|
3. [Implémentation du trait MusicSource](#implémentation-du-trait-musicsource)
|
||||||
|
4. [Patterns d'implémentation](#patterns-dimplémentation)
|
||||||
|
5. [Intégration avec l'écosystème PMOMusic](#intégration-avec-lécosystème-pmomusic)
|
||||||
|
6. [Checklist de mise en œuvre](#checklist-de-mise-en-œuvre)
|
||||||
|
7. [Exemples de référence](#exemples-de-référence)
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
Une `MusicSource` est une abstraction qui représente une source de contenu musical dans PMOMusic. Elle peut être :
|
||||||
|
|
||||||
|
- **Dynamique (FIFO)** : Radio Paradise, streaming radio, playlists live
|
||||||
|
- **Statique** : Albums Qobuz, bibliothèque locale, playlists fixes
|
||||||
|
|
||||||
|
Le trait `MusicSource` définit une interface unifiée pour :
|
||||||
|
- La navigation UPnP ContentDirectory (browse)
|
||||||
|
- La résolution d'URI audio (avec cache)
|
||||||
|
- La gestion de playlists FIFO (pour les sources dynamiques)
|
||||||
|
- Le suivi des changements (update_id, last_change)
|
||||||
|
|
||||||
|
## Structure d'une MusicSource
|
||||||
|
|
||||||
|
### Organisation du code
|
||||||
|
|
||||||
|
```
|
||||||
|
pmo<votre-source>/
|
||||||
|
├── src/
|
||||||
|
│ ├── lib.rs # Exports publics
|
||||||
|
│ ├── source.rs # Implémentation MusicSource
|
||||||
|
│ ├── client.rs # Client API (optionnel)
|
||||||
|
│ ├── models.rs # Structures de données
|
||||||
|
│ ├── config.rs # Configuration
|
||||||
|
│ └── didl.rs # Conversion DIDL-Lite (optionnel)
|
||||||
|
├── assets/
|
||||||
|
│ └── default.webp # Logo 300x300px
|
||||||
|
├── Cargo.toml
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dépendances principales
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
pmosource = { path = "../pmosource" }
|
||||||
|
pmodidl = { path = "../pmodidl" }
|
||||||
|
pmoplaylist = { path = "../pmoplaylist", optional = true } # Si FIFO
|
||||||
|
pmoaudiocache = { path = "../pmoaudiocache", optional = true } # Si cache
|
||||||
|
pmocovers = { path = "../pmocovers", optional = true } # Si cache
|
||||||
|
|
||||||
|
async-trait = "0.1"
|
||||||
|
tokio = { version = "1", features = ["sync"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["cache"]
|
||||||
|
cache = ["pmoaudiocache", "pmocovers"]
|
||||||
|
playlist = ["pmoplaylist"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implémentation du trait MusicSource
|
||||||
|
|
||||||
|
### 1. Informations de base
|
||||||
|
|
||||||
|
Chaque source doit fournir :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pmosource::{async_trait, MusicSource};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MyMusicSource {
|
||||||
|
// Champs internes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl MusicSource for MyMusicSource {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"Ma Source Musicale" // Nom affiché dans l'UI
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id(&self) -> &str {
|
||||||
|
"my-music-source" // ID unique (format: lowercase-kebab-case)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_image(&self) -> &[u8] {
|
||||||
|
// Logo WebP 300x300px inclus dans le binaire
|
||||||
|
include_bytes!("../assets/default.webp")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_image_mime_type(&self) -> &str {
|
||||||
|
"image/webp" // Toujours WebP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Règles :**
|
||||||
|
- `id()` doit être unique parmi toutes les sources
|
||||||
|
- `id()` doit être en lowercase-kebab-case
|
||||||
|
- `default_image()` doit être un WebP 300x300px
|
||||||
|
|
||||||
|
### 2. Navigation ContentDirectory
|
||||||
|
|
||||||
|
#### 2.1 Container racine
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn root_container(&self) -> Result<Container> {
|
||||||
|
Ok(Container {
|
||||||
|
id: self.id().to_string(), // "my-music-source"
|
||||||
|
parent_id: "0".to_string(), // Toujours "0" pour la racine
|
||||||
|
restricted: Some("1".to_string()),
|
||||||
|
child_count: None, // Optionnel
|
||||||
|
searchable: Some("1".to_string()),
|
||||||
|
title: self.name().to_string(),
|
||||||
|
class: "object.container".to_string(),
|
||||||
|
artist: None,
|
||||||
|
album_art: None,
|
||||||
|
containers: vec![],
|
||||||
|
items: vec![],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 Browse
|
||||||
|
|
||||||
|
La méthode `browse()` est le cœur de la navigation :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
|
||||||
|
match self.parse_object_id(object_id) {
|
||||||
|
ObjectIdType::Root => {
|
||||||
|
// Retourner les sous-containers principaux
|
||||||
|
let containers = vec![
|
||||||
|
self.build_albums_container(),
|
||||||
|
self.build_playlists_container(),
|
||||||
|
self.build_favorites_container(),
|
||||||
|
];
|
||||||
|
Ok(BrowseResult::Containers(containers))
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectIdType::Album { album_id } => {
|
||||||
|
// Retourner le container + ses tracks
|
||||||
|
let album_container = self.build_album_container(&album_id);
|
||||||
|
let tracks = self.get_album_tracks(&album_id).await?;
|
||||||
|
Ok(BrowseResult::Mixed {
|
||||||
|
containers: vec![album_container],
|
||||||
|
items: tracks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectIdType::Track { track_id } => {
|
||||||
|
// Retourner les détails d'un track
|
||||||
|
let track = self.get_track_item(&track_id).await?;
|
||||||
|
Ok(BrowseResult::Items(vec![track]))
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => Err(MusicSourceError::ObjectNotFound(
|
||||||
|
format!("Unknown object: {}", object_id)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Schema d'Object ID recommandé :**
|
||||||
|
|
||||||
|
```
|
||||||
|
<source-id> # Racine
|
||||||
|
<source-id>:albums # Container albums
|
||||||
|
<source-id>:album:<album_id> # Album spécifique
|
||||||
|
<source-id>:track:<track_id> # Track spécifique
|
||||||
|
<source-id>:playlist:<playlist_id> # Playlist spécifique
|
||||||
|
```
|
||||||
|
|
||||||
|
**Types de BrowseResult :**
|
||||||
|
- `Containers(Vec<Container>)` : Liste de containers (navigation)
|
||||||
|
- `Items(Vec<Item>)` : Liste de tracks (lecture)
|
||||||
|
- `Mixed { containers, items }` : Les deux (album avec tracks)
|
||||||
|
|
||||||
|
#### 2.3 Résolution d'URI
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
|
||||||
|
// Étape 1 : Vérifier le cache audio
|
||||||
|
if let Some(cached_pk) = self.get_cached_audio_pk(object_id).await {
|
||||||
|
return Ok(format!("{}/audio/flac/{}", self.base_url, cached_pk));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Étape 2 : Retourner l'URI originale
|
||||||
|
match self.parse_object_id(object_id) {
|
||||||
|
ObjectIdType::Track { track_id } => {
|
||||||
|
let stream_url = self.get_stream_url(&track_id).await?;
|
||||||
|
Ok(stream_url)
|
||||||
|
}
|
||||||
|
_ => Err(MusicSourceError::UriResolutionError(
|
||||||
|
format!("Cannot resolve URI for: {}", object_id)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ordre de résolution :**
|
||||||
|
1. Cache audio local (si disponible)
|
||||||
|
2. URI originale (API streaming, fichier local, etc.)
|
||||||
|
|
||||||
|
### 3. Support FIFO (sources dynamiques)
|
||||||
|
|
||||||
|
Si votre source est dynamique (radio, streaming live) :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pmoplaylist::PlaylistManager;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RadioSource {
|
||||||
|
playlist_id: String,
|
||||||
|
update_counter: Arc<RwLock<u32>>,
|
||||||
|
last_change: Arc<RwLock<SystemTime>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl MusicSource for RadioSource {
|
||||||
|
fn supports_fifo(&self) -> bool {
|
||||||
|
true // Cette source utilise une FIFO
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn append_track(&self, track: Item) -> Result<()> {
|
||||||
|
// Récupérer le gestionnaire de playlist
|
||||||
|
let manager = PlaylistManager();
|
||||||
|
let writer = manager
|
||||||
|
.get_persistent_write_handle(self.playlist_id.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
|
||||||
|
// Extraire le PK depuis l'URI du track
|
||||||
|
let pk = self.extract_pk_from_item(&track)?;
|
||||||
|
|
||||||
|
// Ajouter à la playlist
|
||||||
|
writer
|
||||||
|
.push_lazy(pk)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
|
||||||
|
// Incrémenter update_id
|
||||||
|
self.bump_update_counter().await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_oldest(&self) -> Result<Option<Item>> {
|
||||||
|
let manager = PlaylistManager();
|
||||||
|
let reader = manager
|
||||||
|
.get_read_handle(&self.playlist_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
|
||||||
|
// Récupérer le plus ancien
|
||||||
|
let items = reader.to_items(1).await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
|
||||||
|
if let Some(item) = items.first() {
|
||||||
|
// Adapter l'item au schéma de la source
|
||||||
|
let adapted = self.adapt_item_to_schema(item.clone());
|
||||||
|
self.bump_update_counter().await;
|
||||||
|
Ok(Some(adapted))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_id(&self) -> u32 {
|
||||||
|
*self.update_counter.read().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn last_change(&self) -> Option<SystemTime> {
|
||||||
|
Some(*self.last_change.read().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
|
||||||
|
let manager = PlaylistManager();
|
||||||
|
let reader = manager
|
||||||
|
.get_read_handle(&self.playlist_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
|
||||||
|
// Récupérer les items
|
||||||
|
let items = reader
|
||||||
|
.to_items(count)
|
||||||
|
.await
|
||||||
|
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
|
||||||
|
|
||||||
|
// Adapter au schéma de la source
|
||||||
|
let adapted = items.into_iter()
|
||||||
|
.map(|item| self.adapt_item_to_schema(item))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(adapted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RadioSource {
|
||||||
|
async fn bump_update_counter(&self) {
|
||||||
|
let mut counter = self.update_counter.write().await;
|
||||||
|
*counter = counter.wrapping_add(1).max(1);
|
||||||
|
let mut last = self.last_change.write().await;
|
||||||
|
*last = SystemTime::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Points clés :**
|
||||||
|
- Utiliser `pmoplaylist::PlaylistManager` singleton
|
||||||
|
- Incrémenter `update_id` à chaque modification
|
||||||
|
- Mettre à jour `last_change` à chaque modification
|
||||||
|
- Adapter les IDs des items au schéma de la source
|
||||||
|
|
||||||
|
### 4. Support statique (albums, bibliothèques)
|
||||||
|
|
||||||
|
Si votre source est statique (catalogue, albums) :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
impl MusicSource for CatalogSource {
|
||||||
|
fn supports_fifo(&self) -> bool {
|
||||||
|
false // Pas de FIFO
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn append_track(&self, _track: Item) -> Result<()> {
|
||||||
|
Err(MusicSourceError::NotSupported(
|
||||||
|
"This source is read-only".to_string()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_oldest(&self) -> Result<Option<Item>> {
|
||||||
|
Ok(None) // Pas de suppression
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_id(&self) -> u32 {
|
||||||
|
0 // Jamais de changement
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn last_change(&self) -> Option<SystemTime> {
|
||||||
|
None // Pas de suivi des changements
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
|
||||||
|
// Retourner une liste paginée depuis le catalogue
|
||||||
|
self.get_catalog_items(offset, count).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Patterns d'implémentation
|
||||||
|
|
||||||
|
### Pattern 1 : Source dynamique avec FIFO (Radio Paradise)
|
||||||
|
|
||||||
|
**Caractéristiques :**
|
||||||
|
- Flux continu de tracks
|
||||||
|
- Capacité limitée (50-100 tracks)
|
||||||
|
- Suppression automatique des plus anciens
|
||||||
|
- `supports_fifo() = true`
|
||||||
|
|
||||||
|
**Structure :**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RadioParadiseSource {
|
||||||
|
base_url: String,
|
||||||
|
update_counter: Arc<RwLock<u32>>,
|
||||||
|
last_change: Arc<RwLock<SystemTime>>,
|
||||||
|
callback_tokens: Arc<std::sync::Mutex<Vec<u64>>>,
|
||||||
|
container_notifier: Option<Arc<dyn Fn(&[String]) + Send + Sync>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RadioParadiseSource {
|
||||||
|
// Enregistrer des callbacks sur les playlists pour notifier les changements
|
||||||
|
pub fn attach_playlist_callbacks(self: &Arc<Self>) {
|
||||||
|
let playlist_ids = vec![
|
||||||
|
self.live_playlist_id(),
|
||||||
|
self.history_playlist_id(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let manager = PlaylistManager();
|
||||||
|
let mut tokens = self.callback_tokens.lock().unwrap();
|
||||||
|
|
||||||
|
for pid in playlist_ids {
|
||||||
|
let weak = Arc::downgrade(self);
|
||||||
|
let pid_clone = pid.clone();
|
||||||
|
let token = manager.register_callback(move |event| {
|
||||||
|
if event.playlist_id == pid_clone {
|
||||||
|
if let Some(strong) = weak.upgrade() {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
strong.bump_update_counter().await;
|
||||||
|
// Notifier ContentDirectory
|
||||||
|
if let Some(notifier) = strong.container_notifier.as_ref() {
|
||||||
|
notifier(&[format!("radio-paradise:history")]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tokens.push(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Points clés :**
|
||||||
|
- Callbacks sur `pmoplaylist` pour détecter les changements
|
||||||
|
- Notification du ContentDirectory via un notifier injecté
|
||||||
|
- `update_counter` partagé via `Arc<RwLock<u32>>`
|
||||||
|
|
||||||
|
### Pattern 2 : Source catalogue avec playlists lazy (Qobuz)
|
||||||
|
|
||||||
|
**Caractéristiques :**
|
||||||
|
- Catalogue vaste (millions de tracks)
|
||||||
|
- Playlists créées à la demande
|
||||||
|
- Cache lazy (cover eager, audio lazy)
|
||||||
|
- `supports_fifo() = false`
|
||||||
|
|
||||||
|
**Structure :**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct QobuzSource {
|
||||||
|
inner: Arc<QobuzSourceInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct QobuzSourceInner {
|
||||||
|
client: Arc<QobuzClient>,
|
||||||
|
cache_manager: SourceCacheManager,
|
||||||
|
base_url: String,
|
||||||
|
update_counter: tokio::sync::RwLock<u32>,
|
||||||
|
last_change: tokio::sync::RwLock<SystemTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QobuzSource {
|
||||||
|
// Ajouter un track avec cache lazy
|
||||||
|
pub async fn add_track_lazy(&self, track: &Track) -> Result<(String, String)> {
|
||||||
|
let track_id = format!("qobuz://track/{}", track.id);
|
||||||
|
let lazy_pk = format!("QOBUZ:{}", track.id);
|
||||||
|
|
||||||
|
// 1. Cache cover EAGERLY (petit, UI en a besoin)
|
||||||
|
let cached_cover_pk = if let Some(ref image_url) = track.album.as_ref()
|
||||||
|
.and_then(|a| a.image.as_ref()) {
|
||||||
|
self.inner.cache_manager.cache_cover(image_url).await.ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Préparer metadata
|
||||||
|
let metadata = AudioMetadata {
|
||||||
|
title: Some(track.title.clone()),
|
||||||
|
artist: track.performer.as_ref().map(|p| p.name.clone()),
|
||||||
|
album: track.album.as_ref().map(|a| a.title.clone()),
|
||||||
|
duration_secs: Some(track.duration as u64),
|
||||||
|
// ... autres champs
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Cache audio LAZILY (grand, téléchargé à la demande)
|
||||||
|
let cached_audio_pk = self
|
||||||
|
.inner
|
||||||
|
.cache_manager
|
||||||
|
.cache_audio_lazy_with_provider(
|
||||||
|
&lazy_pk,
|
||||||
|
Some(metadata.clone()),
|
||||||
|
cached_cover_pk.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 4. Stocker metadata
|
||||||
|
self.inner.cache_manager.update_metadata(
|
||||||
|
track_id.clone(),
|
||||||
|
pmosource::TrackMetadata {
|
||||||
|
original_uri: stream_url,
|
||||||
|
cached_audio_pk: Some(cached_audio_pk.clone()),
|
||||||
|
cached_cover_pk,
|
||||||
|
},
|
||||||
|
).await;
|
||||||
|
|
||||||
|
Ok((track_id, cached_audio_pk))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer une playlist d'album avec TTL
|
||||||
|
async fn get_or_create_album_playlist_items(
|
||||||
|
&self,
|
||||||
|
album_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<Item>> {
|
||||||
|
const ALBUM_PLAYLIST_TTL: Duration = Duration::from_secs(7 * 24 * 3600);
|
||||||
|
|
||||||
|
let playlist_id = format!("qobuz-album-{}", album_id);
|
||||||
|
let playlist_manager = PlaylistManager();
|
||||||
|
|
||||||
|
// Vérifier validité (existe ET non expirée ET non vide)
|
||||||
|
let is_valid = self.is_album_playlist_valid(&playlist_id).await?;
|
||||||
|
|
||||||
|
if is_valid {
|
||||||
|
// Récupérer depuis playlist existante
|
||||||
|
let reader = playlist_manager.get_read_handle(&playlist_id).await?;
|
||||||
|
let items = reader.to_items(limit).await?;
|
||||||
|
return self.adapt_playlist_items_to_qobuz(items, album_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer nouvelle playlist
|
||||||
|
let writer = playlist_manager
|
||||||
|
.create_persistent_playlist_with_role(
|
||||||
|
playlist_id.clone(),
|
||||||
|
pmoplaylist::PlaylistRole::Album,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Ajouter tracks avec cache lazy
|
||||||
|
self.add_album_to_playlist(&playlist_id, album_id).await?;
|
||||||
|
|
||||||
|
// Récupérer items
|
||||||
|
let reader = playlist_manager.get_read_handle(&playlist_id).await?;
|
||||||
|
let items = reader.to_items(limit).await?;
|
||||||
|
self.adapt_playlist_items_to_qobuz(items, album_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Points clés :**
|
||||||
|
- Cache lazy pour l'audio (téléchargé à la demande)
|
||||||
|
- Cache eager pour les covers (petit, UI en a besoin)
|
||||||
|
- Playlists avec TTL (7 jours)
|
||||||
|
- `LazyProvider` pour télécharger l'audio lors de la lecture
|
||||||
|
|
||||||
|
### Pattern 3 : Adaptation des IDs entre playlist et source
|
||||||
|
|
||||||
|
Lorsqu'une source utilise `pmoplaylist`, les items retournés ont des IDs génériques. Il faut les adapter au schéma de la source :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn adapt_playlist_items_to_source(
|
||||||
|
&self,
|
||||||
|
items: Vec<Item>,
|
||||||
|
parent_id: &str,
|
||||||
|
) -> Result<Vec<Item>> {
|
||||||
|
let mut adapted = Vec::with_capacity(items.len());
|
||||||
|
|
||||||
|
for mut item in items {
|
||||||
|
// Extraire cache_pk depuis l'URL du resource
|
||||||
|
let cache_pk = if let Some(resource) = item.resources.first() {
|
||||||
|
resource
|
||||||
|
.url
|
||||||
|
.strip_prefix("/audio/flac/")
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(pk) = cache_pk {
|
||||||
|
// Récupérer source_track_id depuis metadata
|
||||||
|
if let Ok(Some(track_id_value)) = self
|
||||||
|
.cache_manager
|
||||||
|
.get_audio_metadata(&pk, "source_track_id")
|
||||||
|
{
|
||||||
|
if let Some(track_id) = track_id_value.as_str() {
|
||||||
|
item.id = format!("my-source:track:{}", track_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir URL relative en absolue
|
||||||
|
if let Some(resource) = item.resources.first_mut() {
|
||||||
|
if resource.url.starts_with('/') {
|
||||||
|
resource.url = format!("{}{}", self.base_url, resource.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item.parent_id = parent_id.to_string();
|
||||||
|
|
||||||
|
// Normaliser album art
|
||||||
|
if let Some(art) = item.album_art.as_mut() {
|
||||||
|
if art.starts_with('/') {
|
||||||
|
*art = format!("{}{}", self.base_url, art);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item.album_art = Some(self.default_cover_url());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter genre par défaut si absent (requis par certains clients)
|
||||||
|
if item.genre.is_none() {
|
||||||
|
item.genre = Some("Music".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
adapted.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(adapted)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Points clés :**
|
||||||
|
- Stocker `source_track_id` dans les metadata du cache audio
|
||||||
|
- Reconstituer l'ID correct lors de la récupération depuis playlist
|
||||||
|
- Normaliser URLs (relatives → absolues)
|
||||||
|
- Ajouter champs requis par certains clients UPnP
|
||||||
|
|
||||||
|
## Intégration avec l'écosystème PMOMusic
|
||||||
|
|
||||||
|
### Avec pmoplaylist
|
||||||
|
|
||||||
|
Pour les sources dynamiques et les catalogues :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pmoplaylist::{PlaylistManager, PlaylistRole};
|
||||||
|
|
||||||
|
// Créer une playlist persistante
|
||||||
|
let manager = PlaylistManager();
|
||||||
|
let writer = manager
|
||||||
|
.create_persistent_playlist_with_role(
|
||||||
|
"my-source-album-123".to_string(),
|
||||||
|
PlaylistRole::Album,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Configurer metadata
|
||||||
|
writer.set_title("Album Title".to_string()).await?;
|
||||||
|
writer.set_artist(Some("Artist Name".to_string())).await?;
|
||||||
|
writer.set_cover_pk(Some("cover-pk".to_string())).await?;
|
||||||
|
|
||||||
|
// Ajouter tracks avec cache lazy
|
||||||
|
writer.push_lazy_batch(vec!["pk1", "pk2", "pk3"]).await?;
|
||||||
|
|
||||||
|
// Activer mode lazy (lookahead 2 tracks)
|
||||||
|
manager.enable_lazy_mode("my-source-album-123", 2);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avec pmoaudiocache et pmocovers (via SourceCacheManager)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pmosource::SourceCacheManager;
|
||||||
|
|
||||||
|
// Créer le manager centralisé
|
||||||
|
let cache_manager = SourceCacheManager::from_registry("my-source".to_string())?;
|
||||||
|
|
||||||
|
// Enregistrer un LazyProvider
|
||||||
|
cache_manager.register_lazy_provider(Arc::new(MyLazyProvider::new(client)));
|
||||||
|
|
||||||
|
// Cache eager (cover)
|
||||||
|
let cover_pk = cache_manager.cache_cover("https://example.com/cover.jpg").await?;
|
||||||
|
|
||||||
|
// Cache lazy (audio)
|
||||||
|
let audio_pk = cache_manager
|
||||||
|
.cache_audio_lazy_with_provider(
|
||||||
|
"MY-SOURCE:123", // Lazy PK
|
||||||
|
Some(metadata),
|
||||||
|
Some(cover_pk),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Récupérer metadata
|
||||||
|
let value = cache_manager.get_audio_metadata(&audio_pk, "key").await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
**LazyProvider personnalisé :**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pmoaudiocache::{LazyProvider, LazyProviderError};
|
||||||
|
|
||||||
|
pub struct MyLazyProvider {
|
||||||
|
client: Arc<MyClient>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LazyProvider for MyLazyProvider {
|
||||||
|
async fn fetch_audio(&self, lazy_pk: &str) -> Result<Vec<u8>, LazyProviderError> {
|
||||||
|
// Extraire l'ID depuis le lazy_pk
|
||||||
|
let id = lazy_pk
|
||||||
|
.strip_prefix("MY-SOURCE:")
|
||||||
|
.ok_or_else(|| LazyProviderError::InvalidKey)?;
|
||||||
|
|
||||||
|
// Récupérer l'URL de streaming
|
||||||
|
let stream_url = self.client.get_stream_url(id).await
|
||||||
|
.map_err(|e| LazyProviderError::FetchFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
// Télécharger l'audio
|
||||||
|
let response = reqwest::get(&stream_url).await
|
||||||
|
.map_err(|e| LazyProviderError::FetchFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
let bytes = response.bytes().await
|
||||||
|
.map_err(|e| LazyProviderError::FetchFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(bytes.to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avec pmodidl
|
||||||
|
|
||||||
|
Conversion de vos structures en DIDL-Lite :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pmodidl::{Container, Item, Resource};
|
||||||
|
|
||||||
|
// Container
|
||||||
|
pub trait ToDIDLContainer {
|
||||||
|
fn to_didl_container(&self, parent_id: &str) -> Result<Container>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToDIDLContainer for MyAlbum {
|
||||||
|
fn to_didl_container(&self, parent_id: &str) -> Result<Container> {
|
||||||
|
Ok(Container {
|
||||||
|
id: format!("my-source:album:{}", self.id),
|
||||||
|
parent_id: parent_id.to_string(),
|
||||||
|
restricted: Some("1".to_string()),
|
||||||
|
child_count: self.tracks_count.map(|c| c.to_string()),
|
||||||
|
searchable: Some("1".to_string()),
|
||||||
|
title: self.title.clone(),
|
||||||
|
class: "object.container.album.musicAlbum".to_string(),
|
||||||
|
artist: Some(self.artist.name.clone()),
|
||||||
|
album_art: self.cover_url.clone(),
|
||||||
|
containers: vec![],
|
||||||
|
items: vec![],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item
|
||||||
|
pub trait ToDIDLItem {
|
||||||
|
fn to_didl_item(&self, parent_id: &str) -> Result<Item>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToDIDLItem for MyTrack {
|
||||||
|
fn to_didl_item(&self, parent_id: &str) -> Result<Item> {
|
||||||
|
Ok(Item {
|
||||||
|
id: format!("my-source:track:{}", self.id),
|
||||||
|
parent_id: parent_id.to_string(),
|
||||||
|
restricted: Some("1".to_string()),
|
||||||
|
title: self.title.clone(),
|
||||||
|
creator: self.artist.as_ref().map(|a| a.name.clone()),
|
||||||
|
class: "object.item.audioItem.musicTrack".to_string(),
|
||||||
|
artist: self.artist.as_ref().map(|a| a.name.clone()),
|
||||||
|
album: self.album.as_ref().map(|a| a.title.clone()),
|
||||||
|
genre: Some("Music".to_string()),
|
||||||
|
album_art: self.cover_url.clone(),
|
||||||
|
album_art_pk: self.cover_pk.clone(),
|
||||||
|
date: self.release_date.clone(),
|
||||||
|
original_track_number: Some(self.track_number),
|
||||||
|
resources: vec![Resource {
|
||||||
|
protocol_info: "http-get:*:audio/flac:*".to_string(),
|
||||||
|
bits_per_sample: self.bit_depth.map(|b| b.to_string()),
|
||||||
|
sample_frequency: self.sample_rate.map(|s| s.to_string()),
|
||||||
|
nr_audio_channels: Some("2".to_string()),
|
||||||
|
duration: self.duration_as_upnp_format(),
|
||||||
|
url: format!("/audio/flac/{}", self.cache_pk),
|
||||||
|
}],
|
||||||
|
descriptions: vec![],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checklist de mise en œuvre
|
||||||
|
|
||||||
|
### Phase 1 : Structure de base
|
||||||
|
|
||||||
|
- [ ] Créer le crate `pmo<votre-source>`
|
||||||
|
- [ ] Ajouter les dépendances dans `Cargo.toml`
|
||||||
|
- [ ] Créer le logo WebP 300x300px dans `assets/`
|
||||||
|
- [ ] Définir la structure principale
|
||||||
|
- [ ] Implémenter `name()`, `id()`, `default_image()`
|
||||||
|
|
||||||
|
### Phase 2 : Navigation ContentDirectory
|
||||||
|
|
||||||
|
- [ ] Définir le schéma d'Object ID
|
||||||
|
- [ ] Implémenter `root_container()`
|
||||||
|
- [ ] Implémenter `browse()` pour la racine
|
||||||
|
- [ ] Implémenter `browse()` pour les sous-containers
|
||||||
|
- [ ] Implémenter `browse()` pour les items
|
||||||
|
- [ ] Tester la navigation avec un client UPnP
|
||||||
|
|
||||||
|
### Phase 3 : Résolution d'URI
|
||||||
|
|
||||||
|
- [ ] Implémenter `resolve_uri()` avec fallback
|
||||||
|
- [ ] Intégrer avec `SourceCacheManager`
|
||||||
|
- [ ] Implémenter `LazyProvider` si cache lazy
|
||||||
|
- [ ] Tester la lecture audio
|
||||||
|
|
||||||
|
### Phase 4 : Support FIFO (si dynamique)
|
||||||
|
|
||||||
|
- [ ] Décider de la stratégie FIFO
|
||||||
|
- [ ] Implémenter `supports_fifo() = true`
|
||||||
|
- [ ] Implémenter `append_track()`
|
||||||
|
- [ ] Implémenter `remove_oldest()`
|
||||||
|
- [ ] Implémenter `update_id()` et `last_change()`
|
||||||
|
- [ ] Enregistrer callbacks sur playlists
|
||||||
|
- [ ] Tester ajout/suppression de tracks
|
||||||
|
|
||||||
|
### Phase 5 : Support statique (si catalogue)
|
||||||
|
|
||||||
|
- [ ] Implémenter `supports_fifo() = false`
|
||||||
|
- [ ] Implémenter `get_items()` avec pagination
|
||||||
|
- [ ] Implémenter `search()` si applicable
|
||||||
|
- [ ] Tester browsing du catalogue
|
||||||
|
|
||||||
|
### Phase 6 : Intégration avancée
|
||||||
|
|
||||||
|
- [ ] Implémenter `get_item()` pour metadata
|
||||||
|
- [ ] Implémenter `capabilities()`
|
||||||
|
- [ ] Implémenter `get_available_formats()`
|
||||||
|
- [ ] Ajouter gestion d'erreurs robuste
|
||||||
|
- [ ] Documenter le code
|
||||||
|
|
||||||
|
### Phase 7 : Tests et validation
|
||||||
|
|
||||||
|
- [ ] Écrire tests unitaires
|
||||||
|
- [ ] Écrire tests d'intégration
|
||||||
|
- [ ] Tester avec différents clients UPnP
|
||||||
|
- [ ] Valider les performances
|
||||||
|
- [ ] Documenter les limitations
|
||||||
|
|
||||||
|
## Exemples de référence
|
||||||
|
|
||||||
|
### Radio Paradise (source dynamique FIFO)
|
||||||
|
|
||||||
|
**Fichier :** `pmoparadise/src/source.rs`
|
||||||
|
|
||||||
|
**Points d'intérêt :**
|
||||||
|
- Structure avec `Arc<RwLock<>>` pour l'état partagé
|
||||||
|
- Callbacks sur playlists pour détecter les changements
|
||||||
|
- Notifier injecté pour ContentDirectory
|
||||||
|
- Adaptation des IDs playlist → Radio Paradise
|
||||||
|
- Support de 4 canaux avec sous-containers
|
||||||
|
|
||||||
|
**Schema d'Object ID :**
|
||||||
|
```
|
||||||
|
radio-paradise # Racine
|
||||||
|
radio-paradise:channel:{slug} # Canal (main, mellow, rock, eclectic)
|
||||||
|
radio-paradise:channel:{slug}:live # Stream live
|
||||||
|
radio-paradise:channel:{slug}:liveplaylist # Playlist live (queue)
|
||||||
|
radio-paradise:channel:{slug}:liveplaylist:track:{pk} # Track dans queue
|
||||||
|
radio-paradise:channel:{slug}:history # Historique
|
||||||
|
radio-paradise:channel:{slug}:history:track:{pk} # Track dans historique
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qobuz (source catalogue avec playlists lazy)
|
||||||
|
|
||||||
|
**Fichier :** `pmoqobuz/src/source.rs`
|
||||||
|
|
||||||
|
**Points d'intérêt :**
|
||||||
|
- `SourceCacheManager` centralisé
|
||||||
|
- Cache lazy pour audio, eager pour covers
|
||||||
|
- `LazyProvider` personnalisé
|
||||||
|
- Playlists d'albums avec TTL (7 jours)
|
||||||
|
- Adaptation IDs playlist → Qobuz
|
||||||
|
- Navigation hiérarchique complexe (Discover, Genres, Favorites)
|
||||||
|
|
||||||
|
**Schema d'Object ID :**
|
||||||
|
```
|
||||||
|
qobuz # Racine
|
||||||
|
qobuz:discover # Discover Catalog
|
||||||
|
qobuz:discover:albums:ideal # Albums (Ideal Discography)
|
||||||
|
qobuz:discover:artists # Artistes Featured
|
||||||
|
qobuz:genres # Discover Genres
|
||||||
|
qobuz:genre:{id} # Genre spécifique
|
||||||
|
qobuz:genre:{id}:new-releases # Nouveautés du genre
|
||||||
|
qobuz:favorites # My Music
|
||||||
|
qobuz:favorites:albums # Albums favoris
|
||||||
|
qobuz:album:{id} # Album spécifique
|
||||||
|
qobuz:track:{id} # Track spécifique
|
||||||
|
qobuz:playlist:{id} # Playlist spécifique
|
||||||
|
qobuz:artist:{id} # Artiste spécifique
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conseils d'implémentation
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
1. **Cache agressif** : Utilisez `SourceCacheManager` pour tout
|
||||||
|
2. **Pagination** : Limitez le nombre d'items retournés (max 100)
|
||||||
|
3. **Lazy loading** : Ne chargez que ce qui est demandé
|
||||||
|
4. **Rate limiting** : Respectez les limites API de la source
|
||||||
|
5. **Arc<>** : Partagez les données coûteuses
|
||||||
|
|
||||||
|
### Compatibilité UPnP
|
||||||
|
|
||||||
|
1. **Genre obligatoire** : Certains clients (gupnp-av-cp) requièrent `<upnp:genre>`
|
||||||
|
2. **URLs absolues** : Toujours retourner des URLs complètes (pas de chemins relatifs)
|
||||||
|
3. **Protocol Info** : Utilisez `http-get:*:audio/flac:*` pour FLAC
|
||||||
|
4. **Duration** : Format `H:MM:SS` (ex: `0:03:45`)
|
||||||
|
5. **childCount** : Optionnel mais recommandé pour l'UI
|
||||||
|
|
||||||
|
### Gestion d'erreurs
|
||||||
|
|
||||||
|
1. **ObjectNotFound** : ID invalide
|
||||||
|
2. **BrowseError** : Erreur générique de navigation
|
||||||
|
3. **UriResolutionError** : Impossible de résoudre l'URI
|
||||||
|
4. **PlaylistError** : Erreur d'interaction avec pmoplaylist
|
||||||
|
5. **CacheError** : Erreur de cache
|
||||||
|
|
||||||
|
### Thread Safety
|
||||||
|
|
||||||
|
1. **Arc<RwLock<>>** : Pour l'état mutable partagé
|
||||||
|
2. **tokio::sync::RwLock** : Pour l'async
|
||||||
|
3. **Éviter Rc<>** : Pas thread-safe
|
||||||
|
4. **Clone** : Implémentez `Clone` pour `Arc<>`
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
L'implémentation d'une nouvelle `MusicSource` suit ces étapes :
|
||||||
|
|
||||||
|
1. **Définir le schéma d'Object ID** : Hiérarchie claire et cohérente
|
||||||
|
2. **Implémenter la navigation** : `browse()` pour tous les niveaux
|
||||||
|
3. **Résoudre les URIs** : Cache local d'abord, puis original
|
||||||
|
4. **Gérer le cache** : `SourceCacheManager` + `LazyProvider`
|
||||||
|
5. **Adapter les IDs** : Playlist → Schema de la source
|
||||||
|
6. **Notifier les changements** : `update_id` + callbacks
|
||||||
|
|
||||||
|
Les exemples Radio Paradise et Qobuz couvrent les deux patterns principaux :
|
||||||
|
- **Dynamique FIFO** : Radio Paradise
|
||||||
|
- **Catalogue lazy** : Qobuz
|
||||||
|
|
||||||
|
En suivant ces patterns, vous obtiendrez une source musicale performante, compatible UPnP, et bien intégrée dans l'écosystème PMOMusic.
|
||||||
1074
Blackboard/Architecture/pmoconfig_ext.md
Normal file
1074
Blackboard/Architecture/pmoconfig_ext.md
Normal file
File diff suppressed because it is too large
Load Diff
870
Blackboard/Architecture/pmoserver_ext.md
Normal file
870
Blackboard/Architecture/pmoserver_ext.md
Normal file
@@ -0,0 +1,870 @@
|
|||||||
|
# Pattern d'extension PMOServer (`pmoserver_ext`)
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
Le pattern `pmoserver_ext` permet d'étendre les fonctionnalités du serveur HTTP `pmoserver` de manière modulaire et découplée. Chaque crate spécialisée peut ajouter ses propres routes HTTP sans que `pmoserver` ne dépende de ces crates.
|
||||||
|
|
||||||
|
**Principe** : Définir un trait d'extension que `pmoserver::Server` implémente via une feature Cargo.
|
||||||
|
|
||||||
|
## Anatomie d'une extension
|
||||||
|
|
||||||
|
### 1. Structure du module
|
||||||
|
|
||||||
|
Créer un module `pmoserver_ext.rs` dans la crate :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// pmoXXX/src/pmoserver_ext.rs
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use crate::{/* types internes de la crate */};
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use async_trait::async_trait;
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use axum::{Router, routing::get, Json, extract::{State, Path}};
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use std::sync::Arc;
|
||||||
|
```
|
||||||
|
|
||||||
|
Déclarer le module dans `lib.rs` :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// pmoXXX/src/lib.rs
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
pub mod pmoserver_ext;
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
pub use pmoserver_ext::XXXExt;
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajouter la feature dans `Cargo.toml` :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[features]
|
||||||
|
pmoserver = ["dep:axum", "dep:async-trait"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.8", optional = true }
|
||||||
|
async-trait = { version = "0.1", optional = true }
|
||||||
|
pmoserver = { path = "../pmoserver" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Définir le trait d'extension
|
||||||
|
|
||||||
|
**Convention de nommage** : `{Domaine}Ext` avec méthodes préfixées `init_*`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Trait pour étendre pmoserver avec les fonctionnalités XXX
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[async_trait]
|
||||||
|
pub trait XXXExt {
|
||||||
|
/// Initialise l'extension XXX et enregistre les routes HTTP
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `param1` - Description du paramètre
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Instance partagée de la ressource créée
|
||||||
|
///
|
||||||
|
/// # Exemple
|
||||||
|
/// ```ignore
|
||||||
|
/// use pmoserver::ServerBuilder;
|
||||||
|
/// use pmoXXX::XXXExt;
|
||||||
|
///
|
||||||
|
/// let mut server = ServerBuilder::new(...).build();
|
||||||
|
/// let resource = server.init_xxx(param1).await?;
|
||||||
|
/// ```
|
||||||
|
async fn init_xxx(&mut self, param1: String) -> anyhow::Result<Arc<Resource>>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Implémenter le trait
|
||||||
|
|
||||||
|
Implémenter le trait pour `pmoserver::Server` :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[async_trait]
|
||||||
|
impl XXXExt for pmoserver::Server {
|
||||||
|
async fn init_xxx(&mut self, param1: String) -> anyhow::Result<Arc<Resource>> {
|
||||||
|
// 1. Créer la ressource interne
|
||||||
|
let resource = Arc::new(Resource::new(param1)?);
|
||||||
|
|
||||||
|
// 2. Créer l'état partagé pour les handlers
|
||||||
|
let state = XxxState::new(resource.clone());
|
||||||
|
|
||||||
|
// 3. Créer le router avec les routes
|
||||||
|
let router = create_xxx_router(state);
|
||||||
|
|
||||||
|
// 4. Enregistrer le router sur le serveur
|
||||||
|
self.add_router("/api/xxx", router).await;
|
||||||
|
|
||||||
|
// 5. Retourner la ressource pour usage ultérieur
|
||||||
|
Ok(resource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. État partagé (State)
|
||||||
|
|
||||||
|
Créer une structure d'état cloneable pour les handlers :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// État partagé pour les handlers XXX
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct XxxState {
|
||||||
|
resource: Arc<Resource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl XxxState {
|
||||||
|
pub fn new(resource: Arc<Resource>) -> Self {
|
||||||
|
Self { resource }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Créer le router
|
||||||
|
|
||||||
|
Définir les routes et handlers :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Crée le router pour l'API XXX
|
||||||
|
fn create_xxx_router(state: XxxState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/items", get(list_items).post(create_item))
|
||||||
|
.route("/items/{id}", get(get_item).delete(delete_item))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
async fn list_items(
|
||||||
|
State(state): State<XxxState>
|
||||||
|
) -> Json<Vec<ItemSummary>> {
|
||||||
|
let items = state.resource.list_items();
|
||||||
|
Json(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_item(
|
||||||
|
State(state): State<XxxState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<Item>, StatusCode> {
|
||||||
|
state.resource.get_item(&id)
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)
|
||||||
|
.map(Json)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Méthodes disponibles du serveur
|
||||||
|
|
||||||
|
`pmoserver::Server` expose ces méthodes pour enregistrer des routes :
|
||||||
|
|
||||||
|
| Méthode | Usage |
|
||||||
|
|---------|-------|
|
||||||
|
| `add_handler(path, handler)` | Ajoute un handler simple sans état |
|
||||||
|
| `add_handler_with_state(path, handler, state)` | Ajoute un handler avec état partagé |
|
||||||
|
| `add_router(path, router)` | Monte un sous-router Axum |
|
||||||
|
| `add_openapi(router, doc, tag)` | Enregistre une API avec documentation OpenAPI |
|
||||||
|
| `add_spa::<W>(path)` | Sert une Single Page Application (RustEmbed) |
|
||||||
|
| `base_url()` | Récupère l'URL de base du serveur |
|
||||||
|
|
||||||
|
## Documentation OpenAPI avec utoipa
|
||||||
|
|
||||||
|
La documentation OpenAPI est essentielle pour une extension `pmoserver`. Elle génère automatiquement une interface Swagger UI et documente les endpoints de l'API.
|
||||||
|
|
||||||
|
### Configuration de base
|
||||||
|
|
||||||
|
Ajouter `utoipa` dans `Cargo.toml` :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
utoipa = { version = "5", features = ["axum_extras"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Définir les schémas de données
|
||||||
|
|
||||||
|
Annoter les structures de réponse/requête avec `#[derive(ToSchema)]` :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
/// Information sur un item
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ItemInfo {
|
||||||
|
/// ID unique de l'item
|
||||||
|
#[schema(example = "item-123")]
|
||||||
|
pub id: String,
|
||||||
|
|
||||||
|
/// Nom de l'item
|
||||||
|
#[schema(example = "Mon Item")]
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
/// Description optionnelle
|
||||||
|
#[schema(example = "Une description détaillée")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
|
||||||
|
/// Timestamp de création (millisecondes)
|
||||||
|
#[schema(example = 1234567890)]
|
||||||
|
pub created_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liste d'items
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct ItemList {
|
||||||
|
/// Nombre total d'items
|
||||||
|
pub total: usize,
|
||||||
|
|
||||||
|
/// Items de la page courante
|
||||||
|
pub items: Vec<ItemInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requête de création d'item
|
||||||
|
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||||
|
pub struct CreateItemRequest {
|
||||||
|
/// Nom de l'item à créer
|
||||||
|
#[schema(example = "Nouvel Item")]
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
/// Description optionnelle
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réponse d'erreur standard
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
/// Message d'erreur
|
||||||
|
#[schema(example = "Item not found")]
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Points clés** :
|
||||||
|
- `#[schema(example = "...")]` : Fournit des exemples pour la doc Swagger
|
||||||
|
- Documenter chaque champ avec `///` pour apparaître dans l'API
|
||||||
|
- Utiliser `Option<T>` pour les champs optionnels
|
||||||
|
|
||||||
|
### 2. Annoter les handlers
|
||||||
|
|
||||||
|
Utiliser `#[utoipa::path(...)]` pour documenter chaque endpoint :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// GET /items - Liste tous les items
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/items",
|
||||||
|
params(
|
||||||
|
("limit" = Option<u32>, Query, description = "Nombre max d'items à retourner"),
|
||||||
|
("offset" = Option<u32>, Query, description = "Offset pour la pagination")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Liste des items", body = ItemList),
|
||||||
|
(status = 500, description = "Erreur serveur", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "items"
|
||||||
|
)]
|
||||||
|
async fn list_items(
|
||||||
|
State(state): State<XxxState>,
|
||||||
|
Query(params): Query<ListParams>,
|
||||||
|
) -> Result<Json<ItemList>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let items = state.resource.list_items(params.limit, params.offset)
|
||||||
|
.map_err(|e| (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse { error: e.to_string() })
|
||||||
|
))?;
|
||||||
|
|
||||||
|
Ok(Json(ItemList {
|
||||||
|
total: items.len(),
|
||||||
|
items,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /items/{id} - Récupère un item spécifique
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/items/{id}",
|
||||||
|
params(
|
||||||
|
("id" = String, Path, description = "ID unique de l'item")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Item trouvé", body = ItemInfo),
|
||||||
|
(status = 404, description = "Item non trouvé", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur serveur", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "items"
|
||||||
|
)]
|
||||||
|
async fn get_item(
|
||||||
|
State(state): State<XxxState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<ItemInfo>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
state.resource.get_item(&id)
|
||||||
|
.ok_or_else(|| (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Item {} not found", id)
|
||||||
|
})
|
||||||
|
))
|
||||||
|
.map(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /items - Crée un nouvel item
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/items",
|
||||||
|
request_body = CreateItemRequest,
|
||||||
|
responses(
|
||||||
|
(status = 201, description = "Item créé", body = ItemInfo),
|
||||||
|
(status = 400, description = "Requête invalide", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur serveur", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "items"
|
||||||
|
)]
|
||||||
|
async fn create_item(
|
||||||
|
State(state): State<XxxState>,
|
||||||
|
Json(req): Json<CreateItemRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<ItemInfo>), (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
let item = state.resource.create_item(req.name, req.description)
|
||||||
|
.map_err(|e| (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse { error: e.to_string() })
|
||||||
|
))?;
|
||||||
|
|
||||||
|
Ok((StatusCode::CREATED, Json(item)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /items/{id} - Supprime un item
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/items/{id}",
|
||||||
|
params(
|
||||||
|
("id" = String, Path, description = "ID unique de l'item")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 204, description = "Item supprimé"),
|
||||||
|
(status = 404, description = "Item non trouvé", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur serveur", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "items"
|
||||||
|
)]
|
||||||
|
async fn delete_item(
|
||||||
|
State(state): State<XxxState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
state.resource.delete_item(&id)
|
||||||
|
.map_err(|e| (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse { error: e.to_string() })
|
||||||
|
))?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure de `#[utoipa::path]`** :
|
||||||
|
- **Méthode HTTP** : `get`, `post`, `put`, `delete`, `patch`
|
||||||
|
- **`path`** : Chemin de l'endpoint (doit correspondre au router)
|
||||||
|
- **`params`** : Paramètres Path ou Query avec description
|
||||||
|
- **`request_body`** : Type du body pour POST/PUT
|
||||||
|
- **`responses`** : Liste des réponses possibles avec codes HTTP
|
||||||
|
- **`tag`** : Groupe d'endpoints dans Swagger UI
|
||||||
|
|
||||||
|
### 3. Créer la structure OpenAPI
|
||||||
|
|
||||||
|
Définir une structure avec `#[derive(OpenApi)]` :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
/// Documentation OpenAPI pour l'API XXX
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
info(
|
||||||
|
title = "XXX API",
|
||||||
|
version = "1.0.0",
|
||||||
|
description = r#"
|
||||||
|
# API REST pour XXX
|
||||||
|
|
||||||
|
Cette API permet de gérer les items XXX avec les fonctionnalités suivantes :
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- **CRUD complet** : Création, lecture, mise à jour et suppression d'items
|
||||||
|
- **Pagination** : Support de limit/offset pour les listes
|
||||||
|
- **Filtrage** : Recherche par critères multiples
|
||||||
|
- **Validation** : Vérification automatique des données
|
||||||
|
|
||||||
|
## Exemples d'utilisation
|
||||||
|
|
||||||
|
### Lister les items
|
||||||
|
```
|
||||||
|
GET /api/xxx/items?limit=10&offset=0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Créer un item
|
||||||
|
```
|
||||||
|
POST /api/xxx/items
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Mon Item",
|
||||||
|
"description": "Description détaillée"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Récupérer un item
|
||||||
|
```
|
||||||
|
GET /api/xxx/items/item-123
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supprimer un item
|
||||||
|
```
|
||||||
|
DELETE /api/xxx/items/item-123
|
||||||
|
```
|
||||||
|
"#
|
||||||
|
),
|
||||||
|
paths(
|
||||||
|
list_items,
|
||||||
|
get_item,
|
||||||
|
create_item,
|
||||||
|
delete_item,
|
||||||
|
),
|
||||||
|
components(schemas(
|
||||||
|
ItemInfo,
|
||||||
|
ItemList,
|
||||||
|
CreateItemRequest,
|
||||||
|
ErrorResponse,
|
||||||
|
)),
|
||||||
|
tags(
|
||||||
|
(name = "items", description = "Opérations sur les items")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub struct ApiDoc;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sections importantes** :
|
||||||
|
- **`info`** : Titre, version et description Markdown de l'API
|
||||||
|
- **`paths`** : Liste des fonctions handler annotées
|
||||||
|
- **`components(schemas(...))`** : Liste des structures `ToSchema`
|
||||||
|
- **`tags`** : Organisation des endpoints en groupes
|
||||||
|
|
||||||
|
### 4. Enregistrer l'API avec OpenAPI
|
||||||
|
|
||||||
|
Dans l'implémentation du trait d'extension :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
impl XxxExt for pmoserver::Server {
|
||||||
|
async fn init_xxx(&mut self) -> anyhow::Result<Arc<Resource>> {
|
||||||
|
let resource = Arc::new(Resource::new()?);
|
||||||
|
let state = XxxState { resource: resource.clone() };
|
||||||
|
|
||||||
|
// Créer le router avec les routes
|
||||||
|
let router = Router::new()
|
||||||
|
.route("/items", get(list_items).post(create_item))
|
||||||
|
.route("/items/{id}", get(get_item).delete(delete_item))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
// Enregistrer avec OpenAPI (génère aussi /swagger-ui/xxx)
|
||||||
|
let openapi = ApiDoc::openapi();
|
||||||
|
self.add_openapi(router, openapi, "xxx").await;
|
||||||
|
|
||||||
|
Ok(resource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ce que fait `add_openapi`** :
|
||||||
|
- Monte le router sur `/api/{tag}/`
|
||||||
|
- Génère la spec OpenAPI JSON sur `/api/{tag}/openapi.json`
|
||||||
|
- Crée une UI Swagger sur `/swagger-ui/{tag}/`
|
||||||
|
|
||||||
|
### 5. Exemple complet : Radio Paradise
|
||||||
|
|
||||||
|
**Extrait de** `pmoparadise/src/pmoserver_ext.rs:93-315`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Information sur un morceau
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct SongInfo {
|
||||||
|
/// Index dans le block
|
||||||
|
pub index: usize,
|
||||||
|
/// Artiste
|
||||||
|
pub artist: String,
|
||||||
|
/// Titre
|
||||||
|
pub title: String,
|
||||||
|
/// Album
|
||||||
|
pub album: String,
|
||||||
|
/// Année
|
||||||
|
pub year: Option<u32>,
|
||||||
|
/// Temps écoulé depuis le début du block (ms)
|
||||||
|
pub elapsed_ms: u64,
|
||||||
|
/// Durée du morceau (ms)
|
||||||
|
pub duration_ms: u64,
|
||||||
|
/// URL de la pochette
|
||||||
|
pub cover_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réponse pour l'URL de streaming
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct StreamUrlResponse {
|
||||||
|
/// Event ID du block
|
||||||
|
#[schema(example = 1234567)]
|
||||||
|
pub event: u64,
|
||||||
|
/// URL de streaming FLAC
|
||||||
|
#[schema(example = "https://apps.radioparadise.com/blocks/chan/0/4/1234567-1234580.flac")]
|
||||||
|
pub stream_url: String,
|
||||||
|
/// Durée totale (ms)
|
||||||
|
#[schema(example = 900000)]
|
||||||
|
pub length_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /stream-url/{event_id} - Récupère l'URL de streaming
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/stream-url/{event_id}",
|
||||||
|
params(
|
||||||
|
("event_id" = u64, Path, description = "Event ID du block"),
|
||||||
|
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "URL de streaming", body = StreamUrlResponse),
|
||||||
|
(status = 500, description = "Erreur serveur")
|
||||||
|
),
|
||||||
|
tag = "Radio Paradise"
|
||||||
|
)]
|
||||||
|
async fn get_stream_url(
|
||||||
|
State(state): State<RadioParadiseState>,
|
||||||
|
Path(event_id): Path<u64>,
|
||||||
|
Query(params): Query<ParadiseQuery>,
|
||||||
|
) -> Result<Json<StreamUrlResponse>, StatusCode> {
|
||||||
|
let client = state.client_for_params(¶ms).await?;
|
||||||
|
let block = client.get_block(Some(event_id)).await.map_err(|e| {
|
||||||
|
tracing::error!("Failed to fetch block {}: {}", event_id, e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(StreamUrlResponse {
|
||||||
|
event: block.event,
|
||||||
|
stream_url: block.url,
|
||||||
|
length_ms: block.length,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(
|
||||||
|
info(
|
||||||
|
title = "Radio Paradise API",
|
||||||
|
version = "1.0.0",
|
||||||
|
description = "API REST pour accéder aux métadonnées Radio Paradise"
|
||||||
|
),
|
||||||
|
paths(
|
||||||
|
get_now_playing,
|
||||||
|
get_current_block,
|
||||||
|
get_stream_url,
|
||||||
|
),
|
||||||
|
components(schemas(
|
||||||
|
SongInfo,
|
||||||
|
StreamUrlResponse,
|
||||||
|
)),
|
||||||
|
tags(
|
||||||
|
(name = "Radio Paradise", description = "Endpoints Radio Paradise")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub struct RadioParadiseApiDoc;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Résultat : Interface Swagger
|
||||||
|
|
||||||
|
Après avoir appelé `init_xxx()`, l'API est accessible :
|
||||||
|
|
||||||
|
- **API JSON** : `http://localhost:8080/api/xxx/`
|
||||||
|
- **Spec OpenAPI** : `http://localhost:8080/api/xxx/openapi.json`
|
||||||
|
- **Swagger UI** : `http://localhost:8080/swagger-ui/xxx/`
|
||||||
|
|
||||||
|
L'interface Swagger permet :
|
||||||
|
- Parcourir tous les endpoints avec leur documentation
|
||||||
|
- Tester les requêtes directement depuis le navigateur
|
||||||
|
- Voir les schémas de données avec exemples
|
||||||
|
- Consulter les codes de réponse HTTP possibles
|
||||||
|
|
||||||
|
## Patterns courants
|
||||||
|
|
||||||
|
### Pattern 1 : Extension simple avec router
|
||||||
|
|
||||||
|
**Exemple** : `pmoparadise` (pmoparadise/src/pmoserver_ext.rs:367-392)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
impl RadioParadiseExt for pmoserver::Server {
|
||||||
|
async fn init_radioparadise(&mut self) -> anyhow::Result<State> {
|
||||||
|
let state = RadioParadiseState::new().await?;
|
||||||
|
|
||||||
|
// Créer le router API
|
||||||
|
let api_router = create_api_router(state.clone());
|
||||||
|
|
||||||
|
// Enregistrer avec OpenAPI
|
||||||
|
self.add_openapi(api_router, ApiDoc::openapi(), "radioparadise")
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2 : Extension avec cache et fichiers
|
||||||
|
|
||||||
|
**Exemple** : `pmoaudiocache` (pmoaudiocache/src/lib.rs:225-260)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
impl AudioCacheExt for pmoserver::Server {
|
||||||
|
async fn init_audio_cache(
|
||||||
|
&mut self,
|
||||||
|
cache_dir: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> anyhow::Result<Arc<Cache>> {
|
||||||
|
let cache = Arc::new(new_cache(cache_dir, limit)?);
|
||||||
|
|
||||||
|
// Router pour servir les fichiers FLAC
|
||||||
|
let file_router = create_file_router(cache.clone(), "audio/flac");
|
||||||
|
self.add_router("/", file_router).await;
|
||||||
|
|
||||||
|
// API REST
|
||||||
|
let api_router = Router::new()
|
||||||
|
.route("/", get(list).post(add))
|
||||||
|
.route("/{pk}", get(get_info).delete(delete))
|
||||||
|
.with_state(cache.clone());
|
||||||
|
|
||||||
|
self.add_openapi(api_router, ApiDoc::openapi(), "audio").await;
|
||||||
|
|
||||||
|
Ok(cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3 : Extension avec routes dynamiques
|
||||||
|
|
||||||
|
**Exemple** : `pmomediaserver` (pmomediaserver/src/paradise_streaming.rs:70-148)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
impl ParadiseStreamingExt for pmoserver::Server {
|
||||||
|
async fn init_paradise_streaming(&mut self) -> Result<Arc<Manager>> {
|
||||||
|
// 1. Récupérer/créer les ressources partagées
|
||||||
|
let audio_cache = get_or_init_audio_cache(self).await?;
|
||||||
|
let manager = Arc::new(Manager::new(audio_cache).await?);
|
||||||
|
|
||||||
|
// 2. Créer l'état partagé
|
||||||
|
let state = Arc::new(StreamingState { manager: manager.clone() });
|
||||||
|
|
||||||
|
// 3. Enregistrer les routes pour chaque canal
|
||||||
|
for descriptor in ALL_CHANNELS.iter() {
|
||||||
|
let slug = descriptor.slug;
|
||||||
|
|
||||||
|
// Route streaming FLAC
|
||||||
|
let path = format!("/stream/{}/flac", slug);
|
||||||
|
self.add_handler_with_state(
|
||||||
|
&path,
|
||||||
|
move |State(s): State<Arc<StreamingState>>| async move {
|
||||||
|
stream_flac(s.manager.clone(), descriptor.id).await
|
||||||
|
},
|
||||||
|
state.clone(),
|
||||||
|
).await;
|
||||||
|
|
||||||
|
// Route streaming OGG
|
||||||
|
let path = format!("/stream/{}/ogg", slug);
|
||||||
|
self.add_handler_with_state(
|
||||||
|
&path,
|
||||||
|
move |State(s): State<Arc<StreamingState>>| async move {
|
||||||
|
stream_ogg(s.manager.clone(), descriptor.id).await
|
||||||
|
},
|
||||||
|
state.clone(),
|
||||||
|
).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(manager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gestion des opérations longues
|
||||||
|
|
||||||
|
### Utiliser `spawn_blocking` pour le code synchrone
|
||||||
|
|
||||||
|
Pour éviter de bloquer le runtime Tokio avec du code synchrone :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn list_renderers(
|
||||||
|
State(state): State<ControlPointState>
|
||||||
|
) -> Json<Vec<Summary>> {
|
||||||
|
let control_point = state.control_point.clone();
|
||||||
|
|
||||||
|
let summaries = tokio::task::spawn_blocking(move || {
|
||||||
|
let renderers = control_point.list_music_renderers();
|
||||||
|
renderers.into_iter()
|
||||||
|
.map(|r| Summary::from(&r))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Json(summaries)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ajouter des timeouts pour les opérations réseau
|
||||||
|
|
||||||
|
```rust
|
||||||
|
const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
async fn play_renderer(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<Response>, (StatusCode, Json<Error>)> {
|
||||||
|
let renderer = state.get_renderer(&id)
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, Json(Error::not_found())))?;
|
||||||
|
|
||||||
|
let play_task = tokio::task::spawn_blocking(move || renderer.play());
|
||||||
|
|
||||||
|
time::timeout(COMMAND_TIMEOUT, play_task)
|
||||||
|
.await
|
||||||
|
.map_err(|_| (
|
||||||
|
StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
Json(Error::timeout())
|
||||||
|
))?
|
||||||
|
.map_err(|e| (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(Error::internal(e))
|
||||||
|
))??;
|
||||||
|
|
||||||
|
Ok(Json(Response::success()))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Utiliser `spawn` pour les tâches en arrière-plan
|
||||||
|
|
||||||
|
Pour les opérations qui ne nécessitent pas d'attendre le résultat :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn trigger_action(
|
||||||
|
State(state): State<XxxState>,
|
||||||
|
Json(req): Json<Request>,
|
||||||
|
) -> Json<Response> {
|
||||||
|
// Valider la requête
|
||||||
|
state.validate(&req)?;
|
||||||
|
|
||||||
|
// Lancer l'action en arrière-plan
|
||||||
|
let state_clone = state.clone();
|
||||||
|
tokio::task::spawn(async move {
|
||||||
|
match state_clone.perform_action(req).await {
|
||||||
|
Ok(_) => debug!("Action completed"),
|
||||||
|
Err(e) => warn!("Action failed: {}", e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retourner immédiatement
|
||||||
|
Json(Response::accepted())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checklist d'implémentation
|
||||||
|
|
||||||
|
### Configuration de base
|
||||||
|
- [ ] Créer le module `pmoserver_ext.rs` avec `#[cfg(feature = "pmoserver")]`
|
||||||
|
- [ ] Ajouter la feature `pmoserver` dans `Cargo.toml` avec dépendances optionnelles
|
||||||
|
- [ ] Re-exporter le trait dans `lib.rs`
|
||||||
|
|
||||||
|
### Définition du trait
|
||||||
|
- [ ] Définir le trait `{Domaine}Ext` avec méthode `init_*`
|
||||||
|
- [ ] Créer la structure `{Domaine}State` avec `#[derive(Clone)]`
|
||||||
|
- [ ] Implémenter le trait pour `pmoserver::Server`
|
||||||
|
|
||||||
|
### Documentation OpenAPI
|
||||||
|
- [ ] Ajouter `utoipa` dans les dépendances
|
||||||
|
- [ ] Définir les schémas de réponse/requête avec `#[derive(ToSchema)]`
|
||||||
|
- [ ] Ajouter des exemples avec `#[schema(example = "...")]`
|
||||||
|
- [ ] Annoter chaque handler avec `#[utoipa::path(...)]`
|
||||||
|
- [ ] Créer la structure `#[derive(OpenApi)]` avec documentation complète
|
||||||
|
- [ ] Lister tous les paths et schemas dans `#[openapi(...)]`
|
||||||
|
|
||||||
|
### Handlers et routes
|
||||||
|
- [ ] Créer les handlers avec les extracteurs Axum appropriés
|
||||||
|
- [ ] Gérer les erreurs avec des codes HTTP sémantiques
|
||||||
|
- [ ] Créer le router et l'enregistrer avec `add_openapi()`
|
||||||
|
- [ ] Ajouter des logs (debug, info, warn, error)
|
||||||
|
|
||||||
|
### Performance et robustesse
|
||||||
|
- [ ] Utiliser `spawn_blocking` pour le code synchrone
|
||||||
|
- [ ] Ajouter des timeouts pour les opérations réseau
|
||||||
|
- [ ] Utiliser `spawn` pour les tâches en arrière-plan si nécessaire
|
||||||
|
|
||||||
|
## Exemple complet minimal
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// pmoexample/src/pmoserver_ext.rs
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use async_trait::async_trait;
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use axum::{Router, routing::get, Json, extract::State};
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use std::sync::Arc;
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use crate::ExampleResource;
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ExampleState {
|
||||||
|
resource: Arc<ExampleResource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ExampleExt {
|
||||||
|
async fn init_example(&mut self) -> anyhow::Result<Arc<ExampleResource>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[async_trait]
|
||||||
|
impl ExampleExt for pmoserver::Server {
|
||||||
|
async fn init_example(&mut self) -> anyhow::Result<Arc<ExampleResource>> {
|
||||||
|
let resource = Arc::new(ExampleResource::new());
|
||||||
|
let state = ExampleState { resource: resource.clone() };
|
||||||
|
|
||||||
|
let router = Router::new()
|
||||||
|
.route("/items", get(list_items))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
self.add_router("/api/example", router).await;
|
||||||
|
|
||||||
|
Ok(resource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
async fn list_items(State(state): State<ExampleState>) -> Json<Vec<String>> {
|
||||||
|
let items = state.resource.list();
|
||||||
|
Json(items)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Références
|
||||||
|
|
||||||
|
### Exemples dans le codebase
|
||||||
|
|
||||||
|
| Crate | Fichier | Pattern |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `pmoparadise` | `src/pmoserver_ext.rs:367-392` | Extension simple avec OpenAPI |
|
||||||
|
| `pmoaudiocache` | `src/lib.rs:225-260` | Extension avec cache et fichiers |
|
||||||
|
| `pmomediaserver` | `src/paradise_streaming.rs:70-148` | Extension avec routes dynamiques |
|
||||||
|
| `pmocontrol` | `src/pmoserver_ext.rs:68-92` | Handlers avec `spawn_blocking` |
|
||||||
|
| `pmoapp` | `src/lib.rs:145-165` | Extension SPA avec RustEmbed |
|
||||||
|
|
||||||
|
### Dépendances communes
|
||||||
|
|
||||||
|
- `axum` : Framework HTTP (Router, handlers, extracteurs)
|
||||||
|
- `async-trait` : Support des traits async
|
||||||
|
- `tokio` : Runtime async (spawn, spawn_blocking, timeout)
|
||||||
|
- `anyhow` : Gestion d'erreurs pour init
|
||||||
|
- `tracing` : Logging structuré
|
||||||
|
- `utoipa` : Documentation OpenAPI/Swagger
|
||||||
|
- `serde` : Sérialisation JSON
|
||||||
905
Blackboard/Done/Pinnable_cache_item.md
Normal file
905
Blackboard/Done/Pinnable_cache_item.md
Normal file
@@ -0,0 +1,905 @@
|
|||||||
|
# Rapport Final : Items Épinglables et TTL dans PMOcache
|
||||||
|
|
||||||
|
## Objectif de la tâche
|
||||||
|
|
||||||
|
Étendre le système de cache PMOcache pour permettre un contrôle plus fin des règles de suppression des items. L'objectif était double :
|
||||||
|
|
||||||
|
1. **Phase 1** : Implémenter un système d'items épinglables (pinned) protégés de l'éviction LRU, avec support du TTL (Time To Live) pour l'expiration automatique
|
||||||
|
2. **Phase 2** : Exposer ces fonctionnalités via une API REST complète avec documentation OpenAPI
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
La crate PMOcache implémente un système de cache avec :
|
||||||
|
- Capacité maximale configurable
|
||||||
|
- Politique d'éviction LRU (Least Recently Used)
|
||||||
|
- TTL optionnel pour les items
|
||||||
|
|
||||||
|
La nouvelle fonctionnalité permet de :
|
||||||
|
- **Épingler** des items critiques pour les rendre permanents
|
||||||
|
- **Exclure** les items épinglés du comptage de la limite du cache
|
||||||
|
- **Définir un TTL** pour supprimer automatiquement les items temporaires
|
||||||
|
- **Garantir l'incompatibilité** entre pinning et TTL (règle métier)
|
||||||
|
|
||||||
|
## Architecture de la solution
|
||||||
|
|
||||||
|
### 1. Modifications de la base de données
|
||||||
|
|
||||||
|
#### Schéma SQL étendu
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS asset (
|
||||||
|
pk TEXT PRIMARY KEY,
|
||||||
|
collection TEXT,
|
||||||
|
id TEXT,
|
||||||
|
hits INTEGER DEFAULT 0,
|
||||||
|
last_used TEXT,
|
||||||
|
lazy_pk TEXT,
|
||||||
|
pinned INTEGER DEFAULT 0 CHECK (pinned IN (0, 1)),
|
||||||
|
ttl_expires_at TEXT
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Deux nouvelles colonnes :
|
||||||
|
- **`pinned`** : Booléen (0/1) indiquant si l'item est protégé
|
||||||
|
- **`ttl_expires_at`** : Date RFC3339 d'expiration (optionnel)
|
||||||
|
|
||||||
|
#### Structure `CacheEntry` enrichie
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct CacheEntry {
|
||||||
|
pub pk: String,
|
||||||
|
pub lazy_pk: Option<String>,
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub collection: Option<String>,
|
||||||
|
pub hits: i32,
|
||||||
|
pub last_used: Option<String>,
|
||||||
|
pub pinned: bool, // Nouveau
|
||||||
|
pub ttl_expires_at: Option<String>, // Nouveau
|
||||||
|
pub metadata: Option<Value>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. API de base de données (db.rs)
|
||||||
|
|
||||||
|
#### Nouvelles méthodes implémentées
|
||||||
|
|
||||||
|
##### Gestion du comptage
|
||||||
|
- **`count_unpinned()`** : Compte uniquement les items non épinglés
|
||||||
|
- Les items épinglés sont exclus de la limite du cache
|
||||||
|
|
||||||
|
##### Gestion du pinning
|
||||||
|
- **`pin(pk)`** : Épingle un item
|
||||||
|
- Vérifie qu'aucun TTL n'est défini (règle métier)
|
||||||
|
- Retourne erreur si TTL présent
|
||||||
|
|
||||||
|
- **`unpin(pk)`** : Désépingle un item
|
||||||
|
|
||||||
|
- **`is_pinned(pk)`** : Vérifie le statut de pinning
|
||||||
|
|
||||||
|
##### Gestion du TTL
|
||||||
|
- **`set_ttl(pk, expires_at)`** : Définit la date d'expiration
|
||||||
|
- Vérifie que l'item n'est pas épinglé (règle métier)
|
||||||
|
- Retourne erreur si épinglé
|
||||||
|
|
||||||
|
- **`clear_ttl(pk)`** : Supprime le TTL
|
||||||
|
|
||||||
|
- **`get_expired()`** : Récupère tous les items expirés
|
||||||
|
|
||||||
|
##### Modification de `get_oldest()`
|
||||||
|
|
||||||
|
Exclusion automatique des items épinglés :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT ... FROM asset
|
||||||
|
WHERE pinned = 0
|
||||||
|
ORDER BY last_used ASC, hits ASC
|
||||||
|
LIMIT ?1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Logique du cache (cache.rs)
|
||||||
|
|
||||||
|
#### Méthodes publiques exposées
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn pin(&self, pk: &str) -> Result<()>
|
||||||
|
pub async fn unpin(&self, pk: &str) -> Result<()>
|
||||||
|
pub async fn is_pinned(&self, pk: &str) -> Result<bool>
|
||||||
|
pub async fn set_ttl(&self, pk: &str, expires_at: &str) -> Result<()>
|
||||||
|
pub async fn clear_ttl(&self, pk: &str) -> Result<()>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Politique d'éviction améliorée
|
||||||
|
|
||||||
|
La méthode `enforce_limit()` a été complètement repensée :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn enforce_limit(&self) -> Result<usize> {
|
||||||
|
// 1. Supprimer d'abord les items expirés (TTL dépassé)
|
||||||
|
let expired_entries = self.db.get_expired()?;
|
||||||
|
for entry in expired_entries {
|
||||||
|
// Suppression fichiers + DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Compter UNIQUEMENT les items non épinglés
|
||||||
|
let count = self.db.count_unpinned()?;
|
||||||
|
|
||||||
|
// 3. Si limite dépassée, supprimer les plus vieux (non épinglés)
|
||||||
|
if count > self.limit {
|
||||||
|
let to_remove = count - self.limit;
|
||||||
|
let old_entries = self.db.get_oldest(to_remove)?;
|
||||||
|
// Suppression...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ordre de priorité** :
|
||||||
|
1. Items expirés (TTL) → suppression immédiate
|
||||||
|
2. Items non épinglés les plus vieux (LRU) → suppression si limite dépassée
|
||||||
|
3. Items épinglés → **jamais supprimés automatiquement**
|
||||||
|
|
||||||
|
### 4. API REST (api.rs)
|
||||||
|
|
||||||
|
#### Nouvelles structures de données
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct PinStatus {
|
||||||
|
pub pk: String,
|
||||||
|
pub pinned: bool,
|
||||||
|
pub ttl_expires_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct PinResponse {
|
||||||
|
pub pk: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct SetTtlRequest {
|
||||||
|
pub expires_at: String, // RFC3339
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Handlers HTTP implémentés
|
||||||
|
|
||||||
|
##### `get_pin_status(pk)` - GET /{pk}/pin
|
||||||
|
Récupère le statut actuel de pinning et TTL d'un item.
|
||||||
|
|
||||||
|
**Réponse 200 OK** :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pk": "1a2b3c4d5e6f7a8b",
|
||||||
|
"pinned": false,
|
||||||
|
"ttl_expires_at": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### `pin_item(pk)` - POST /{pk}/pin
|
||||||
|
Épingle un item pour le protéger de l'éviction.
|
||||||
|
|
||||||
|
**Réponse 200 OK** :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pk": "1a2b3c4d5e6f7a8b",
|
||||||
|
"message": "Item '1a2b3c4d5e6f7a8b' pinned successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse 409 CONFLICT** (si TTL défini) :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "CONFLICT",
|
||||||
|
"message": "Cannot pin an item with TTL set. Clear TTL first."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### `unpin_item(pk)` - DELETE /{pk}/pin
|
||||||
|
Désépingle un item.
|
||||||
|
|
||||||
|
##### `set_item_ttl(pk, request)` - POST /{pk}/ttl
|
||||||
|
Définit le TTL d'un item.
|
||||||
|
|
||||||
|
**Requête** :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expires_at": "2025-01-20T10:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse 409 CONFLICT** (si épinglé) :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "CONFLICT",
|
||||||
|
"message": "Cannot set TTL on a pinned item. Unpin first."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse 400 BAD REQUEST** (format invalide) :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "INVALID_DATE",
|
||||||
|
"message": "Invalid RFC3339 date format"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### `clear_item_ttl(pk)` - DELETE /{pk}/ttl
|
||||||
|
Supprime le TTL d'un item.
|
||||||
|
|
||||||
|
### 5. Routes HTTP (pmoserver_ext.rs)
|
||||||
|
|
||||||
|
Routes ajoutées au router API :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Router::new()
|
||||||
|
// ... routes existantes ...
|
||||||
|
.route(
|
||||||
|
"/{pk}/pin",
|
||||||
|
get(api::get_pin_status::<C>)
|
||||||
|
.post(api::pin_item::<C>)
|
||||||
|
.delete(api::unpin_item::<C>),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{pk}/ttl",
|
||||||
|
post(api::set_item_ttl::<C>)
|
||||||
|
.delete(api::clear_item_ttl::<C>),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**URLs complètes** (exemple pour cache audio) :
|
||||||
|
- `GET /api/audio/{pk}/pin`
|
||||||
|
- `POST /api/audio/{pk}/pin`
|
||||||
|
- `DELETE /api/audio/{pk}/pin`
|
||||||
|
- `POST /api/audio/{pk}/ttl`
|
||||||
|
- `DELETE /api/audio/{pk}/ttl`
|
||||||
|
|
||||||
|
### 6. Documentation OpenAPI (openapi.rs)
|
||||||
|
|
||||||
|
La macro `create_cache_openapi!` a été enrichie pour inclure automatiquement :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[openapi(
|
||||||
|
paths(
|
||||||
|
// ... paths existants ...
|
||||||
|
$crate::api::get_pin_status::<Self>,
|
||||||
|
$crate::api::pin_item::<Self>,
|
||||||
|
$crate::api::unpin_item::<Self>,
|
||||||
|
$crate::api::set_item_ttl::<Self>,
|
||||||
|
$crate::api::clear_item_ttl::<Self>,
|
||||||
|
),
|
||||||
|
components(
|
||||||
|
schemas(
|
||||||
|
// ... schemas existants ...
|
||||||
|
$crate::api::PinStatus,
|
||||||
|
$crate::api::PinResponse,
|
||||||
|
$crate::api::SetTtlRequest,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Accès Swagger UI** : `/swagger-ui/{cache_name}`
|
||||||
|
|
||||||
|
## Règles métier implémentées
|
||||||
|
|
||||||
|
### 1. Incompatibilité stricte : Pinned ↔ TTL
|
||||||
|
|
||||||
|
Un item ne peut **jamais** être à la fois épinglé ET avoir un TTL :
|
||||||
|
|
||||||
|
| État actuel | Action | Résultat |
|
||||||
|
|-------------|--------|----------|
|
||||||
|
| Aucun TTL | `pin()` | ✅ Succès |
|
||||||
|
| TTL défini | `pin()` | ❌ Erreur 409 |
|
||||||
|
| Non épinglé | `set_ttl()` | ✅ Succès |
|
||||||
|
| Épinglé | `set_ttl()` | ❌ Erreur 409 |
|
||||||
|
|
||||||
|
**Rationale** :
|
||||||
|
- **Épinglé** = permanent, ne doit jamais être supprimé automatiquement
|
||||||
|
- **TTL** = temporaire, sera supprimé à expiration
|
||||||
|
- Ces deux concepts sont sémantiquement contradictoires
|
||||||
|
|
||||||
|
### 2. Exclusion du comptage
|
||||||
|
|
||||||
|
Les items épinglés ne comptent **pas** dans la limite du cache :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Cache avec limite de 100 items
|
||||||
|
let unpinned_count = cache.db.count_unpinned()?; // 100
|
||||||
|
let total_count = cache.db.count()?; // 150
|
||||||
|
|
||||||
|
// Le cache peut contenir :
|
||||||
|
// - 100 items non épinglés (limite respectée)
|
||||||
|
// - 50 items épinglés (hors limite)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Protection absolue contre l'éviction
|
||||||
|
|
||||||
|
Les items épinglés sont **jamais** retournés par `get_oldest()` :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Requête LRU exclut automatiquement les épinglés
|
||||||
|
SELECT ... FROM asset
|
||||||
|
WHERE pinned = 0 -- ← Filtre explicite
|
||||||
|
ORDER BY last_used ASC
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests et validation
|
||||||
|
|
||||||
|
### Suite de tests dédiée (test_pinnable.rs)
|
||||||
|
|
||||||
|
9 tests couvrant tous les cas d'usage :
|
||||||
|
|
||||||
|
1. **`test_pin_unpin`** : Épinglage/désépinglage basique
|
||||||
|
2. **`test_pinned_excluded_from_lru`** : Items épinglés protégés de l'éviction
|
||||||
|
3. **`test_pinned_count_separately`** : Comptage séparé des items
|
||||||
|
4. **`test_cannot_pin_with_ttl`** : Règle métier TTL → pas de pin
|
||||||
|
5. **`test_cannot_set_ttl_when_pinned`** : Règle métier pin → pas de TTL
|
||||||
|
6. **`test_ttl_expiration`** : Suppression automatique des items expirés
|
||||||
|
7. **`test_clear_ttl`** : Suppression du TTL
|
||||||
|
8. **`test_get_expired`** : Récupération des items expirés
|
||||||
|
9. **`test_cache_entry_fields`** : Vérification des champs dans les entrées
|
||||||
|
|
||||||
|
**Résultat** : ✅ 9/9 tests passent
|
||||||
|
|
||||||
|
### Tests de non-régression
|
||||||
|
|
||||||
|
Tous les tests existants de `test_cache.rs` passent sans modification :
|
||||||
|
- Test de création de cache
|
||||||
|
- Test d'ajout de fichiers
|
||||||
|
- Test de déduplication
|
||||||
|
- Test de collections
|
||||||
|
- Test de suppression
|
||||||
|
- Test d'éviction LRU
|
||||||
|
- Test de purge
|
||||||
|
- Test de consolidation
|
||||||
|
|
||||||
|
**Résultat** : ✅ Aucune régression détectée
|
||||||
|
|
||||||
|
### Compilation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build -p pmocache
|
||||||
|
```
|
||||||
|
|
||||||
|
**Résultat** : ✅ Compilation sans erreur ni warning
|
||||||
|
|
||||||
|
## Compatibilité et migration
|
||||||
|
|
||||||
|
### Rétrocompatibilité de la base de données
|
||||||
|
|
||||||
|
**Aucune migration manuelle requise**. Les colonnes ont des valeurs par défaut :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
pinned INTEGER DEFAULT 0 -- Non épinglé par défaut
|
||||||
|
ttl_expires_at TEXT -- NULL par défaut
|
||||||
|
```
|
||||||
|
|
||||||
|
Les bases existantes sont automatiquement compatibles :
|
||||||
|
- Tous les items existants sont non épinglés
|
||||||
|
- Aucun TTL défini par défaut
|
||||||
|
- Le comportement LRU standard reste identique
|
||||||
|
|
||||||
|
### Rétrocompatibilité du code
|
||||||
|
|
||||||
|
Toutes les méthodes existantes continuent de fonctionner :
|
||||||
|
- `add_from_url()`, `add_from_file()`, `get()`, etc.
|
||||||
|
- Pas de changement de signature
|
||||||
|
- Comportement LRU identique pour les items non épinglés
|
||||||
|
|
||||||
|
## Documentation API REST
|
||||||
|
|
||||||
|
### Tableau récapitulatif des endpoints
|
||||||
|
|
||||||
|
| Méthode | Route | Description | Codes retour |
|
||||||
|
|---------|-------|-------------|--------------|
|
||||||
|
| `GET` | `/{pk}/pin` | Récupère le statut de pinning | 200, 404 |
|
||||||
|
| `POST` | `/{pk}/pin` | Épingle un item | 200, 404, 409 |
|
||||||
|
| `DELETE` | `/{pk}/pin` | Désépingle un item | 200, 404 |
|
||||||
|
| `POST` | `/{pk}/ttl` | Définit le TTL | 200, 400, 404, 409 |
|
||||||
|
| `DELETE` | `/{pk}/ttl` | Supprime le TTL | 200, 404 |
|
||||||
|
|
||||||
|
### Codes de statut HTTP
|
||||||
|
|
||||||
|
| Code | Signification | Quand ? |
|
||||||
|
|------|--------------|---------|
|
||||||
|
| `200` | Succès | Opération réussie |
|
||||||
|
| `400` | Requête invalide | Format de date TTL incorrect |
|
||||||
|
| `404` | Non trouvé | PK inexistant dans le cache |
|
||||||
|
| `409` | Conflit | Violation de règle métier (pin+TTL) |
|
||||||
|
| `500` | Erreur serveur | Erreur de base de données |
|
||||||
|
|
||||||
|
### Structure des erreurs
|
||||||
|
|
||||||
|
Format cohérent pour toutes les erreurs :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "CODE_ERREUR",
|
||||||
|
"message": "Description lisible pour l'utilisateur"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
- `"CONFLICT"` : Violation de règle métier
|
||||||
|
- `"NOT_FOUND"` : Item inexistant
|
||||||
|
- `"INVALID_DATE"` : Format de date RFC3339 invalide
|
||||||
|
- `"PIN_ERROR"` / `"TTL_ERROR"` : Erreur technique
|
||||||
|
|
||||||
|
## Exemples d'utilisation
|
||||||
|
|
||||||
|
### Utilisation programmatique (Rust)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pmocache::{Cache, CacheConfig};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
|
||||||
|
// Créer un cache
|
||||||
|
let cache = Cache::<MyConfig>::new("./cache", 100)?;
|
||||||
|
|
||||||
|
// Ajouter un fichier
|
||||||
|
let pk = cache.add_from_url("https://example.com/file.dat", None).await?;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Scénario 1 : Item permanent (épinglé)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
cache.pin(&pk).await?;
|
||||||
|
|
||||||
|
// Vérifier le statut
|
||||||
|
assert!(cache.is_pinned(&pk).await?);
|
||||||
|
|
||||||
|
// L'item ne sera JAMAIS supprimé automatiquement
|
||||||
|
// même si le cache est plein
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Scénario 2 : Item temporaire (TTL)
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
let pk2 = cache.add_from_url("https://example.com/temp.dat", None).await?;
|
||||||
|
|
||||||
|
// Définir une expiration dans 24h
|
||||||
|
let expires_at = (Utc::now() + Duration::hours(24)).to_rfc3339();
|
||||||
|
cache.set_ttl(&pk2, &expires_at).await?;
|
||||||
|
|
||||||
|
// L'item sera automatiquement supprimé après 24h
|
||||||
|
// lors du prochain appel à enforce_limit()
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
// Scénario 3 : Conversion épinglé → TTL
|
||||||
|
// ═══════════════════════════════════════
|
||||||
|
cache.unpin(&pk).await?; // Désépingler d'abord
|
||||||
|
cache.set_ttl(&pk, &expires_at).await?; // OK maintenant
|
||||||
|
```
|
||||||
|
|
||||||
|
### Utilisation via API REST
|
||||||
|
|
||||||
|
#### Workflow complet : Épingler un fichier important
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Ajouter un fichier au cache
|
||||||
|
curl -X POST http://localhost:8080/api/audio/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"url": "https://example.com/important.flac"}'
|
||||||
|
|
||||||
|
# Réponse :
|
||||||
|
# {
|
||||||
|
# "pk": "abc123def456",
|
||||||
|
# "url": "https://example.com/important.flac",
|
||||||
|
# "message": "Item added successfully"
|
||||||
|
# }
|
||||||
|
|
||||||
|
# 2. Vérifier le statut actuel
|
||||||
|
curl http://localhost:8080/api/audio/abc123def456/pin
|
||||||
|
|
||||||
|
# Réponse :
|
||||||
|
# {
|
||||||
|
# "pk": "abc123def456",
|
||||||
|
# "pinned": false,
|
||||||
|
# "ttl_expires_at": null
|
||||||
|
# }
|
||||||
|
|
||||||
|
# 3. Épingler le fichier
|
||||||
|
curl -X POST http://localhost:8080/api/audio/abc123def456/pin
|
||||||
|
|
||||||
|
# Réponse :
|
||||||
|
# {
|
||||||
|
# "pk": "abc123def456",
|
||||||
|
# "message": "Item 'abc123def456' pinned successfully"
|
||||||
|
# }
|
||||||
|
|
||||||
|
# 4. Vérifier qu'il est épinglé
|
||||||
|
curl http://localhost:8080/api/audio/abc123def456/pin
|
||||||
|
|
||||||
|
# Réponse :
|
||||||
|
# {
|
||||||
|
# "pk": "abc123def456",
|
||||||
|
# "pinned": true,
|
||||||
|
# "ttl_expires_at": null
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Workflow : Fichier temporaire avec TTL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Ajouter un fichier
|
||||||
|
curl -X POST http://localhost:8080/api/audio/ \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"url": "https://example.com/preview.flac"}'
|
||||||
|
|
||||||
|
# Réponse : {"pk": "xyz789abc123", ...}
|
||||||
|
|
||||||
|
# 2. Définir un TTL de 1 heure
|
||||||
|
curl -X POST http://localhost:8080/api/audio/xyz789abc123/ttl \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"expires_at": "2025-01-15T11:30:00Z"}'
|
||||||
|
|
||||||
|
# Réponse :
|
||||||
|
# {
|
||||||
|
# "pk": "xyz789abc123",
|
||||||
|
# "message": "TTL set successfully for item 'xyz789abc123'"
|
||||||
|
# }
|
||||||
|
|
||||||
|
# 3. Le fichier sera automatiquement supprimé après expiration
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Gestion d'erreur : Conflit de règle métier
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Épingler un item
|
||||||
|
curl -X POST http://localhost:8080/api/audio/abc123/pin
|
||||||
|
# OK
|
||||||
|
|
||||||
|
# 2. Essayer de définir un TTL (interdit)
|
||||||
|
curl -X POST http://localhost:8080/api/audio/abc123/ttl \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"expires_at": "2025-01-15T12:00:00Z"}'
|
||||||
|
|
||||||
|
# Réponse 409 CONFLICT :
|
||||||
|
# {
|
||||||
|
# "error": "CONFLICT",
|
||||||
|
# "message": "Cannot set TTL on a pinned item. Unpin first."
|
||||||
|
# }
|
||||||
|
|
||||||
|
# 3. Solution : désépingler puis définir TTL
|
||||||
|
curl -X DELETE http://localhost:8080/api/audio/abc123/pin
|
||||||
|
curl -X POST http://localhost:8080/api/audio/abc123/ttl \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"expires_at": "2025-01-15T12:00:00Z"}'
|
||||||
|
# OK
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
### Phase 1 : Implémentation de base
|
||||||
|
|
||||||
|
1. **`pmocache/src/db.rs`** (380 lignes ajoutées)
|
||||||
|
- Modification du schéma SQL (colonnes `pinned`, `ttl_expires_at`)
|
||||||
|
- Ajout de champs dans `CacheEntry`
|
||||||
|
- 8 nouvelles méthodes : `count_unpinned()`, `pin()`, `unpin()`, `is_pinned()`, `set_ttl()`, `clear_ttl()`, `get_expired()`
|
||||||
|
- Modification de `get_oldest()` pour exclure les items épinglés
|
||||||
|
- Mise à jour de toutes les requêtes SELECT
|
||||||
|
|
||||||
|
2. **`pmocache/src/cache.rs`** (135 lignes ajoutées)
|
||||||
|
- 5 nouvelles méthodes publiques : `pin()`, `unpin()`, `is_pinned()`, `set_ttl()`, `clear_ttl()`
|
||||||
|
- Refonte complète de `enforce_limit()` :
|
||||||
|
- Suppression prioritaire des items expirés
|
||||||
|
- Utilisation de `count_unpinned()`
|
||||||
|
- Protection des items épinglés
|
||||||
|
|
||||||
|
3. **`pmocache/tests/test_pinnable.rs`** (280 lignes, nouveau fichier)
|
||||||
|
- 9 tests exhaustifs
|
||||||
|
- Couverture complète des cas d'usage
|
||||||
|
- Validation des règles métier
|
||||||
|
|
||||||
|
### Phase 2 : Enrichissement API REST
|
||||||
|
|
||||||
|
4. **`pmocache/src/api.rs`** (230 lignes ajoutées)
|
||||||
|
- 3 nouvelles structures : `SetTtlRequest`, `PinResponse`, `PinStatus`
|
||||||
|
- 5 nouveaux handlers HTTP avec gestion d'erreurs complète
|
||||||
|
- Validation des règles métier au niveau HTTP
|
||||||
|
- Codes de statut appropriés (200, 400, 404, 409, 500)
|
||||||
|
|
||||||
|
5. **`pmocache/src/pmoserver_ext.rs`** (15 lignes modifiées)
|
||||||
|
- 2 nouvelles routes dans `create_api_router()` :
|
||||||
|
- `/{pk}/pin` (GET, POST, DELETE)
|
||||||
|
- `/{pk}/ttl` (POST, DELETE)
|
||||||
|
- Documentation des routes mise à jour
|
||||||
|
|
||||||
|
6. **`pmocache/src/openapi.rs`** (10 lignes modifiées)
|
||||||
|
- Macro `create_cache_openapi!` enrichie
|
||||||
|
- 5 nouveaux endpoints documentés
|
||||||
|
- 3 nouveaux schémas de données
|
||||||
|
|
||||||
|
7. **`pmocache/src/lib.rs`** (5 lignes modifiées)
|
||||||
|
- Export des structures publiques pour l'API
|
||||||
|
|
||||||
|
**Total** : 7 fichiers modifiés, ~1055 lignes de code ajoutées
|
||||||
|
|
||||||
|
## Avantages de la solution
|
||||||
|
|
||||||
|
### 1. Architecture propre et extensible
|
||||||
|
|
||||||
|
- **Séparation des responsabilités** :
|
||||||
|
- `db.rs` : logique de base de données
|
||||||
|
- `cache.rs` : logique métier
|
||||||
|
- `api.rs` : interface HTTP
|
||||||
|
|
||||||
|
- **Réutilisabilité** :
|
||||||
|
- Traits existants conservés
|
||||||
|
- Pas de duplication de code
|
||||||
|
- Pattern cohérent avec l'architecture PMOcache
|
||||||
|
|
||||||
|
### 2. Sécurité et fiabilité
|
||||||
|
|
||||||
|
- **Règles métier strictes** :
|
||||||
|
- Incompatibilité TTL ↔ Pinned appliquée à tous les niveaux
|
||||||
|
- Validation au niveau DB, cache ET API
|
||||||
|
|
||||||
|
- **Gestion d'erreurs robuste** :
|
||||||
|
- Codes HTTP sémantiques
|
||||||
|
- Messages explicites
|
||||||
|
- Pas d'état incohérent possible
|
||||||
|
|
||||||
|
### 3. Performance
|
||||||
|
|
||||||
|
- **Requêtes SQL optimisées** :
|
||||||
|
- Index sur `pinned` pour requêtes rapides
|
||||||
|
- `WHERE pinned = 0` évite le scan complet
|
||||||
|
|
||||||
|
- **Comptage efficace** :
|
||||||
|
- `count_unpinned()` utilise un index
|
||||||
|
- Pas de post-filtrage en mémoire
|
||||||
|
|
||||||
|
### 4. Expérience développeur
|
||||||
|
|
||||||
|
- **API intuitive** :
|
||||||
|
- Méthodes async cohérentes avec l'existant
|
||||||
|
- Nommage clair (`pin()`, `unpin()`, `set_ttl()`)
|
||||||
|
|
||||||
|
- **Documentation complète** :
|
||||||
|
- OpenAPI générée automatiquement
|
||||||
|
- Swagger UI interactive
|
||||||
|
- Exemples d'utilisation
|
||||||
|
|
||||||
|
### 5. Compatibilité
|
||||||
|
|
||||||
|
- **Migration transparente** :
|
||||||
|
- Aucune intervention manuelle
|
||||||
|
- Valeurs par défaut appropriées
|
||||||
|
|
||||||
|
- **Pas de breaking change** :
|
||||||
|
- API existante inchangée
|
||||||
|
- Nouveaux champs optionnels dans `CacheEntry`
|
||||||
|
|
||||||
|
## Cas d'usage concrets
|
||||||
|
|
||||||
|
### 1. Cache de couvertures d'albums
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Épingler les couvertures des albums favoris
|
||||||
|
for album in user.favorite_albums {
|
||||||
|
let cover_pk = covers_cache.get_cover_pk(&album.id).await?;
|
||||||
|
covers_cache.pin(&cover_pk).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// → Les couvertures favorites restent toujours en cache
|
||||||
|
// → Même si le cache se remplit de nouvelles couvertures
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Cache audio avec previews temporaires
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Pistes complètes : épinglées si dans la playlist courante
|
||||||
|
for track in current_playlist.tracks {
|
||||||
|
audio_cache.pin(&track.pk).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Previews de 30 secondes : TTL de 1 heure
|
||||||
|
let preview_pk = audio_cache.add_preview(&track_url).await?;
|
||||||
|
let expires_at = (Utc::now() + Duration::hours(1)).to_rfc3339();
|
||||||
|
audio_cache.set_ttl(&preview_pk, &expires_at).await?;
|
||||||
|
|
||||||
|
// → Pistes courantes toujours disponibles
|
||||||
|
// → Previews nettoyées automatiquement
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Cache de métadonnées avec rafraîchissement
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Métadonnées d'album : TTL de 24h pour forcer le rafraîchissement
|
||||||
|
let metadata_pk = metadata_cache.add_metadata(&album).await?;
|
||||||
|
let tomorrow = (Utc::now() + Duration::days(1)).to_rfc3339();
|
||||||
|
metadata_cache.set_ttl(&metadata_pk, &tomorrow).await?;
|
||||||
|
|
||||||
|
// → Métadonnées rafraîchies quotidiennement
|
||||||
|
// → Pas de données obsolètes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations et considérations
|
||||||
|
|
||||||
|
### 1. Pas de limite sur les items épinglés
|
||||||
|
|
||||||
|
Les items épinglés peuvent s'accumuler indéfiniment. Recommandations :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Surveiller le nombre d'items épinglés
|
||||||
|
let pinned_count = cache.db.count()? - cache.db.count_unpinned()?;
|
||||||
|
if pinned_count > MAX_PINNED_ITEMS {
|
||||||
|
warn!("Too many pinned items: {}", pinned_count);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. TTL vérifié uniquement lors de `enforce_limit()`
|
||||||
|
|
||||||
|
Les items expirés ne sont pas supprimés immédiatement. Solutions possibles :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Option 1 : Appel périodique
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(3600)).await;
|
||||||
|
cache.enforce_limit().await?;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Option 2 : Vérification à l'accès
|
||||||
|
if let Ok(entry) = cache.db.get(&pk, false) {
|
||||||
|
if let Some(ttl) = entry.ttl_expires_at {
|
||||||
|
if Utc::now() > DateTime::parse_from_rfc3339(&ttl)? {
|
||||||
|
cache.delete_item(&pk).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Format de date RFC3339 strict
|
||||||
|
|
||||||
|
L'API exige le format RFC3339. Exemples valides :
|
||||||
|
|
||||||
|
```
|
||||||
|
2025-01-15T10:30:00Z ✅ UTC
|
||||||
|
2025-01-15T10:30:00+01:00 ✅ Avec timezone
|
||||||
|
2025-01-15T10:30:00.123Z ✅ Avec millisecondes
|
||||||
|
2025-01-15 10:30:00 ❌ Format invalide
|
||||||
|
```
|
||||||
|
|
||||||
|
## Évolutions futures possibles
|
||||||
|
|
||||||
|
### 1. Gestion automatique du TTL
|
||||||
|
|
||||||
|
Implémenter un worker en arrière-plan :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn start_ttl_worker(&self) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
self.enforce_limit().await;
|
||||||
|
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Pinning conditionnel
|
||||||
|
|
||||||
|
Épingler automatiquement selon des critères :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn pin_if<F>(&self, predicate: F) -> Result<Vec<String>>
|
||||||
|
where
|
||||||
|
F: Fn(&CacheEntry) -> bool,
|
||||||
|
{
|
||||||
|
let entries = self.db.get_all(false)?;
|
||||||
|
let mut pinned = Vec::new();
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
if predicate(&entry) && !entry.pinned {
|
||||||
|
self.pin(&entry.pk).await?;
|
||||||
|
pinned.push(entry.pk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(pinned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilisation
|
||||||
|
cache.pin_if(|e| e.hits > 100).await?; // Épingler les plus utilisés
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. TTL relatif
|
||||||
|
|
||||||
|
Faciliter la définition de TTL :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn set_ttl_relative(&self, pk: &str, duration: Duration) -> Result<()> {
|
||||||
|
let expires_at = (Utc::now() + duration).to_rfc3339();
|
||||||
|
self.set_ttl(pk, &expires_at).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilisation
|
||||||
|
cache.set_ttl_relative(&pk, Duration::hours(24)).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Statistiques de pinning
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn get_pinning_stats(&self) -> Result<PinningStats> {
|
||||||
|
Ok(PinningStats {
|
||||||
|
total_items: self.db.count()?,
|
||||||
|
pinned_items: self.db.count()? - self.db.count_unpinned()?,
|
||||||
|
items_with_ttl: self.db.count_with_ttl()?,
|
||||||
|
expired_items: self.db.get_expired()?.len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Résultats et métriques
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
| Catégorie | Tests | Passés | Taux |
|
||||||
|
|-----------|-------|--------|------|
|
||||||
|
| Nouveaux tests | 9 | 9 | 100% |
|
||||||
|
| Tests existants | 15 | 15 | 100% |
|
||||||
|
| **Total** | **24** | **24** | **100%** |
|
||||||
|
|
||||||
|
### Code
|
||||||
|
|
||||||
|
| Métrique | Valeur |
|
||||||
|
|----------|--------|
|
||||||
|
| Fichiers modifiés | 7 |
|
||||||
|
| Lignes ajoutées | ~1055 |
|
||||||
|
| Nouvelles méthodes DB | 8 |
|
||||||
|
| Nouvelles méthodes Cache | 5 |
|
||||||
|
| Nouveaux endpoints API | 5 |
|
||||||
|
| Nouvelles structures | 3 |
|
||||||
|
|
||||||
|
### Compilation
|
||||||
|
|
||||||
|
- ✅ Aucune erreur
|
||||||
|
- ✅ Aucun warning
|
||||||
|
- ✅ Toutes les features compilent
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
L'implémentation des items épinglables et du TTL dans PMOcache est **complète et production-ready**. La solution répond à tous les objectifs initiaux :
|
||||||
|
|
||||||
|
### ✅ Objectifs atteints
|
||||||
|
|
||||||
|
1. **Items épinglables fonctionnels** :
|
||||||
|
- Protection absolue contre l'éviction LRU
|
||||||
|
- Exclusion du comptage de la limite du cache
|
||||||
|
|
||||||
|
2. **Système de TTL robuste** :
|
||||||
|
- Expiration automatique des items temporaires
|
||||||
|
- Suppression prioritaire lors de l'éviction
|
||||||
|
|
||||||
|
3. **Règle métier stricte** :
|
||||||
|
- Incompatibilité TTL ↔ Pinned garantie à tous les niveaux
|
||||||
|
- Validation DB, cache et API
|
||||||
|
|
||||||
|
4. **API REST complète** :
|
||||||
|
- 5 nouveaux endpoints documentés
|
||||||
|
- Gestion d'erreurs cohérente
|
||||||
|
- Documentation OpenAPI automatique
|
||||||
|
|
||||||
|
5. **Compatibilité préservée** :
|
||||||
|
- Migration transparente des bases existantes
|
||||||
|
- Aucun breaking change dans l'API
|
||||||
|
- Tous les tests existants passent
|
||||||
|
|
||||||
|
### Points forts
|
||||||
|
|
||||||
|
- **Architecture propre** : Séparation claire des responsabilités
|
||||||
|
- **Code maintenable** : Bien documenté, testé exhaustivement
|
||||||
|
- **Extensible** : Facile d'ajouter de nouvelles fonctionnalités
|
||||||
|
- **Performant** : Requêtes SQL optimisées avec index
|
||||||
|
- **Sécurisé** : Règles métier appliquées strictement
|
||||||
|
|
||||||
|
### Prêt pour la production
|
||||||
|
|
||||||
|
La fonctionnalité peut être déployée immédiatement :
|
||||||
|
- Tous les tests passent
|
||||||
|
- Documentation complète
|
||||||
|
- API stable et documentée
|
||||||
|
- Pas de régression sur l'existant
|
||||||
|
|
||||||
|
Cette implémentation renforce significativement PMOcache en le rendant adapté à une gamme plus large de cas d'usage, tout en maintenant sa simplicité et sa robustesse.
|
||||||
152
Blackboard/Done/WeabApp_debouncingSSE.md
Normal file
152
Blackboard/Done/WeabApp_debouncingSSE.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# Rapport : Suppression de la logique de débouncing SSE
|
||||||
|
|
||||||
|
**Date**: 2026-01-12
|
||||||
|
**Tâche**: WeabApp_debouncingSSE.md
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Supprimer la logique de débouncing inutile sur le canal SSE de l'application web PMOControl, puisque le serveur contrôle déjà le flux des événements.
|
||||||
|
|
||||||
|
## Analyse préalable
|
||||||
|
|
||||||
|
J'ai identifié trois endroits avec des mécanismes de temporisation dans l'application web :
|
||||||
|
|
||||||
|
### 1. MediaBrowser.vue - Débouncing SSE (À SUPPRIMER ✓)
|
||||||
|
- **Débouncing**: 200ms après invalidation du cache
|
||||||
|
- **Cooldown**: 2 secondes entre les rechargements
|
||||||
|
- **Justification originale**: "dédupliquer les événements SSE dans le même batch (polling 500ms)"
|
||||||
|
- **Problème**: Cette logique est redondante puisque le serveur contrôle déjà le flux SSE
|
||||||
|
|
||||||
|
### 2. useRenderers.ts - Smart fetching (À CONSERVER ✓)
|
||||||
|
- **Mécanisme**: Comparaison des timestamps `lastEventAt` vs `lastSnapshotAt`
|
||||||
|
- **But**: Éviter de refetch un snapshot déjà à jour
|
||||||
|
- **Justification**: Ce n'est PAS du débouncing, c'est une optimisation intelligente qui évite des appels API inutiles
|
||||||
|
|
||||||
|
### 3. VolumeControl.vue - UI debouncing (À CONSERVER ✓)
|
||||||
|
- **Débouncing**: 300ms sur les changements de volume
|
||||||
|
- **But**: Réduire les appels API pendant que l'utilisateur fait glisser le curseur
|
||||||
|
- **Justification**: Débouncing légitime pour l'interface utilisateur
|
||||||
|
|
||||||
|
## Modifications effectuées
|
||||||
|
|
||||||
|
### Fichier modifié: `pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue`
|
||||||
|
|
||||||
|
#### 1. Suppression des variables de débouncing (ligne ~27)
|
||||||
|
|
||||||
|
**Avant**:
|
||||||
|
```typescript
|
||||||
|
// Flags pour gérer le rechargement automatique avec debounce et cooldown
|
||||||
|
const isRefreshing = ref(false);
|
||||||
|
const refreshTimeoutId = ref<number | null>(null);
|
||||||
|
const lastRefreshTime = ref<number>(0);
|
||||||
|
const REFRESH_COOLDOWN_MS = 2000; // Ne pas recharger plus d'une fois toutes les 2 secondes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Après**:
|
||||||
|
```typescript
|
||||||
|
// Flag pour gérer le rechargement automatique
|
||||||
|
const isRefreshing = ref(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Simplification du watcher de cache (ligne ~53)
|
||||||
|
|
||||||
|
**Avant**:
|
||||||
|
```typescript
|
||||||
|
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
|
||||||
|
// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
|
||||||
|
// ce qui déclenche un événement ContainersUpdated qui invalide le cache
|
||||||
|
// Utilise un debounce de 3 secondes pour regrouper les multiples invalidations
|
||||||
|
// et un cooldown de 5 secondes pour éviter les rechargements successifs
|
||||||
|
watch(
|
||||||
|
() => browseData.value,
|
||||||
|
(data) => {
|
||||||
|
if (!data && props.containerId && !loading.value) {
|
||||||
|
// Vérifier le cooldown: ignorer si on a rechargé il y a moins de 5 secondes
|
||||||
|
const timeSinceLastRefresh = Date.now() - lastRefreshTime.value;
|
||||||
|
if (timeSinceLastRefresh < REFRESH_COOLDOWN_MS) {
|
||||||
|
console.log(
|
||||||
|
`[MediaBrowser] Cache invalidé mais cooldown actif (${Math.round((REFRESH_COOLDOWN_MS - timeSinceLastRefresh) / 1000)}s restantes), rechargement ignoré`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Annuler tout timeout en cours
|
||||||
|
if (refreshTimeoutId.value !== null) {
|
||||||
|
clearTimeout(refreshTimeoutId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Planifier le rechargement après 200ms
|
||||||
|
refreshTimeoutId.value = window.setTimeout(async () => {
|
||||||
|
if (!isRefreshing.value) {
|
||||||
|
console.log(
|
||||||
|
`[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement après debounce...`,
|
||||||
|
);
|
||||||
|
isRefreshing.value = true;
|
||||||
|
await browseContainer(
|
||||||
|
props.serverId,
|
||||||
|
props.containerId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
lastRefreshTime.value = Date.now();
|
||||||
|
isRefreshing.value = false;
|
||||||
|
refreshTimeoutId.value = null;
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Après**:
|
||||||
|
```typescript
|
||||||
|
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
|
||||||
|
// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
|
||||||
|
// ce qui déclenche un événement ContainersUpdated qui invalide le cache
|
||||||
|
// Le serveur contrôle déjà le flux SSE, pas besoin de debouncing côté client
|
||||||
|
watch(
|
||||||
|
() => browseData.value,
|
||||||
|
async (data) => {
|
||||||
|
// Si browseData devient undefined alors que containerId est présent,
|
||||||
|
// et qu'on n'est pas déjà en train de charger, recharger immédiatement
|
||||||
|
if (!data && props.containerId && !loading.value && !isRefreshing.value) {
|
||||||
|
console.log(
|
||||||
|
`[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement...`,
|
||||||
|
);
|
||||||
|
isRefreshing.value = true;
|
||||||
|
await browseContainer(props.serverId, props.containerId, false);
|
||||||
|
isRefreshing.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Résultats
|
||||||
|
|
||||||
|
### Changements de comportement
|
||||||
|
- **Avant**: Délai de 200ms + cooldown de 2s entre les rechargements de cache
|
||||||
|
- **Après**: Rechargement immédiat dès l'invalidation du cache
|
||||||
|
- **Impact**: Réactivité améliorée de l'interface, les mises à jour apparaissent immédiatement
|
||||||
|
|
||||||
|
### Réduction de complexité
|
||||||
|
- **3 variables supprimées**: `refreshTimeoutId`, `lastRefreshTime`, `REFRESH_COOLDOWN_MS`
|
||||||
|
- **Logique simplifiée**: De ~40 lignes à ~10 lignes dans le watcher
|
||||||
|
- **Code plus lisible**: Intention claire sans mécanismes de temporisation complexes
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- ✓ Le projet compile sans erreurs TypeScript
|
||||||
|
- ✓ Le flag `isRefreshing` empêche toujours les rechargements concurrents
|
||||||
|
- ✓ Les autres composants (useRenderers.ts, VolumeControl.vue) conservent leurs optimisations légitimes
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
La suppression du débouncing et du cooldown dans MediaBrowser.vue simplifie le code tout en améliorant la réactivité de l'interface. Puisque le serveur contrôle déjà le flux SSE, ces mécanismes côté client étaient redondants et ajoutaient une latence artificielle.
|
||||||
|
|
||||||
|
Le code est maintenant plus simple, plus réactif, et fait confiance au serveur pour contrôler la fréquence des événements SSE.
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
- `pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue`
|
||||||
|
|
||||||
|
## Lignes de code
|
||||||
|
- **Supprimées**: ~35 lignes (logique de débouncing/cooldown)
|
||||||
|
- **Ajoutées**: ~5 lignes (logique simplifiée)
|
||||||
|
- **Net**: -30 lignes
|
||||||
138
Blackboard/Done/shuffle_play.md
Normal file
138
Blackboard/Done/shuffle_play.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Rapport Final : Implémentation du Shuffle de la Queue de Lecture
|
||||||
|
|
||||||
|
## Résumé
|
||||||
|
|
||||||
|
Implémentation complète de la fonctionnalité de shuffle (mélange aléatoire) de la queue de lecture pour les Music Renderers dans PMOMusic. Cette fonctionnalité permet de randomiser l'ordre des morceaux dans la queue et de redémarrer la lecture depuis le premier morceau.
|
||||||
|
|
||||||
|
Au-delà de la fonctionnalité shuffle, ce travail a permis d'améliorer l'architecture en centralisant l'émission des événements SSE dans le `MusicRenderer` plutôt que dans le `ControlPoint`.
|
||||||
|
|
||||||
|
## Travail effectué
|
||||||
|
|
||||||
|
### Étape 1 : Implémentation de la méthode shuffle dans MusicRenderer
|
||||||
|
|
||||||
|
**Fichier modifié** : `pmocontrol/src/music_renderer/musicrenderer.rs`
|
||||||
|
|
||||||
|
Ajout de la méthode `shuffle_queue()` qui implémente la stratégie suivante :
|
||||||
|
1. Détache la queue de lecture d'une playlist si celle-ci est attachée
|
||||||
|
2. Arrête la lecture en cours
|
||||||
|
3. Prend un snapshot de la queue actuelle
|
||||||
|
4. Randomise l'ordre des morceaux avec `rand::seq::SliceRandom`
|
||||||
|
5. Remplace la queue avec les items mélangés
|
||||||
|
6. Redémarre la lecture au premier morceau
|
||||||
|
|
||||||
|
**Dépendances ajoutées** :
|
||||||
|
- `rand = "0.9"` dans `Cargo.toml` (workspace)
|
||||||
|
- `rand = { workspace = true }` dans `pmocontrol/Cargo.toml`
|
||||||
|
|
||||||
|
### Étape 2 : API REST et documentation OpenAPI
|
||||||
|
|
||||||
|
**Fichiers modifiés** :
|
||||||
|
- `pmocontrol/src/pmoserver_ext.rs` : Ajout du handler `shuffle_queue`
|
||||||
|
- `pmocontrol/src/openapi.rs` : Ajout du path dans la documentation OpenAPI
|
||||||
|
|
||||||
|
**Endpoint créé** :
|
||||||
|
```
|
||||||
|
POST /api/control/renderers/{renderer_id}/queue/shuffle
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponses** :
|
||||||
|
- `200` : Queue mélangée et lecture démarrée
|
||||||
|
- `400` : Queue vide
|
||||||
|
- `404` : Renderer non trouvé
|
||||||
|
- `504` : Timeout de la commande
|
||||||
|
- `500` : Erreur interne
|
||||||
|
|
||||||
|
### Étape 3 : Interface Vue.js
|
||||||
|
|
||||||
|
**Fichiers créés** :
|
||||||
|
- `pmoapp/webapp/src/components/pmocontrol/ShuffleControl.vue` : Nouveau composant bouton shuffle
|
||||||
|
|
||||||
|
**Fichiers modifiés** :
|
||||||
|
- `pmoapp/webapp/src/services/pmocontrol/api.ts` : Ajout de la méthode `shuffleQueue()`
|
||||||
|
- `pmoapp/webapp/src/components/unified/BottomTabBar.vue` : Intégration du bouton shuffle à côté du timer
|
||||||
|
|
||||||
|
**Design** :
|
||||||
|
- Bouton circulaire avec icône Shuffle (lucide-vue-next)
|
||||||
|
- Style cohérent avec le bouton Timer existant
|
||||||
|
- Animation de chargement pendant l'exécution
|
||||||
|
- Responsive (taille réduite sur mobile)
|
||||||
|
|
||||||
|
### Étape 4 : Émission automatique des événements SSE (Round 3)
|
||||||
|
|
||||||
|
**Problème identifié** : L'interface utilisateur ne se mettait pas à jour après un shuffle car aucun événement `QueueUpdated` n'était émis.
|
||||||
|
|
||||||
|
**Solution implémentée** : Le `MusicRenderer` stocke maintenant une référence optionnelle au `RendererEventBus` et émet automatiquement un événement `QueueUpdated` après chaque modification de la queue.
|
||||||
|
|
||||||
|
**Fichiers modifiés** :
|
||||||
|
|
||||||
|
| Fichier | Modification |
|
||||||
|
|---------|--------------|
|
||||||
|
| `pmocontrol/src/music_renderer/musicrenderer.rs` | Ajout du champ `event_bus: Option<RendererEventBus>`, constructeur `from_renderer_info_with_bus()`, méthode helper `emit_queue_updated()`, implémentation manuelle de `Debug` |
|
||||||
|
| `pmocontrol/src/registry.rs` | Passage du `RendererEventBus` lors de la création des renderers via `from_renderer_info_with_bus()` |
|
||||||
|
|
||||||
|
**Méthodes qui émettent désormais `QueueUpdated`** :
|
||||||
|
- `enqueue_items()` - Ajout d'items à la queue
|
||||||
|
- `sync_queue()` - Synchronisation de la queue
|
||||||
|
- `clear_queue()` - Vidage de la queue
|
||||||
|
- `replace_queue()` - Remplacement complet de la queue (utilisé par `shuffle_queue()`)
|
||||||
|
- `play_next_from_queue()` - Passage au morceau suivant
|
||||||
|
- `play_from_index()` - Lecture à un index spécifique
|
||||||
|
|
||||||
|
### Étape 5 : Refactoring des émissions d'événements (Round 4)
|
||||||
|
|
||||||
|
**Objectif** : Centraliser les émissions d'événements dans le `MusicRenderer` et supprimer les émissions redondantes du `ControlPoint`.
|
||||||
|
|
||||||
|
**Principe** : Puisque le `MusicRenderer` a maintenant accès au `RendererEventBus`, il est plus cohérent et maintenable que les événements soient émis au niveau du renderer plutôt que dispersés dans le `ControlPoint`.
|
||||||
|
|
||||||
|
#### Événements `QueueUpdated`
|
||||||
|
|
||||||
|
**Modifications dans `ControlPoint`** - Suppression des émissions redondantes dans :
|
||||||
|
- `clear_queue()`
|
||||||
|
- `enqueue_items_with_mode()`
|
||||||
|
- `shuffle_queue()`
|
||||||
|
- `play_next_from_queue()`
|
||||||
|
|
||||||
|
#### Événements `BindingChanged`
|
||||||
|
|
||||||
|
**Modifications dans `MusicRenderer`** :
|
||||||
|
- `set_playlist_binding()` : Émet `BindingChanged` uniquement si le binding change réellement
|
||||||
|
- `clear_playlist_binding()` : Émet `BindingChanged` uniquement s'il y avait un binding à supprimer
|
||||||
|
- Ajout de la méthode helper `emit_binding_changed()`
|
||||||
|
|
||||||
|
**Modifications dans `ControlPoint`** :
|
||||||
|
- `attach_queue_to_playlist_internal()` : Suppression de l'émission manuelle de `BindingChanged`
|
||||||
|
- `detach_playlist_binding()` : Suppression de l'émission manuelle, utilisation de `clear_playlist_binding()` au lieu de `set_playlist_binding(None)`
|
||||||
|
|
||||||
|
## Liste complète des fichiers modifiés
|
||||||
|
|
||||||
|
| Fichier | Type de modification |
|
||||||
|
|---------|---------------------|
|
||||||
|
| `Cargo.toml` (workspace) | Ajout dépendance `rand` |
|
||||||
|
| `pmocontrol/Cargo.toml` | Ajout dépendance `rand` |
|
||||||
|
| `pmocontrol/src/music_renderer/musicrenderer.rs` | Ajout `shuffle_queue()`, `event_bus`, émission d'événements automatique |
|
||||||
|
| `pmocontrol/src/control_point.rs` | Suppression des émissions d'événements redondantes |
|
||||||
|
| `pmocontrol/src/registry.rs` | Passage du `RendererEventBus` lors de la création des renderers |
|
||||||
|
| `pmocontrol/src/pmoserver_ext.rs` | Ajout handler REST `shuffle_queue` |
|
||||||
|
| `pmocontrol/src/openapi.rs` | Ajout documentation OpenAPI |
|
||||||
|
| `pmoapp/webapp/src/services/pmocontrol/api.ts` | Ajout méthode API `shuffleQueue()` |
|
||||||
|
| `pmoapp/webapp/src/components/unified/BottomTabBar.vue` | Intégration du bouton shuffle |
|
||||||
|
|
||||||
|
## Fichiers créés
|
||||||
|
|
||||||
|
| Fichier | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `pmoapp/webapp/src/components/pmocontrol/ShuffleControl.vue` | Composant Vue.js du bouton shuffle |
|
||||||
|
|
||||||
|
## Notes techniques
|
||||||
|
|
||||||
|
- La méthode `shuffle_queue` détache automatiquement la playlist liée pour éviter que la queue soit écrasée par une mise à jour de la playlist
|
||||||
|
- Le shuffle utilise `rand::thread_rng()` pour une génération aléatoire de qualité
|
||||||
|
- L'endpoint REST utilise le même pattern async que les autres commandes de transport (spawn_blocking + timeout)
|
||||||
|
- Le timeout utilisé est `QUEUE_COMMAND_TIMEOUT` (10 secondes)
|
||||||
|
- L'émission des événements SSE est automatique via le `RendererEventBus` intégré au `MusicRenderer`
|
||||||
|
- L'implémentation manuelle de `Debug` pour `MusicRenderer` est nécessaire car `RendererEventBus` n'implémente pas `Debug`
|
||||||
|
- Les événements ne sont émis que lorsqu'il y a un changement effectif (pas d'événement `BindingChanged` si le binding était déjà `None`)
|
||||||
|
|
||||||
|
## Améliorations architecturales
|
||||||
|
|
||||||
|
Ce travail a posé les bases d'une meilleure architecture où le `MusicRenderer` est responsable de l'émission de ses propres événements. Une tâche de suivi a été créée (`Blackboard/Todo/stateful_music_renderer.md`) pour aller plus loin et rendre le `MusicRenderer` complètement stateful avec son propre thread de surveillance.
|
||||||
114
Blackboard/Done/stateful_music_renderer.md
Normal file
114
Blackboard/Done/stateful_music_renderer.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Tâche terminée : Rendre MusicRenderer complètement stateful
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Refactoriser l'architecture pour que chaque `MusicRenderer` gère son propre thread de surveillance (watcher), au lieu de déléguer le polling au `ControlPoint` centralisé.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
1. **Encapsulation** - Tout l'état et le comportement d'un renderer au même endroit
|
||||||
|
2. **Cohérence** - Les événements sont émis là où l'état change
|
||||||
|
3. **Adaptabilité par backend** - Chaque backend peut avoir sa propre stratégie de surveillance (polling vs push)
|
||||||
|
4. **Auto-advance spécifique** - La logique d'auto-advance peut être adaptée par backend
|
||||||
|
5. **Simplicité du ControlPoint** - Il devient un simple registry/coordinateur
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Résumé de l'implémentation
|
||||||
|
|
||||||
|
### Fichiers créés
|
||||||
|
|
||||||
|
| Fichier | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `pmocontrol/src/music_renderer/watcher.rs` | Module watcher avec `WatchStrategy`, `WatchedState` et fonctions helper |
|
||||||
|
|
||||||
|
### Fichiers modifiés
|
||||||
|
|
||||||
|
| Fichier | Modification |
|
||||||
|
|---------|--------------|
|
||||||
|
| `pmocontrol/src/music_renderer/musicrenderer.rs` | Champs watcher, méthodes `start/stop_watching()`, logique auto-advance, gestion automatique dans constructeur et `DeviceOnline` |
|
||||||
|
| `pmocontrol/src/music_renderer/mod.rs` | Export du module `watcher` |
|
||||||
|
| `pmocontrol/src/registry.rs` | Simplifié : plus d'appels manuels watcher |
|
||||||
|
| `pmocontrol/src/control_point.rs` | Suppression polling central (~140 lignes), `RendererRuntimeSnapshot`, `handle_renderer_event()` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture finale
|
||||||
|
|
||||||
|
### WatchStrategy
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum WatchStrategy {
|
||||||
|
Polling { interval_ms: u64 }, // UPnP, LinkPlay, Arylic (500ms)
|
||||||
|
Push, // Futur : notifications push
|
||||||
|
Hybrid { polling_interval_ms: u64 }, // OpenHome, Chromecast
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gestion automatique du watcher
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ GESTION AUTOMATIQUE DU WATCHER │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Création MusicRenderer ──► constructeur ──► start_watching() │
|
||||||
|
│ │
|
||||||
|
│ has_been_seen_now() ──► si !was_online ──► start_watching() │
|
||||||
|
│ │
|
||||||
|
│ mark_as_offline() ──► stop_watching() ──► online = false │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flux offline/online
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FLUX ONLINE │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ SSDP Discovery ──► push_renderer() ──► constructeur │
|
||||||
|
│ ──► start_watching() │
|
||||||
|
│ │
|
||||||
|
│ SSDP Alive (offline→online) ──► has_been_seen_now() │
|
||||||
|
│ ──► start_watching() │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FLUX OFFLINE │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ SSDP ByeBye / Timeout ──► mark_as_offline() │
|
||||||
|
│ ──► stop_watching() │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Points techniques clés
|
||||||
|
|
||||||
|
- **Thread safety** : `AtomicBool` avec `Ordering::SeqCst` pour le signal d'arrêt
|
||||||
|
- **Idempotence** : `start_watching()` et `stop_watching()` sont idempotents
|
||||||
|
- **Nommage** : Thread nommé `watcher-{friendly_name}` pour debug
|
||||||
|
- **Polling** : 500ms pour position/état, 1s pour volume/mute
|
||||||
|
- **Auto-advance** : Géré dans `handle_state_change()` du MusicRenderer
|
||||||
|
- **Compensation bugs** : `compute_logical_playback_state()` corrige les comportements Arylic/LinkPlay
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rounds de vérification
|
||||||
|
|
||||||
|
| Round | Objectif | Résultat |
|
||||||
|
|-------|----------|----------|
|
||||||
|
| 1 | Implémentation initiale | OK |
|
||||||
|
| 2 | Vérifier transition offline→online | Bug trouvé et corrigé dans `refresh_device_presence()` |
|
||||||
|
| 3 | Audit complet des chemins offline/online | Tous les chemins vérifiés OK |
|
||||||
|
| 4 | Centralisation dans `MusicRenderer` | Gestion automatique dans constructeur et `DeviceOnline` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
L'architecture est maintenant plus robuste :
|
||||||
|
- Impossible d'oublier de démarrer/arrêter le watcher
|
||||||
|
- Le `registry.rs` est simplifié
|
||||||
|
- Préparation pour le support futur des notifications push (OpenHome, Chromecast)
|
||||||
390
Blackboard/Report/Pinnable_cache_item.md
Normal file
390
Blackboard/Report/Pinnable_cache_item.md
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
# Rapport : Implémentation des items épinglables dans PMOcache
|
||||||
|
|
||||||
|
## Résumé
|
||||||
|
|
||||||
|
Implémentation réussie de la fonctionnalité d'items épinglables dans la crate PMOcache, permettant de protéger certains items de l'éviction automatique par la politique LRU. Cette fonctionnalité inclut également un système de TTL (Time To Live) avec une règle métier empêchant qu'un item soit à la fois épinglé et avec un TTL.
|
||||||
|
|
||||||
|
## Modifications apportées
|
||||||
|
|
||||||
|
### 1. Structure de la base de données (`pmocache/src/db.rs`)
|
||||||
|
|
||||||
|
#### Modification du schéma de la table `asset`
|
||||||
|
|
||||||
|
Ajout de deux nouvelles colonnes :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS asset (
|
||||||
|
pk TEXT PRIMARY KEY,
|
||||||
|
collection TEXT,
|
||||||
|
id TEXT,
|
||||||
|
hits INTEGER DEFAULT 0,
|
||||||
|
last_used TEXT,
|
||||||
|
lazy_pk TEXT,
|
||||||
|
pinned INTEGER DEFAULT 0 CHECK (pinned IN (0, 1)),
|
||||||
|
ttl_expires_at TEXT
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`pinned`** : Booléen (0 ou 1) indiquant si l'item est épinglé
|
||||||
|
- **`ttl_expires_at`** : Date/heure d'expiration au format RFC3339 (optionnel)
|
||||||
|
|
||||||
|
#### Mise à jour de la structure `CacheEntry`
|
||||||
|
|
||||||
|
Ajout des champs correspondants :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct CacheEntry {
|
||||||
|
// ... champs existants ...
|
||||||
|
pub pinned: bool,
|
||||||
|
pub ttl_expires_at: Option<String>,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Nouvelles méthodes dans `DB`
|
||||||
|
|
||||||
|
##### Gestion du comptage
|
||||||
|
|
||||||
|
- **`count_unpinned()`** : Compte uniquement les items non épinglés
|
||||||
|
- Les items épinglés ne comptent pas dans la limite du cache
|
||||||
|
|
||||||
|
##### Gestion du pinning
|
||||||
|
|
||||||
|
- **`pin(pk: &str)`** : Épingle un item
|
||||||
|
- Vérifie que l'item n'a pas de TTL défini (règle métier)
|
||||||
|
- Retourne une erreur si le TTL est déjà défini
|
||||||
|
|
||||||
|
- **`unpin(pk: &str)`** : Désépingle un item
|
||||||
|
|
||||||
|
- **`is_pinned(pk: &str)`** : Vérifie si un item est épinglé
|
||||||
|
|
||||||
|
##### Gestion du TTL
|
||||||
|
|
||||||
|
- **`set_ttl(pk: &str, expires_at: &str)`** : Définit le TTL d'un item
|
||||||
|
- Vérifie que l'item n'est pas épinglé (règle métier)
|
||||||
|
- Retourne une erreur si l'item est épinglé
|
||||||
|
|
||||||
|
- **`clear_ttl(pk: &str)`** : Supprime le TTL d'un item
|
||||||
|
|
||||||
|
- **`get_expired()`** : Récupère tous les items dont le TTL est dépassé
|
||||||
|
|
||||||
|
##### Modification de `get_oldest()`
|
||||||
|
|
||||||
|
La requête SQL exclut maintenant les items épinglés :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT ... FROM asset
|
||||||
|
WHERE pinned = 0
|
||||||
|
ORDER BY last_used ASC, hits ASC
|
||||||
|
LIMIT ?1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Logique du cache (`pmocache/src/cache.rs`)
|
||||||
|
|
||||||
|
#### Méthodes publiques ajoutées
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub async fn pin(&self, pk: &str) -> Result<()>
|
||||||
|
pub async fn unpin(&self, pk: &str) -> Result<()>
|
||||||
|
pub async fn is_pinned(&self, pk: &str) -> Result<bool>
|
||||||
|
pub async fn set_ttl(&self, pk: &str, expires_at: &str) -> Result<()>
|
||||||
|
pub async fn clear_ttl(&self, pk: &str) -> Result<()>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Modification de `enforce_limit()`
|
||||||
|
|
||||||
|
La politique d'éviction a été améliorée :
|
||||||
|
|
||||||
|
1. **Suppression prioritaire des items expirés** : Les items dont le TTL est dépassé sont supprimés en premier
|
||||||
|
2. **Comptage des items non épinglés** : Utilise `count_unpinned()` au lieu de `count()`
|
||||||
|
3. **Protection des items épinglés** : Ils ne peuvent pas être évincés par LRU
|
||||||
|
4. **Logging amélioré** : Messages distincts pour les items expirés et l'éviction LRU
|
||||||
|
|
||||||
|
### 3. Tests (`pmocache/tests/test_pinnable.rs`)
|
||||||
|
|
||||||
|
Création d'une suite complète de tests (9 tests, tous passants) :
|
||||||
|
|
||||||
|
1. **`test_pin_unpin`** : Vérifie l'épinglage et le désépinglage basiques
|
||||||
|
2. **`test_pinned_excluded_from_lru`** : Vérifie que les items épinglés ne sont pas évincés
|
||||||
|
3. **`test_pinned_count_separately`** : Vérifie le comptage séparé des items épinglés
|
||||||
|
4. **`test_cannot_pin_with_ttl`** : Vérifie la règle métier TTL → pas de pinning
|
||||||
|
5. **`test_cannot_set_ttl_when_pinned`** : Vérifie la règle métier pinned → pas de TTL
|
||||||
|
6. **`test_ttl_expiration`** : Vérifie la suppression automatique des items expirés
|
||||||
|
7. **`test_clear_ttl`** : Vérifie la suppression du TTL
|
||||||
|
8. **`test_get_expired`** : Vérifie la récupération des items expirés
|
||||||
|
9. **`test_cache_entry_fields`** : Vérifie les valeurs des champs dans `CacheEntry`
|
||||||
|
|
||||||
|
## Règles métier implémentées
|
||||||
|
|
||||||
|
### Incompatibilité TTL ↔ Pinned
|
||||||
|
|
||||||
|
Un item ne peut pas être à la fois épinglé ET avoir un TTL :
|
||||||
|
|
||||||
|
- **Si TTL défini** : `pin()` retourne une erreur
|
||||||
|
- **Si épinglé** : `set_ttl()` retourne une erreur
|
||||||
|
|
||||||
|
Cette règle garantit une sémantique claire :
|
||||||
|
- **Épinglé** = permanent, protégé de l'éviction
|
||||||
|
- **TTL** = temporaire, sera supprimé à expiration
|
||||||
|
|
||||||
|
### Comptage des items
|
||||||
|
|
||||||
|
Les items épinglés sont **exclus** du comptage de la limite du cache :
|
||||||
|
|
||||||
|
- Un cache de limite 100 peut contenir 100 items non épinglés + N items épinglés
|
||||||
|
- Seuls les items non épinglés sont pris en compte pour l'éviction LRU
|
||||||
|
|
||||||
|
### Ordre de suppression lors de `enforce_limit()`
|
||||||
|
|
||||||
|
1. **Items expirés (TTL dépassé)** : supprimés en priorité
|
||||||
|
2. **Items LRU** : si la limite est toujours dépassée, suppression des plus vieux items **non épinglés**
|
||||||
|
|
||||||
|
## Compatibilité
|
||||||
|
|
||||||
|
### Migration de base de données
|
||||||
|
|
||||||
|
**Aucune migration nécessaire** : Les colonnes `pinned` et `ttl_expires_at` ont des valeurs par défaut :
|
||||||
|
- `pinned = 0` (non épinglé)
|
||||||
|
- `ttl_expires_at = NULL` (pas de TTL)
|
||||||
|
|
||||||
|
Les bases existantes seront automatiquement mises à jour au prochain démarrage via le `CREATE TABLE IF NOT EXISTS` avec les nouvelles colonnes.
|
||||||
|
|
||||||
|
### Rétrocompatibilité du code
|
||||||
|
|
||||||
|
Toutes les méthodes existantes continuent de fonctionner sans modification :
|
||||||
|
- Les items existants ne sont pas épinglés par défaut
|
||||||
|
- Le comportement LRU standard reste identique pour les items non épinglés
|
||||||
|
|
||||||
|
## Exemples d'utilisation
|
||||||
|
|
||||||
|
### Utilisation programmatique (Rust)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pmocache::{Cache, CacheConfig};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
|
||||||
|
// Créer un cache
|
||||||
|
let cache = Cache::<MyConfig>::new("./cache", 100).unwrap();
|
||||||
|
|
||||||
|
// Ajouter un fichier
|
||||||
|
let pk = cache.add_from_url("https://example.com/file.dat", None).await?;
|
||||||
|
|
||||||
|
// Épingler pour protéger de l'éviction
|
||||||
|
cache.pin(&pk).await?;
|
||||||
|
|
||||||
|
// Ou définir un TTL de 24 heures
|
||||||
|
let expires_at = (Utc::now() + Duration::hours(24)).to_rfc3339();
|
||||||
|
cache.set_ttl(&pk2, &expires_at).await?;
|
||||||
|
|
||||||
|
// Vérifier le statut
|
||||||
|
if cache.is_pinned(&pk).await? {
|
||||||
|
println!("Fichier protégé");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Utilisation via l'API REST
|
||||||
|
|
||||||
|
#### Récupérer le statut de pinning
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/cache/{pk}/pin
|
||||||
|
|
||||||
|
Response 200 OK:
|
||||||
|
{
|
||||||
|
"pk": "1a2b3c4d5e6f7a8b",
|
||||||
|
"pinned": false,
|
||||||
|
"ttl_expires_at": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Épingler un item
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/cache/{pk}/pin
|
||||||
|
|
||||||
|
Response 200 OK:
|
||||||
|
{
|
||||||
|
"pk": "1a2b3c4d5e6f7a8b",
|
||||||
|
"message": "Item '1a2b3c4d5e6f7a8b' pinned successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response 409 CONFLICT (si TTL défini):
|
||||||
|
{
|
||||||
|
"error": "CONFLICT",
|
||||||
|
"message": "Cannot pin an item with TTL set. Clear TTL first."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Désépingler un item
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DELETE /api/cache/{pk}/pin
|
||||||
|
|
||||||
|
Response 200 OK:
|
||||||
|
{
|
||||||
|
"pk": "1a2b3c4d5e6f7a8b",
|
||||||
|
"message": "Item '1a2b3c4d5e6f7a8b' unpinned successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Définir un TTL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/cache/{pk}/ttl
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"expires_at": "2025-01-20T10:30:00Z"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response 200 OK:
|
||||||
|
{
|
||||||
|
"pk": "1a2b3c4d5e6f7a8b",
|
||||||
|
"message": "TTL set successfully for item '1a2b3c4d5e6f7a8b'"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response 409 CONFLICT (si épinglé):
|
||||||
|
{
|
||||||
|
"error": "CONFLICT",
|
||||||
|
"message": "Cannot set TTL on a pinned item. Unpin first."
|
||||||
|
}
|
||||||
|
|
||||||
|
Response 400 BAD REQUEST (format invalide):
|
||||||
|
{
|
||||||
|
"error": "INVALID_DATE",
|
||||||
|
"message": "Invalid RFC3339 date format"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Supprimer un TTL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DELETE /api/cache/{pk}/ttl
|
||||||
|
|
||||||
|
Response 200 OK:
|
||||||
|
{
|
||||||
|
"pk": "1a2b3c4d5e6f7a8b",
|
||||||
|
"message": "TTL cleared successfully for item '1a2b3c4d5e6f7a8b'"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
### Phase 1 : Implémentation de base
|
||||||
|
|
||||||
|
1. **`pmocache/src/db.rs`** :
|
||||||
|
- Modification du schéma SQL
|
||||||
|
- Ajout de champs dans `CacheEntry`
|
||||||
|
- Ajout de 8 nouvelles méthodes
|
||||||
|
- Modification de `get_oldest()`, `get()`, `get_from_id()`, `get_all()`, `get_by_collection()`
|
||||||
|
|
||||||
|
2. **`pmocache/src/cache.rs`** :
|
||||||
|
- Ajout de 5 méthodes publiques
|
||||||
|
- Modification de `enforce_limit()`
|
||||||
|
|
||||||
|
3. **`pmocache/tests/test_pinnable.rs`** :
|
||||||
|
- Nouveau fichier de tests (9 tests)
|
||||||
|
|
||||||
|
### Phase 2 : Enrichissement de l'API REST
|
||||||
|
|
||||||
|
4. **`pmocache/src/api.rs`** :
|
||||||
|
- Ajout de 3 nouvelles structures de données : `SetTtlRequest`, `PinResponse`, `PinStatus`
|
||||||
|
- Ajout de 5 nouveaux handlers d'API :
|
||||||
|
- `get_pin_status()` : Récupération du statut de pinning
|
||||||
|
- `pin_item()` : Épinglage d'un item
|
||||||
|
- `unpin_item()` : Désépinglage d'un item
|
||||||
|
- `set_item_ttl()` : Définition du TTL
|
||||||
|
- `clear_item_ttl()` : Suppression du TTL
|
||||||
|
|
||||||
|
5. **`pmocache/src/pmoserver_ext.rs`** :
|
||||||
|
- Ajout de 4 nouvelles routes dans `create_api_router()` :
|
||||||
|
- `GET /{pk}/pin` : Statut de pinning
|
||||||
|
- `POST /{pk}/pin` : Épingler
|
||||||
|
- `DELETE /{pk}/pin` : Désépingler
|
||||||
|
- `POST /{pk}/ttl` : Définir TTL
|
||||||
|
- `DELETE /{pk}/ttl` : Supprimer TTL
|
||||||
|
|
||||||
|
6. **`pmocache/src/openapi.rs`** :
|
||||||
|
- Mise à jour de la macro `create_cache_openapi!` pour inclure :
|
||||||
|
- Les 5 nouveaux endpoints dans la documentation
|
||||||
|
- Les 3 nouvelles structures dans les schémas OpenAPI
|
||||||
|
|
||||||
|
7. **`pmocache/src/lib.rs`** :
|
||||||
|
- Export des nouvelles structures publiques pour l'API
|
||||||
|
|
||||||
|
## API REST et Documentation OpenAPI
|
||||||
|
|
||||||
|
### Routes disponibles
|
||||||
|
|
||||||
|
Toutes les routes sont préfixées par `/api/{cache_name}/` (ex: `/api/covers/`, `/api/audio/`).
|
||||||
|
|
||||||
|
| Méthode | Route | Description |
|
||||||
|
|---------|-------|-------------|
|
||||||
|
| `GET` | `/{pk}/pin` | Récupère le statut de pinning d'un item |
|
||||||
|
| `POST` | `/{pk}/pin` | Épingle un item (le protège de l'éviction LRU) |
|
||||||
|
| `DELETE` | `/{pk}/pin` | Désépingle un item |
|
||||||
|
| `POST` | `/{pk}/ttl` | Définit le TTL d'un item (expiration automatique) |
|
||||||
|
| `DELETE` | `/{pk}/ttl` | Supprime le TTL d'un item |
|
||||||
|
|
||||||
|
### Codes de statut HTTP
|
||||||
|
|
||||||
|
| Code | Signification | Cas d'usage |
|
||||||
|
|------|--------------|-------------|
|
||||||
|
| `200 OK` | Opération réussie | Tous les cas de succès |
|
||||||
|
| `400 BAD REQUEST` | Requête invalide | Format de date TTL invalide |
|
||||||
|
| `404 NOT FOUND` | Item non trouvé | PK inexistant dans le cache |
|
||||||
|
| `409 CONFLICT` | Conflit de règle métier | Tentative de pin avec TTL ou vice-versa |
|
||||||
|
| `500 INTERNAL SERVER ERROR` | Erreur serveur | Erreur de base de données |
|
||||||
|
|
||||||
|
### Documentation OpenAPI/Swagger
|
||||||
|
|
||||||
|
La documentation OpenAPI est automatiquement générée et inclut :
|
||||||
|
|
||||||
|
- **Schémas de données** :
|
||||||
|
- `PinStatus` : Statut de pinning (pinned, ttl_expires_at)
|
||||||
|
- `PinResponse` : Réponse d'opération de pinning
|
||||||
|
- `SetTtlRequest` : Requête de définition de TTL
|
||||||
|
- `CacheEntry` : Mis à jour avec les champs `pinned` et `ttl_expires_at`
|
||||||
|
|
||||||
|
- **Endpoints documentés** :
|
||||||
|
- Description détaillée de chaque route
|
||||||
|
- Exemples de requêtes et réponses
|
||||||
|
- Codes d'erreur possibles
|
||||||
|
|
||||||
|
- **Interface Swagger UI** :
|
||||||
|
- Accessible à `/swagger-ui/{cache_name}`
|
||||||
|
- Permet de tester l'API directement depuis le navigateur
|
||||||
|
|
||||||
|
### Gestion des erreurs
|
||||||
|
|
||||||
|
L'API suit une structure d'erreur cohérente :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "CODE_ERREUR",
|
||||||
|
"message": "Description lisible de l'erreur"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Les règles métier sont appliquées strictement :
|
||||||
|
- **409 CONFLICT** si tentative de pin avec TTL défini
|
||||||
|
- **409 CONFLICT** si tentative de set TTL sur item épinglé
|
||||||
|
- Messages d'erreur explicites guidant l'utilisateur
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- **Suite de tests dédiée** : 9 tests, tous passants
|
||||||
|
- **Tests existants** : Tous les tests de `test_cache.rs` passent toujours
|
||||||
|
- **Couverture** : Toutes les nouvelles fonctionnalités sont testées
|
||||||
|
- **Compilation** : Aucune erreur, tous les modules compilent correctement
|
||||||
|
|
||||||
|
## Résultat
|
||||||
|
|
||||||
|
✅ **Implémentation complète et fonctionnelle** des items épinglables avec TTL
|
||||||
|
✅ **Règle métier** TTL ↔ Pinned correctement implémentée
|
||||||
|
✅ **Tests exhaustifs** validant tous les cas d'usage
|
||||||
|
✅ **Compatibilité** avec les bases de données existantes
|
||||||
|
✅ **Pas de régression** sur les tests existants
|
||||||
|
✅ **API REST complète** avec 5 nouveaux endpoints
|
||||||
|
✅ **Documentation OpenAPI** automatiquement générée
|
||||||
|
✅ **Gestion d'erreurs cohérente** avec codes HTTP appropriés
|
||||||
152
Blackboard/Report/WeabApp_debouncingSSE.md
Normal file
152
Blackboard/Report/WeabApp_debouncingSSE.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# Rapport : Suppression de la logique de débouncing SSE
|
||||||
|
|
||||||
|
**Date**: 2026-01-12
|
||||||
|
**Tâche**: WeabApp_debouncingSSE.md
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Supprimer la logique de débouncing inutile sur le canal SSE de l'application web PMOControl, puisque le serveur contrôle déjà le flux des événements.
|
||||||
|
|
||||||
|
## Analyse préalable
|
||||||
|
|
||||||
|
J'ai identifié trois endroits avec des mécanismes de temporisation dans l'application web :
|
||||||
|
|
||||||
|
### 1. MediaBrowser.vue - Débouncing SSE (À SUPPRIMER ✓)
|
||||||
|
- **Débouncing**: 200ms après invalidation du cache
|
||||||
|
- **Cooldown**: 2 secondes entre les rechargements
|
||||||
|
- **Justification originale**: "dédupliquer les événements SSE dans le même batch (polling 500ms)"
|
||||||
|
- **Problème**: Cette logique est redondante puisque le serveur contrôle déjà le flux SSE
|
||||||
|
|
||||||
|
### 2. useRenderers.ts - Smart fetching (À CONSERVER ✓)
|
||||||
|
- **Mécanisme**: Comparaison des timestamps `lastEventAt` vs `lastSnapshotAt`
|
||||||
|
- **But**: Éviter de refetch un snapshot déjà à jour
|
||||||
|
- **Justification**: Ce n'est PAS du débouncing, c'est une optimisation intelligente qui évite des appels API inutiles
|
||||||
|
|
||||||
|
### 3. VolumeControl.vue - UI debouncing (À CONSERVER ✓)
|
||||||
|
- **Débouncing**: 300ms sur les changements de volume
|
||||||
|
- **But**: Réduire les appels API pendant que l'utilisateur fait glisser le curseur
|
||||||
|
- **Justification**: Débouncing légitime pour l'interface utilisateur
|
||||||
|
|
||||||
|
## Modifications effectuées
|
||||||
|
|
||||||
|
### Fichier modifié: `pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue`
|
||||||
|
|
||||||
|
#### 1. Suppression des variables de débouncing (ligne ~27)
|
||||||
|
|
||||||
|
**Avant**:
|
||||||
|
```typescript
|
||||||
|
// Flags pour gérer le rechargement automatique avec debounce et cooldown
|
||||||
|
const isRefreshing = ref(false);
|
||||||
|
const refreshTimeoutId = ref<number | null>(null);
|
||||||
|
const lastRefreshTime = ref<number>(0);
|
||||||
|
const REFRESH_COOLDOWN_MS = 2000; // Ne pas recharger plus d'une fois toutes les 2 secondes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Après**:
|
||||||
|
```typescript
|
||||||
|
// Flag pour gérer le rechargement automatique
|
||||||
|
const isRefreshing = ref(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Simplification du watcher de cache (ligne ~53)
|
||||||
|
|
||||||
|
**Avant**:
|
||||||
|
```typescript
|
||||||
|
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
|
||||||
|
// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
|
||||||
|
// ce qui déclenche un événement ContainersUpdated qui invalide le cache
|
||||||
|
// Utilise un debounce de 3 secondes pour regrouper les multiples invalidations
|
||||||
|
// et un cooldown de 5 secondes pour éviter les rechargements successifs
|
||||||
|
watch(
|
||||||
|
() => browseData.value,
|
||||||
|
(data) => {
|
||||||
|
if (!data && props.containerId && !loading.value) {
|
||||||
|
// Vérifier le cooldown: ignorer si on a rechargé il y a moins de 5 secondes
|
||||||
|
const timeSinceLastRefresh = Date.now() - lastRefreshTime.value;
|
||||||
|
if (timeSinceLastRefresh < REFRESH_COOLDOWN_MS) {
|
||||||
|
console.log(
|
||||||
|
`[MediaBrowser] Cache invalidé mais cooldown actif (${Math.round((REFRESH_COOLDOWN_MS - timeSinceLastRefresh) / 1000)}s restantes), rechargement ignoré`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Annuler tout timeout en cours
|
||||||
|
if (refreshTimeoutId.value !== null) {
|
||||||
|
clearTimeout(refreshTimeoutId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Planifier le rechargement après 200ms
|
||||||
|
refreshTimeoutId.value = window.setTimeout(async () => {
|
||||||
|
if (!isRefreshing.value) {
|
||||||
|
console.log(
|
||||||
|
`[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement après debounce...`,
|
||||||
|
);
|
||||||
|
isRefreshing.value = true;
|
||||||
|
await browseContainer(
|
||||||
|
props.serverId,
|
||||||
|
props.containerId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
lastRefreshTime.value = Date.now();
|
||||||
|
isRefreshing.value = false;
|
||||||
|
refreshTimeoutId.value = null;
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Après**:
|
||||||
|
```typescript
|
||||||
|
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
|
||||||
|
// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
|
||||||
|
// ce qui déclenche un événement ContainersUpdated qui invalide le cache
|
||||||
|
// Le serveur contrôle déjà le flux SSE, pas besoin de debouncing côté client
|
||||||
|
watch(
|
||||||
|
() => browseData.value,
|
||||||
|
async (data) => {
|
||||||
|
// Si browseData devient undefined alors que containerId est présent,
|
||||||
|
// et qu'on n'est pas déjà en train de charger, recharger immédiatement
|
||||||
|
if (!data && props.containerId && !loading.value && !isRefreshing.value) {
|
||||||
|
console.log(
|
||||||
|
`[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement...`,
|
||||||
|
);
|
||||||
|
isRefreshing.value = true;
|
||||||
|
await browseContainer(props.serverId, props.containerId, false);
|
||||||
|
isRefreshing.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Résultats
|
||||||
|
|
||||||
|
### Changements de comportement
|
||||||
|
- **Avant**: Délai de 200ms + cooldown de 2s entre les rechargements de cache
|
||||||
|
- **Après**: Rechargement immédiat dès l'invalidation du cache
|
||||||
|
- **Impact**: Réactivité améliorée de l'interface, les mises à jour apparaissent immédiatement
|
||||||
|
|
||||||
|
### Réduction de complexité
|
||||||
|
- **3 variables supprimées**: `refreshTimeoutId`, `lastRefreshTime`, `REFRESH_COOLDOWN_MS`
|
||||||
|
- **Logique simplifiée**: De ~40 lignes à ~10 lignes dans le watcher
|
||||||
|
- **Code plus lisible**: Intention claire sans mécanismes de temporisation complexes
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- ✓ Le projet compile sans erreurs TypeScript
|
||||||
|
- ✓ Le flag `isRefreshing` empêche toujours les rechargements concurrents
|
||||||
|
- ✓ Les autres composants (useRenderers.ts, VolumeControl.vue) conservent leurs optimisations légitimes
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
La suppression du débouncing et du cooldown dans MediaBrowser.vue simplifie le code tout en améliorant la réactivité de l'interface. Puisque le serveur contrôle déjà le flux SSE, ces mécanismes côté client étaient redondants et ajoutaient une latence artificielle.
|
||||||
|
|
||||||
|
Le code est maintenant plus simple, plus réactif, et fait confiance au serveur pour contrôler la fréquence des événements SSE.
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
- `pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue`
|
||||||
|
|
||||||
|
## Lignes de code
|
||||||
|
- **Supprimées**: ~35 lignes (logique de débouncing/cooldown)
|
||||||
|
- **Ajoutées**: ~5 lignes (logique simplifiée)
|
||||||
|
- **Net**: -30 lignes
|
||||||
96
Blackboard/Report/config_ext.md
Normal file
96
Blackboard/Report/config_ext.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Rapport : Documentation du pattern d'extension pmoconfig
|
||||||
|
|
||||||
|
## Objectif de la tâche
|
||||||
|
|
||||||
|
Créer une fiche descriptive documentant le pattern d'implémentation des traits d'extension de `pmoconfig::Config` en analysant les implémentations existantes dans les différents crates du projet.
|
||||||
|
|
||||||
|
## Travail réalisé
|
||||||
|
|
||||||
|
### 1. Analyse des fichiers source
|
||||||
|
|
||||||
|
Les fichiers suivants ont été analysés :
|
||||||
|
|
||||||
|
- `pmocovers/src/config_ext.rs` - Pattern cache avec conversion WebP
|
||||||
|
- `pmoaudiocache/src/config_ext.rs` - Pattern cache avec conversion FLAC
|
||||||
|
- `pmoqobuz/src/config_ext.rs` - Pattern authentification et rate limiting
|
||||||
|
- `pmocache/src/config_ext.rs` - Trait générique de cache et macro
|
||||||
|
- `pmoconfig/PASSWORD_ENCRYPTION.md` - Documentation du chiffrement
|
||||||
|
- `pmoupnp/src/config_ext.rs` - Pattern configuration UPnP
|
||||||
|
- `pmoparadise/src/config_ext.rs` - Pattern configuration minimale
|
||||||
|
|
||||||
|
### 2. Patterns identifiés
|
||||||
|
|
||||||
|
#### Pattern de base
|
||||||
|
Tous les traits d'extension suivent la même structure :
|
||||||
|
- Trait public avec méthodes getter/setter
|
||||||
|
- Implémentation pour `pmoconfig::Config`
|
||||||
|
- Utilisation de `get_value`/`set_value` génériques
|
||||||
|
- Constantes pour valeurs par défaut
|
||||||
|
|
||||||
|
#### Patterns spécialisés
|
||||||
|
- **Cache** : Utilisation de `CacheConfigExt` et factory methods
|
||||||
|
- **Authentification** : Getters combinés, helpers de validation, déchiffrement automatique
|
||||||
|
- **Rate limiting** : Configuration des limites avec valeurs par défaut
|
||||||
|
- **Configuration minimale** : Auto-persistence des valeurs par défaut
|
||||||
|
- **UPnP** : Configuration des identifiants devices
|
||||||
|
|
||||||
|
### 3. Structure de la documentation
|
||||||
|
|
||||||
|
La documentation créée couvre :
|
||||||
|
|
||||||
|
1. **Vue d'ensemble** : Objectif et principe du pattern
|
||||||
|
2. **Architecture** : Structure et flux de données
|
||||||
|
3. **Implémentation** : Guide détaillé avec patterns de code
|
||||||
|
4. **Patterns spécialisés** : Exemples pour chaque cas d'usage
|
||||||
|
5. **Bonnes pratiques** : Nommage, erreurs, documentation
|
||||||
|
6. **Exemples complets** : 3 implémentations complètes commentées
|
||||||
|
7. **Checklist** : Liste de vérification pour nouveaux traits
|
||||||
|
8. **Philosophie** : Principes directeurs et avantages
|
||||||
|
|
||||||
|
### 4. Contenu clé
|
||||||
|
|
||||||
|
#### Patterns de getters
|
||||||
|
- Getter simple avec valeur par défaut
|
||||||
|
- Getter avec auto-persistence
|
||||||
|
- Getter optionnel
|
||||||
|
- Getter avec déchiffrement
|
||||||
|
- Getter avec parsing et fallback
|
||||||
|
|
||||||
|
#### Patterns de setters
|
||||||
|
- Setter simple
|
||||||
|
- Setter avec transformation
|
||||||
|
- Setter multiple (transaction)
|
||||||
|
- Setter de nettoyage
|
||||||
|
|
||||||
|
#### Helpers
|
||||||
|
- Factory methods
|
||||||
|
- Getters combinés
|
||||||
|
- Helpers de validation
|
||||||
|
|
||||||
|
### 5. Hiérarchie de configuration YAML
|
||||||
|
|
||||||
|
Documentation des chemins standards :
|
||||||
|
- `host.*` : Configuration hôte/système
|
||||||
|
- `accounts.*` : Comptes et services
|
||||||
|
- `sources.*` : Sources de médias
|
||||||
|
|
||||||
|
## Résultat
|
||||||
|
|
||||||
|
Le document `Blackboard/Architecture/pmoconfig_ext.md` a été créé avec :
|
||||||
|
- 800+ lignes de documentation complète
|
||||||
|
- 3 exemples d'implémentation complète
|
||||||
|
- Patterns pour tous les cas d'usage identifiés
|
||||||
|
- Bonnes pratiques et anti-patterns
|
||||||
|
- Checklist d'implémentation
|
||||||
|
|
||||||
|
## Fichiers créés ou modifiés
|
||||||
|
|
||||||
|
- **Créé** : `Blackboard/Architecture/pmoconfig_ext.md` - Documentation complète du pattern
|
||||||
|
- **Créé** : `Blackboard/Report/config_ext.md` - Ce rapport
|
||||||
|
|
||||||
|
## Conformité avec Rules.md
|
||||||
|
|
||||||
|
- Documentation placée dans `Blackboard/Architecture/` comme demandé
|
||||||
|
- Rapport créé dans `Blackboard/Report/` avec le même nom de fichier
|
||||||
|
- Analyse focalisée sur l'objectif principal
|
||||||
|
- Documentation prête pour classification (Done/ToDiscuss) par l'humain
|
||||||
227
Blackboard/Report/music_source.md
Normal file
227
Blackboard/Report/music_source.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# Rapport : Documentation d'implémentation d'une nouvelle MusicSource
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Créer une documentation complète et pratique pour guider l'implémentation d'une nouvelle source musicale dans l'écosystème PMOMusic.
|
||||||
|
|
||||||
|
## Travail réalisé
|
||||||
|
|
||||||
|
### 1. Analyse des sources existantes
|
||||||
|
|
||||||
|
J'ai analysé deux implémentations de référence :
|
||||||
|
|
||||||
|
- **pmoparadise/src/source.rs** : Source dynamique avec FIFO (radio streaming)
|
||||||
|
- **pmoqobuz/src/source.rs** : Source catalogue avec playlists lazy
|
||||||
|
|
||||||
|
Ainsi que la documentation du trait :
|
||||||
|
|
||||||
|
- **pmosource/README.md** : Vue d'ensemble du trait MusicSource
|
||||||
|
- **pmosource/ARCHITECTURE.md** : Architecture et design decisions
|
||||||
|
|
||||||
|
### 2. Identification des patterns principaux
|
||||||
|
|
||||||
|
Deux patterns majeurs ont été identifiés :
|
||||||
|
|
||||||
|
#### Pattern 1 : Source dynamique FIFO (Radio Paradise)
|
||||||
|
|
||||||
|
**Caractéristiques :**
|
||||||
|
- Flux continu de tracks avec capacité limitée
|
||||||
|
- Suppression automatique des plus anciens
|
||||||
|
- Callbacks sur playlists pour détecter les changements
|
||||||
|
- Notification du ContentDirectory via notifier injecté
|
||||||
|
- Adaptation des IDs playlist → schema source
|
||||||
|
|
||||||
|
**Éléments clés :**
|
||||||
|
```rust
|
||||||
|
update_counter: Arc<RwLock<u32>>
|
||||||
|
last_change: Arc<RwLock<SystemTime>>
|
||||||
|
callback_tokens: Arc<Mutex<Vec<u64>>>
|
||||||
|
container_notifier: Option<Arc<dyn Fn(&[String]) + Send + Sync>>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Pattern 2 : Source catalogue lazy (Qobuz)
|
||||||
|
|
||||||
|
**Caractéristiques :**
|
||||||
|
- Catalogue vaste avec navigation hiérarchique
|
||||||
|
- Cache lazy pour audio, eager pour covers
|
||||||
|
- Playlists créées à la demande avec TTL
|
||||||
|
- LazyProvider pour télécharger l'audio à la lecture
|
||||||
|
- Métadonnées riches stockées dans le cache
|
||||||
|
|
||||||
|
**Éléments clés :**
|
||||||
|
```rust
|
||||||
|
SourceCacheManager centralisé
|
||||||
|
QobuzLazyProvider implémentant LazyProvider
|
||||||
|
Playlists avec rôle Album et TTL de 7 jours
|
||||||
|
Adaptation IDs avec metadata source_track_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Structure du document créé
|
||||||
|
|
||||||
|
Le document `Blackboard/Architecture/music_source.md` contient :
|
||||||
|
|
||||||
|
#### Table des matières
|
||||||
|
1. Vue d'ensemble
|
||||||
|
2. Structure d'une MusicSource
|
||||||
|
3. Implémentation du trait MusicSource
|
||||||
|
4. Patterns d'implémentation
|
||||||
|
5. Intégration avec l'écosystème PMOMusic
|
||||||
|
6. Checklist de mise en œuvre
|
||||||
|
7. Exemples de référence
|
||||||
|
|
||||||
|
#### Sections détaillées
|
||||||
|
|
||||||
|
**Section 1 : Vue d'ensemble**
|
||||||
|
- Définition d'une MusicSource
|
||||||
|
- Types de sources (dynamique vs statique)
|
||||||
|
- Capacités du trait
|
||||||
|
|
||||||
|
**Section 2 : Structure**
|
||||||
|
- Organisation du code
|
||||||
|
- Dépendances recommandées
|
||||||
|
- Features Cargo
|
||||||
|
|
||||||
|
**Section 3 : Implémentation du trait**
|
||||||
|
- Informations de base (name, id, default_image)
|
||||||
|
- Navigation ContentDirectory (root_container, browse, resolve_uri)
|
||||||
|
- Support FIFO (append_track, remove_oldest, update_id)
|
||||||
|
- Support statique (get_items, search)
|
||||||
|
|
||||||
|
**Section 4 : Patterns**
|
||||||
|
- Pattern 1 : Source dynamique avec FIFO (code complet)
|
||||||
|
- Pattern 2 : Source catalogue avec playlists lazy (code complet)
|
||||||
|
- Pattern 3 : Adaptation des IDs entre playlist et source
|
||||||
|
|
||||||
|
**Section 5 : Intégration écosystème**
|
||||||
|
- pmoplaylist : création et gestion de playlists
|
||||||
|
- pmoaudiocache/pmocovers via SourceCacheManager
|
||||||
|
- pmodidl : conversion vers DIDL-Lite
|
||||||
|
- LazyProvider personnalisé
|
||||||
|
|
||||||
|
**Section 6 : Checklist**
|
||||||
|
- Phase 1 : Structure de base
|
||||||
|
- Phase 2 : Navigation ContentDirectory
|
||||||
|
- Phase 3 : Résolution d'URI
|
||||||
|
- Phase 4 : Support FIFO (si dynamique)
|
||||||
|
- Phase 5 : Support statique (si catalogue)
|
||||||
|
- Phase 6 : Intégration avancée
|
||||||
|
- Phase 7 : Tests et validation
|
||||||
|
|
||||||
|
**Section 7 : Exemples de référence**
|
||||||
|
- Radio Paradise (source dynamique FIFO)
|
||||||
|
- Qobuz (source catalogue lazy)
|
||||||
|
- Schemas d'Object ID détaillés
|
||||||
|
|
||||||
|
### 4. Points techniques importants documentés
|
||||||
|
|
||||||
|
#### Schema d'Object ID
|
||||||
|
|
||||||
|
Format recommandé hiérarchique :
|
||||||
|
```
|
||||||
|
<source-id>
|
||||||
|
<source-id>:albums
|
||||||
|
<source-id>:album:<album_id>
|
||||||
|
<source-id>:track:<track_id>
|
||||||
|
<source-id>:playlist:<playlist_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemples concrets de Radio Paradise et Qobuz fournis.
|
||||||
|
|
||||||
|
#### Adaptation des IDs
|
||||||
|
|
||||||
|
Code complet pour adapter les items de playlist au schema de la source :
|
||||||
|
- Extraction du cache_pk depuis l'URL
|
||||||
|
- Récupération du source_track_id depuis metadata
|
||||||
|
- Reconstruction de l'ID correct
|
||||||
|
- Normalisation des URLs (relatives → absolues)
|
||||||
|
- Ajout de champs requis (genre)
|
||||||
|
|
||||||
|
#### Cache lazy vs eager
|
||||||
|
|
||||||
|
Stratégie claire :
|
||||||
|
- **Covers** : Cache eager (petit, UI en a besoin immédiatement)
|
||||||
|
- **Audio** : Cache lazy (grand, téléchargé à la demande)
|
||||||
|
|
||||||
|
#### Thread Safety
|
||||||
|
|
||||||
|
Règles explicites :
|
||||||
|
- `Arc<RwLock<>>` pour état mutable partagé
|
||||||
|
- `tokio::sync::RwLock` pour async
|
||||||
|
- Éviter `Rc<>`, `RefCell` (non thread-safe)
|
||||||
|
- Implémenter `Clone` via `Arc<>`
|
||||||
|
|
||||||
|
#### Compatibilité UPnP
|
||||||
|
|
||||||
|
Points de vigilance :
|
||||||
|
- Genre obligatoire pour certains clients (gupnp-av-cp)
|
||||||
|
- URLs absolues uniquement
|
||||||
|
- Protocol Info correct pour FLAC
|
||||||
|
- Duration au format `H:MM:SS`
|
||||||
|
- childCount optionnel mais recommandé
|
||||||
|
|
||||||
|
### 5. Code d'exemple complet
|
||||||
|
|
||||||
|
Le document contient des exemples de code complets et fonctionnels pour :
|
||||||
|
|
||||||
|
1. **Structure de base** : définition de la struct et implémentation basique
|
||||||
|
2. **Navigation** : root_container et browse avec pattern matching
|
||||||
|
3. **Résolution URI** : avec fallback cache → original
|
||||||
|
4. **FIFO** : append_track, remove_oldest, callbacks
|
||||||
|
5. **Adaptation IDs** : fonction complète d'adaptation
|
||||||
|
6. **LazyProvider** : implémentation personnalisée
|
||||||
|
7. **Conversion DIDL** : traits ToDIDLContainer et ToDIDLItem
|
||||||
|
|
||||||
|
## Couverture des besoins
|
||||||
|
|
||||||
|
### Sources couvertes
|
||||||
|
|
||||||
|
- ✅ Radio Paradise : source dynamique FIFO
|
||||||
|
- ✅ Qobuz : source catalogue lazy
|
||||||
|
- ✅ Patterns génériques applicables à d'autres sources
|
||||||
|
|
||||||
|
### Cas d'usage couverts
|
||||||
|
|
||||||
|
- ✅ Source radio/streaming live
|
||||||
|
- ✅ Source catalogue de streaming (Spotify, Deezer, etc.)
|
||||||
|
- ✅ Source bibliothèque locale
|
||||||
|
- ✅ Source playlists fixes
|
||||||
|
- ✅ Source avec authentification (via client)
|
||||||
|
|
||||||
|
### Intégrations couvertes
|
||||||
|
|
||||||
|
- ✅ pmoplaylist (FIFO et persistant)
|
||||||
|
- ✅ pmoaudiocache (cache audio)
|
||||||
|
- ✅ pmocovers (cache covers)
|
||||||
|
- ✅ SourceCacheManager (centralisé)
|
||||||
|
- ✅ LazyProvider (téléchargement lazy)
|
||||||
|
- ✅ pmodidl (DIDL-Lite)
|
||||||
|
|
||||||
|
## Limitations et améliorations futures
|
||||||
|
|
||||||
|
### Limitations actuelles
|
||||||
|
|
||||||
|
1. **Search** : Pas d'exemple détaillé de search (optionnel dans le trait)
|
||||||
|
2. **Authentification** : Mentionné mais pas d'exemple complet
|
||||||
|
3. **Multi-format** : Pas d'exemple de source supportant plusieurs formats
|
||||||
|
4. **Offline** : Pas de pattern pour source offline/synchronisation
|
||||||
|
|
||||||
|
### Améliorations possibles
|
||||||
|
|
||||||
|
1. Ajouter un exemple complet de search avec filtres
|
||||||
|
2. Documenter l'intégration avec un système d'auth OAuth
|
||||||
|
3. Ajouter un pattern pour sources multi-formats (FLAC/MP3/AAC)
|
||||||
|
4. Documenter la gestion offline avec synchronisation
|
||||||
|
|
||||||
|
## Fichiers créés
|
||||||
|
|
||||||
|
- `Blackboard/Architecture/music_source.md` : Documentation complète (15 sections, ~800 lignes)
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Le document créé fournit un guide complet et pratique pour implémenter une nouvelle MusicSource. Il combine :
|
||||||
|
|
||||||
|
- **Théorie** : Architecture, design patterns, principes
|
||||||
|
- **Pratique** : Code complet, exemples réels, checklist
|
||||||
|
- **Référence** : Schemas d'Object ID, intégrations, compatibilité
|
||||||
|
|
||||||
|
Un développeur peut suivre ce guide étape par étape pour créer une nouvelle source musicale compatible avec l'écosystème PMOMusic, en s'inspirant des patterns éprouvés de Radio Paradise et Qobuz.
|
||||||
77
Blackboard/Report/pmoserver_ext.md
Normal file
77
Blackboard/Report/pmoserver_ext.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Rapport : Documentation du pattern pmoserver_ext
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Documentation du pattern d'extension du PMOServer à travers plusieurs itérations basées sur les retours utilisateur.
|
||||||
|
|
||||||
|
## Travail réalisé
|
||||||
|
|
||||||
|
### Analyse des fichiers sources
|
||||||
|
|
||||||
|
Les fichiers suivants ont été analysés pour extraire le pattern :
|
||||||
|
|
||||||
|
- `pmoapp/src/lib.rs` : Pattern SPA avec RustEmbed
|
||||||
|
- `pmocontrol/src/pmoserver_ext.rs` : API REST avec Control Point (1506+ lignes)
|
||||||
|
- `pmoparadise/src/pmoserver_ext.rs` : API REST simple avec client externe
|
||||||
|
- `pmoaudiocache/src/lib.rs` : Extension avec cache et fichiers
|
||||||
|
- `pmomediaserver/src/paradise_streaming.rs` : Extension complexe avec streaming
|
||||||
|
|
||||||
|
### Round 1 : Document initial
|
||||||
|
|
||||||
|
Premier jet documentant exhaustivement tous les aspects des extensions (~850 lignes).
|
||||||
|
|
||||||
|
### Round 2 : Recentrage sur le pattern
|
||||||
|
|
||||||
|
**Annotation** : "se recentrer sur le sujet principal"
|
||||||
|
|
||||||
|
**Actions** :
|
||||||
|
- Réduction de ~850 à ~400 lignes
|
||||||
|
- Suppression des digressions (OpenAPI détaillé, handlers spécifiques)
|
||||||
|
- Focus sur l'anatomie du pattern en 5 étapes
|
||||||
|
- Ajout d'une checklist et d'un exemple minimal
|
||||||
|
|
||||||
|
**Résultat** : Document focalisé sur l'implémentation du pattern uniquement.
|
||||||
|
|
||||||
|
### Round 3 : Réintégration OpenAPI
|
||||||
|
|
||||||
|
**Annotation** : "Je trouve que le fait de devoir déclarer et documenter les URL dans OpenAPI / utopia était quelque chose d'important. Remets le."
|
||||||
|
|
||||||
|
**Actions** :
|
||||||
|
- Ajout d'une section complète "Documentation OpenAPI avec utoipa" (~260 lignes)
|
||||||
|
- 5 sous-sections détaillées :
|
||||||
|
1. Configuration de base (dépendances Cargo)
|
||||||
|
2. Définition des schémas avec `#[derive(ToSchema)]`
|
||||||
|
3. Annotation des handlers avec `#[utoipa::path]`
|
||||||
|
4. Création de la structure `#[derive(OpenApi)]`
|
||||||
|
5. Exemple complet extrait de Radio Paradise
|
||||||
|
- Mise à jour de la checklist avec section "Documentation OpenAPI"
|
||||||
|
- Ajout des dépendances `utoipa` et `serde` dans la section références
|
||||||
|
|
||||||
|
**Positionnement** : Section insérée après "Méthodes disponibles du serveur" et avant "Patterns courants", car elle fait partie intégrante de l'implémentation.
|
||||||
|
|
||||||
|
## Structure finale du document
|
||||||
|
|
||||||
|
1. **Vue d'ensemble** : Principe du pattern
|
||||||
|
2. **Anatomie d'une extension** : 5 étapes détaillées
|
||||||
|
3. **Méthodes disponibles du serveur** : API de `pmoserver::Server`
|
||||||
|
4. **Documentation OpenAPI avec utoipa** : Guide complet en 5 étapes ⭐ *Ajouté au Round 3*
|
||||||
|
5. **Patterns courants** : 3 exemples concrets
|
||||||
|
6. **Gestion des opérations longues** : spawn_blocking, timeouts, background tasks
|
||||||
|
7. **Checklist d'implémentation** : Organisée par catégories
|
||||||
|
8. **Exemple complet minimal** : Code fonctionnel
|
||||||
|
9. **Références** : Fichiers sources et dépendances
|
||||||
|
|
||||||
|
## Résultat final
|
||||||
|
|
||||||
|
Le document est maintenant :
|
||||||
|
|
||||||
|
- **Complet** : Couvre tous les aspects essentiels incluant OpenAPI
|
||||||
|
- **Structuré** : Progression logique de la configuration à l'implémentation
|
||||||
|
- **Pratique** : Exemples de code concrets extraits du codebase
|
||||||
|
- **Actionnable** : Checklist détaillée en 4 catégories
|
||||||
|
|
||||||
|
Taille finale : ~660 lignes (avec section OpenAPI complète)
|
||||||
|
|
||||||
|
## Fichiers modifiés
|
||||||
|
|
||||||
|
- `Blackboard/Architecture/pmoserver_ext.md` : Document complet avec OpenAPI (660 lignes)
|
||||||
229
Blackboard/Report/stateful_music_renderer.md
Normal file
229
Blackboard/Report/stateful_music_renderer.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# Rapport : Rendre MusicRenderer complètement stateful
|
||||||
|
|
||||||
|
## Résumé
|
||||||
|
|
||||||
|
Refactorisation de l'architecture pour que chaque `MusicRenderer` gère son propre thread de surveillance (watcher), au lieu de déléguer le polling au `ControlPoint` centralisé. Cette modification améliore l'encapsulation, la cohérence des événements et prépare le terrain pour le support futur des notifications push (OpenHome, Chromecast).
|
||||||
|
|
||||||
|
## Travail effectué
|
||||||
|
|
||||||
|
### Phase 1 : Création du module watcher.rs
|
||||||
|
|
||||||
|
**Fichier créé** : `pmocontrol/src/music_renderer/watcher.rs`
|
||||||
|
|
||||||
|
Nouveau module contenant :
|
||||||
|
- `WatchStrategy` enum avec trois variantes :
|
||||||
|
- `Polling { interval_ms: u64 }` - pour UPnP, LinkPlay, Arylic (500ms)
|
||||||
|
- `Push` - pour support futur des notifications push
|
||||||
|
- `Hybrid { polling_interval_ms: u64 }` - pour OpenHome et Chromecast
|
||||||
|
- `WatchedState` struct pour le cache de détection des changements
|
||||||
|
- Fonctions helper déplacées depuis `control_point.rs` :
|
||||||
|
- `playback_state_equal()`
|
||||||
|
- `playback_position_equal()`
|
||||||
|
- `compute_logical_playback_state()`
|
||||||
|
- `extract_track_metadata()`
|
||||||
|
- `parse_hms_to_secs()`
|
||||||
|
- Tests unitaires pour les fonctions helper
|
||||||
|
|
||||||
|
### Phase 2 : Extension de MusicRenderer
|
||||||
|
|
||||||
|
**Fichier modifié** : `pmocontrol/src/music_renderer/musicrenderer.rs`
|
||||||
|
|
||||||
|
Nouveaux champs ajoutés à la struct `MusicRenderer` :
|
||||||
|
- `watched_state: Arc<Mutex<WatchedState>>` - cache pour détection des changements
|
||||||
|
- `watcher_stop_flag: Arc<AtomicBool>` - signal d'arrêt du thread
|
||||||
|
- `watcher_handle: Arc<Mutex<Option<JoinHandle<()>>>>` - handle du thread watcher
|
||||||
|
|
||||||
|
Nouvelles méthodes publiques :
|
||||||
|
- `start_watching()` - démarre le thread de surveillance (idempotent)
|
||||||
|
- `stop_watching()` - arrête le thread gracieusement (idempotent)
|
||||||
|
- `is_watching()` - retourne l'état du watcher
|
||||||
|
|
||||||
|
Nouvelles méthodes internes :
|
||||||
|
- `spawn_watcher_thread()` - crée le thread avec la stratégie appropriée
|
||||||
|
- `watcher_loop()` - boucle principale de polling
|
||||||
|
- `poll_and_emit_changes()` - poll le backend et émet les événements
|
||||||
|
- `handle_state_change()` - logique d'auto-advance (déplacée depuis ControlPoint)
|
||||||
|
- `emit_event()` - helper pour émettre un événement via le bus
|
||||||
|
|
||||||
|
### Phase 3 : Modification du Registry
|
||||||
|
|
||||||
|
**Fichier modifié** : `pmocontrol/src/registry.rs`
|
||||||
|
|
||||||
|
Ajout des appels `start_watching()` / `stop_watching()` :
|
||||||
|
- `push_renderer()` : appelle `start_watching()` quand un renderer arrive en ligne ou est créé
|
||||||
|
- `device_says_byebye()` : appelle `stop_watching()` avant de marquer offline
|
||||||
|
- `check_timeouts()` : appelle `stop_watching()` avant de marquer offline sur timeout
|
||||||
|
|
||||||
|
### Phase 4 : Simplification du ControlPoint
|
||||||
|
|
||||||
|
**Fichier modifié** : `pmocontrol/src/control_point.rs`
|
||||||
|
|
||||||
|
Suppressions :
|
||||||
|
- Thread de polling central (~140 lignes)
|
||||||
|
- Struct `RendererRuntimeSnapshot`
|
||||||
|
- Méthodes `emit_renderer_event()` et `handle_renderer_event()`
|
||||||
|
- Fonctions helper déplacées vers `watcher.rs`
|
||||||
|
|
||||||
|
### Phase 5 : Mise à jour du module
|
||||||
|
|
||||||
|
**Fichier modifié** : `pmocontrol/src/music_renderer/mod.rs`
|
||||||
|
|
||||||
|
Ajout de `pub mod watcher;` pour exposer le nouveau module.
|
||||||
|
|
||||||
|
## Liste des fichiers
|
||||||
|
|
||||||
|
### Fichiers créés
|
||||||
|
|
||||||
|
| Fichier | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `pmocontrol/src/music_renderer/watcher.rs` | Module watcher avec WatchStrategy, WatchedState et fonctions helper |
|
||||||
|
|
||||||
|
### Fichiers modifiés
|
||||||
|
|
||||||
|
| Fichier | Modification |
|
||||||
|
|---------|--------------|
|
||||||
|
| `pmocontrol/src/music_renderer/musicrenderer.rs` | Ajout champs watcher, méthodes start/stop_watching, logique auto-advance |
|
||||||
|
| `pmocontrol/src/music_renderer/mod.rs` | Ajout `pub mod watcher;` |
|
||||||
|
| `pmocontrol/src/registry.rs` | Appels start/stop_watching dans push_renderer, device_says_byebye, check_timeouts |
|
||||||
|
| `pmocontrol/src/control_point.rs` | Suppression polling central, RendererRuntimeSnapshot, handle_renderer_event, fonctions helper |
|
||||||
|
|
||||||
|
## Notes techniques
|
||||||
|
|
||||||
|
- Le signal d'arrêt utilise `AtomicBool` avec `Ordering::SeqCst` pour garantir la visibilité entre threads
|
||||||
|
- Les méthodes `start_watching()` et `stop_watching()` sont idempotentes
|
||||||
|
- Le thread watcher est nommé `watcher-{friendly_name}` pour faciliter le debug
|
||||||
|
- L'intervalle de polling est de 500ms (volume/mute toutes les 2 ticks = 1s)
|
||||||
|
- La logique `compute_logical_playback_state()` compense les bugs des devices Arylic/LinkPlay
|
||||||
|
- L'auto-advance est maintenant géré directement dans le watcher du MusicRenderer
|
||||||
|
|
||||||
|
## Round 2 : Vérification transition offline → online
|
||||||
|
|
||||||
|
### Problème identifié
|
||||||
|
|
||||||
|
La méthode `refresh_device_presence()` dans `registry.rs` n'appelait pas `start_watching()` quand un renderer passait de offline à online. Cette méthode est appelée lors de la réception de messages SSDP Alive.
|
||||||
|
|
||||||
|
### Correction appliquée
|
||||||
|
|
||||||
|
**Fichier modifié** : `pmocontrol/src/registry.rs`
|
||||||
|
|
||||||
|
Ajout de l'appel `renderer.start_watching()` dans `refresh_device_presence()` quand `was_online == false`.
|
||||||
|
|
||||||
|
### Points de démarrage du watcher vérifiés
|
||||||
|
|
||||||
|
| Méthode | Situation | `start_watching()` appelé |
|
||||||
|
|---------|-----------|---------------------------|
|
||||||
|
| `push_renderer()` | Nouveau renderer | Oui |
|
||||||
|
| `push_renderer()` | Renderer existant, était offline | Oui |
|
||||||
|
| `refresh_device_presence()` | Renderer existant, était offline | Oui (corrigé) |
|
||||||
|
|
||||||
|
### Points d'arrêt du watcher vérifiés
|
||||||
|
|
||||||
|
| Méthode | Situation | `stop_watching()` appelé |
|
||||||
|
|---------|-----------|--------------------------|
|
||||||
|
| `device_says_byebye()` | SSDP ByeBye reçu | Oui |
|
||||||
|
| `check_timeouts()` | Timeout dépassé | Oui |
|
||||||
|
|
||||||
|
## Round 3 : Audit complet de la logique offline/online
|
||||||
|
|
||||||
|
Suite à la découverte du manque dans le Round 2, un audit complet de tous les chemins offline/online a été effectué.
|
||||||
|
|
||||||
|
### Chemins qui appellent `start_watching()`
|
||||||
|
|
||||||
|
| Chemin | Fonction | Ligne | Condition | Status |
|
||||||
|
|--------|----------|-------|-----------|--------|
|
||||||
|
| Nouveau renderer découvert | `push_renderer()` | 180, 194 | Création nouvelle entry | ✅ OK |
|
||||||
|
| Renderer existant, ajout renderer à entry | `push_renderer()` | 169 | Entry existe sans renderer | ✅ OK |
|
||||||
|
| Renderer existant revient online | `push_renderer()` | 160 | `!was_online` | ✅ OK |
|
||||||
|
| SSDP Alive pour device connu | `refresh_device_presence()` | 269 | `!was_online` | ✅ OK (corrigé Round 2) |
|
||||||
|
|
||||||
|
### Chemins qui appellent `stop_watching()`
|
||||||
|
|
||||||
|
| Chemin | Fonction | Ligne | Condition | Status |
|
||||||
|
|--------|----------|-------|-----------|--------|
|
||||||
|
| SSDP ByeBye reçu | `device_says_byebye()` | 289 | Renderer présent | ✅ OK |
|
||||||
|
| Timeout dépassé | `check_timeouts()` | 308 | `elapsed > max_age` | ✅ OK |
|
||||||
|
|
||||||
|
### Analyse des flux
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FLUX ONLINE │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ SSDP Discovery ──► push_renderer() ──► start_watching() ✅ │
|
||||||
|
│ │
|
||||||
|
│ SSDP Alive (nouveau UDN) ──► push_renderer() ──► start_watching() ✅ │
|
||||||
|
│ │
|
||||||
|
│ SSDP Alive (UDN connu, online) ──► refresh_device_presence() │
|
||||||
|
│ (pas de start car déjà en marche) │
|
||||||
|
│ │
|
||||||
|
│ SSDP Alive (UDN connu, offline) ──► refresh_device_presence() │
|
||||||
|
│ ──► start_watching() ✅ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FLUX OFFLINE │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ SSDP ByeBye ──► device_says_byebye() ──► stop_watching() ✅ │
|
||||||
|
│ │
|
||||||
|
│ Timeout ──► check_timeouts() ──► stop_watching() ✅ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conclusion
|
||||||
|
|
||||||
|
**Tous les chemins sont correctement gérés.** Chaque transition offline→online appelle `start_watching()` et chaque transition online→offline appelle `stop_watching()`.
|
||||||
|
|
||||||
|
L'idempotence des méthodes `start_watching()` et `stop_watching()` garantit qu'aucun problème ne survient en cas d'appels multiples.
|
||||||
|
|
||||||
|
## Round 4 : Centralisation de la gestion du watcher
|
||||||
|
|
||||||
|
### Problème identifié
|
||||||
|
|
||||||
|
Les appels à `start_watching()` et `stop_watching()` étaient dispersés dans `registry.rs` (6 emplacements), augmentant le risque d'oubli (comme découvert en Round 2).
|
||||||
|
|
||||||
|
### Solution implémentée
|
||||||
|
|
||||||
|
Centralisation de la gestion du watcher dans `MusicRenderer` lui-même :
|
||||||
|
|
||||||
|
1. **Constructeur** (`from_renderer_info_with_bus()`) : appelle automatiquement `start_watching()` à la fin, car le renderer est créé avec `online = true`
|
||||||
|
|
||||||
|
2. **`has_been_seen_now()`** : appelle automatiquement `start_watching()` si transition offline→online
|
||||||
|
|
||||||
|
3. **`mark_as_offline()`** : appelle automatiquement `stop_watching()` avant de passer offline
|
||||||
|
|
||||||
|
### Fichiers modifiés
|
||||||
|
|
||||||
|
| Fichier | Modification |
|
||||||
|
|---------|--------------|
|
||||||
|
| `pmocontrol/src/music_renderer/musicrenderer.rs` | Ajout `start_watching()` dans constructeur, dans `has_been_seen_now()` et `stop_watching()` dans `mark_as_offline()` |
|
||||||
|
| `pmocontrol/src/registry.rs` | Suppression de tous les appels manuels à `start_watching()` et `stop_watching()` |
|
||||||
|
|
||||||
|
### Avantages
|
||||||
|
|
||||||
|
- **Encapsulation** : la logique watcher est entièrement gérée par `MusicRenderer`
|
||||||
|
- **Impossible d'oublier** : les transitions sont automatiquement gérées
|
||||||
|
- **Code simplifié** : `registry.rs` ne contient plus de logique watcher
|
||||||
|
- **Idempotence** : les appels multiples sont sans effet grâce aux guards existants
|
||||||
|
|
||||||
|
### Nouvelle architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ GESTION AUTOMATIQUE DU WATCHER │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Création MusicRenderer ──► constructeur ──► start_watching() │
|
||||||
|
│ │
|
||||||
|
│ has_been_seen_now() ──► si !was_online ──► start_watching() │
|
||||||
|
│ │
|
||||||
|
│ mark_as_offline() ──► stop_watching() ──► online = false │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compilation
|
||||||
|
|
||||||
|
Le projet compile sans erreur.
|
||||||
177
Blackboard/Rules.md
Normal file
177
Blackboard/Rules.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# Règles de développement PMOMusic
|
||||||
|
|
||||||
|
## Contexte projet
|
||||||
|
|
||||||
|
**PMOMusic** : Système audio HiFi basé sur UPnP/DLNA (LossLess/Bit-Perfect uniquement).
|
||||||
|
|
||||||
|
**Technologies** :
|
||||||
|
- Backend : Rust
|
||||||
|
- Frontend : Vue.js (TypeScript/JavaScript)
|
||||||
|
|
||||||
|
**Composants** : Media Server, Control Point, Media Renderer
|
||||||
|
|
||||||
|
**Développement** : Collaboration humain-LLM (Claude/ChatGPT/Ollama)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Règles Rust (Cargo workspace)
|
||||||
|
|
||||||
|
### Gestion des dépendances
|
||||||
|
|
||||||
|
**⚠️ OBLIGATOIRE** : Les dépendances doivent être ajoutées au niveau **workspace** autant que possible.
|
||||||
|
|
||||||
|
1. Ajouter la dépendance dans `Cargo.toml` racine (section `[workspace.dependencies]`)
|
||||||
|
2. Référencer avec `{ workspace = true }` dans le `Cargo.toml` de la crate
|
||||||
|
|
||||||
|
**Exemple** :
|
||||||
|
```toml
|
||||||
|
# Cargo.toml (racine workspace)
|
||||||
|
[workspace.dependencies]
|
||||||
|
rand = "0.9"
|
||||||
|
|
||||||
|
# pmocontrol/Cargo.toml
|
||||||
|
[dependencies]
|
||||||
|
rand = { workspace = true }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exceptions** : Dépendances spécifiques à une seule crate avec version très particulière.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prérequis des tâches
|
||||||
|
|
||||||
|
### Spécification des crates cibles
|
||||||
|
|
||||||
|
**⚠️ CRITIQUE** : Le LLM doit **REFUSER** d'exécuter une tâche si la ou les crates concernées ne sont pas explicitement spécifiées dans le fichier `Todo/{nom}.md`.
|
||||||
|
|
||||||
|
**Informations requises** :
|
||||||
|
- Nom de la ou des crates à modifier
|
||||||
|
- Chemin relatif si nécessaire (ex: `pmocontrol/src/...`)
|
||||||
|
|
||||||
|
**En cas d'absence** :
|
||||||
|
- Le LLM demande clarification à l'humain
|
||||||
|
- Ne pas deviner ou supposer les crates concernées
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow Blackboard
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Blackboard/
|
||||||
|
├── ToThinkAbout/ # Réflexion, idées, architecture
|
||||||
|
├── Architecture/ # Documentation d'architecture validée
|
||||||
|
├── Todo/ # Tâches à réaliser
|
||||||
|
├── Report/ # Rapports de tâches réalisées
|
||||||
|
├── ToDiscuss/ # Tâches incomplètes nécessitant discussion
|
||||||
|
├── Done/ # Tâches terminées (synthèses)
|
||||||
|
└── Rules.md # Ce fichier
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cycle de vie d'une tâche
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
THINK[ToThinkAbout] -->|Spécification| TODO[Todo]
|
||||||
|
TODO -->|Implémentation| REPORT[Report]
|
||||||
|
REPORT -->|Humain décide| DONE[Done]
|
||||||
|
REPORT -->|Humain décide| DISCUSS[ToDiscuss]
|
||||||
|
DISCUSS -->|Reprise travail| REPORT
|
||||||
|
```
|
||||||
|
|
||||||
|
### Règles strictes
|
||||||
|
|
||||||
|
#### 1. Phase de réflexion (ToThinkAbout)
|
||||||
|
- **Collaboration** : Humain et LLM peuvent modifier
|
||||||
|
- **But** : Explorer idées, définir architecture
|
||||||
|
- **Sortie** : Documents de spécification → `Todo/`
|
||||||
|
|
||||||
|
#### 2. Phase de réalisation (Todo → Report)
|
||||||
|
- **Input** : Fichier `Todo/{nom}.md`
|
||||||
|
- **Action** : LLM implémente la tâche
|
||||||
|
- **Output** : Fichier `Report/{nom}.md` (même nom obligatoire)
|
||||||
|
- **Contenu du rapport** :
|
||||||
|
- Résumé du travail effectué
|
||||||
|
- Liste des fichiers créés/modifiés
|
||||||
|
- **INTERDIT** : Rapport détaillé dans la discussion (uniquement dans `Report/`)
|
||||||
|
|
||||||
|
#### 3. Décision humaine (Report → Done ou ToDiscuss)
|
||||||
|
|
||||||
|
**⚠️ CRITIQUE** : Seul l'humain décide de la destination. Le LLM ne doit JAMAIS déplacer ou classer une tâche.
|
||||||
|
|
||||||
|
**Cas 1 : Tâche complète** → `Done/`
|
||||||
|
- Humain déplace `Todo/{nom}.md` → `Done/{nom}.md`
|
||||||
|
- LLM crée une synthèse (tâche originale + rapport)
|
||||||
|
- Contenu final dans `Done/{nom}.md`
|
||||||
|
|
||||||
|
**Cas 2 : Tâche incomplète** → `ToDiscuss/`
|
||||||
|
- Humain déplace `Todo/{nom}.md` → `ToDiscuss/{nom}.md`
|
||||||
|
- Humain ajoute annotations/remarques dans `ToDiscuss/{nom}.md`
|
||||||
|
- Lors de la reprise :
|
||||||
|
- LLM lit les annotations
|
||||||
|
- Complète `Report/{nom}.md` avec les modifications
|
||||||
|
- Nouveau cycle de validation
|
||||||
|
|
||||||
|
#### 4. Documentation architecture (Architecture/)
|
||||||
|
- Contient les documents d'architecture validés et stables
|
||||||
|
- Référence pour patterns de code (ex: `pmoconfig_ext.md`, `pmoserver_ext.md`)
|
||||||
|
- Ne pas modifier sans validation explicite
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Versioning (Jujutsu)
|
||||||
|
|
||||||
|
**Système** : [Jujutsu (jj)](https://github.com/jj-vcs/jj)
|
||||||
|
**Repository** : `https://gargoton.petite-maison-orange.fr/eric/pmomusic.git`
|
||||||
|
|
||||||
|
### Commandes Makefile
|
||||||
|
|
||||||
|
| Commande | Action | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `make jjnew` | Nouveau commit | Documente le commit actuel (`jj auto-describe`) puis `jj new` |
|
||||||
|
| `make jjpush` | Push vers Git | Documente le commit puis `jj git push --change @`<br/>→ Crée branche + PR sur le serveur |
|
||||||
|
| `make jjfetch` | Récupération | `jj git fetch` puis `jj new main@origin`<br/>→ Après validation du PR |
|
||||||
|
|
||||||
|
### Gestion version
|
||||||
|
|
||||||
|
- **Source de vérité** : `PMOMusic/Cargo.toml`
|
||||||
|
- **Sync** : `version.txt` (généré par Makefile)
|
||||||
|
- **Incrémentation** : `make bump-version` (avant `jjpush`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist LLM
|
||||||
|
|
||||||
|
### Avant de commencer une tâche
|
||||||
|
- [ ] Lire `Todo/{nom}.md`
|
||||||
|
- [ ] Vérifier les références à `Architecture/` si mentionnées
|
||||||
|
- [ ] Comprendre les contraintes (HiFi, LossLess, UPnP/DLNA)
|
||||||
|
|
||||||
|
### Pendant la réalisation
|
||||||
|
- [ ] Suivre les patterns d'architecture existants
|
||||||
|
- [ ] Utiliser Rust (backend) ou Vue.js/TypeScript (frontend)
|
||||||
|
- [ ] Tester le code si applicable
|
||||||
|
|
||||||
|
### Après la réalisation
|
||||||
|
- [ ] Créer `Report/{nom}.md` (même nom que la tâche)
|
||||||
|
- [ ] Lister fichiers créés/modifiés
|
||||||
|
- [ ] **NE PAS** déplacer la tâche
|
||||||
|
- [ ] **NE PAS** écrire de rapport détaillé dans la discussion
|
||||||
|
- [ ] Attendre la décision humaine
|
||||||
|
|
||||||
|
### Si tâche en ToDiscuss
|
||||||
|
- [ ] Lire annotations ajoutées par l'humain
|
||||||
|
- [ ] Expliquer dans `Report/{nom}.md` comment les remarques sont prises en compte
|
||||||
|
- [ ] Reprise du cycle de validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diagrammes Mermaid
|
||||||
|
|
||||||
|
Tous les diagrammes d'architecture doivent utiliser Mermaid. La commande `make blackboard-html` génère une version HTML consultable avec rendu des diagrammes.
|
||||||
|
|
||||||
|
**Syntaxe stricte** :
|
||||||
|
- Labels de subgraph : `subgraph Name[Label]` (pas de guillemets doubles)
|
||||||
|
- Balises HTML : `Node["Text<br/>Multi"]` (guillemets doubles)
|
||||||
|
- Formes spéciales : `DB[("database")]`, `Decision{"Question?"}` (guillemets)
|
||||||
8
Blackboard/ToDiscuss/Pinnable_cache_item.md
Normal file
8
Blackboard/ToDiscuss/Pinnable_cache_item.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
|
||||||
|
|
||||||
|
|
||||||
|
La crâte PMOcache, implémente un system de cache qui pourrait être étendu pour permettre une utilisation plus large. L'idée est de modifier les règles de déletion des items. Actuellement le cache a une capacité maximale. Et les items ont des TTL, qui peuvent être non définies. Lorsque le cash est plein, les plus vieux items en termes d'utilisation ou ceux qui ont dépassé leur TTL peuvent être détruits. Je propose de rajouter une fonctionnalité qui permet d'épingler certains items pour les rendre non destructibles. Ils pourraient aussi sortir du comptage général des items pour savoir si le cache est plein.
|
||||||
|
|
||||||
|
Il faudra modifier la structure de la base de données. Ajouter une colonne indiquant cette propriété. Mettre une règle métier en disant qu'on ne peut pas être à la fois épinglés et avec un TTL.
|
||||||
|
|
||||||
|
On se moque de maintenir la compatibilité avec la base de données actuelle, il n'y a pas à prévoir de phase de transition. Nous sommes en période de développement.
|
||||||
21
Blackboard/ToDiscuss/pmoserver_ext.md
Normal file
21
Blackboard/ToDiscuss/pmoserver_ext.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
|
||||||
|
|
||||||
|
Partir des fichiers suivants:
|
||||||
|
|
||||||
|
- pmoapp/src/lib.rs
|
||||||
|
- pmocontrol/src/pmoserver_ext.rs
|
||||||
|
- pmoparadise/src/pmoserver_ext.rs
|
||||||
|
- pmoaudiocache/src/lib.rs
|
||||||
|
- pmomediaserver/src/paradise_streaming.rs
|
||||||
|
|
||||||
|
réalise une fiche descriptive sur le pattern à réaliser pour implémenter un trait d'extension du PMO serveur.
|
||||||
|
|
||||||
|
Le résultat sera une documentation d'implémentation qui sera placé dans le fichier: `Blackboard/Architecture/pmoserver_ext.md`
|
||||||
|
|
||||||
|
## Round 2
|
||||||
|
|
||||||
|
J'ai regardé ton document généré et je trouve que tu t'élargis du sujet central documenter lecture d'une extension PMOserver. Peux-tu te recentrer sur le sujet principal.
|
||||||
|
|
||||||
|
## Round 3
|
||||||
|
|
||||||
|
Je trouve que le fait de devoir déclarer et documenter les URL dans OpenAPI / utopia était quelque chose d'important. Remets le.
|
||||||
515
Blackboard/ToThinkAbout/MusicBoxSource.md
Normal file
515
Blackboard/ToThinkAbout/MusicBoxSource.md
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
|
||||||
|
|
||||||
|
# MusicBoxSource : Bibliothèque musicale universelle
|
||||||
|
|
||||||
|
Créer une **"boîte à musique"** personnelle : un catalogue unifié de morceaux provenant de n'importe quelle source (Qobuz, URLs, fichiers locaux, Radio Paradise, etc.), avec taxonomie de tags et playlists intelligentes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Vision
|
||||||
|
|
||||||
|
### Concept
|
||||||
|
|
||||||
|
**MusicBoxSource** est une bibliothèque musicale curatoriale qui permet de :
|
||||||
|
- **Collecter** : Ajouter des morceaux depuis n'importe quelle source PMOMusic ou URL
|
||||||
|
- **Organiser** : Classifier avec une taxonomie de tags extensible
|
||||||
|
- **Requêter** : Créer des playlists statiques et smart playlists (requêtes dynamiques)
|
||||||
|
- **Exposer** : Servir via UPnP/DIDL-Lite avec navigation multi-axes
|
||||||
|
|
||||||
|
### Différence avec `pmoplaylist`
|
||||||
|
|
||||||
|
- **`pmoplaylist`** : Playlists FIFO **éphémères** pour sources live (Radio Paradise)
|
||||||
|
- **`pmomusicbox`** : Bibliothèque **persistante** cross-sources avec métadonnées enrichies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏛️ Architecture globale
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Sources[Sources PMOMusic]
|
||||||
|
QOBUZ[pmoqobuz]
|
||||||
|
PARADISE[pmoparadise]
|
||||||
|
LOCAL[pmolocal - à créer]
|
||||||
|
URL[URLs directes]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Import[Import Layer]
|
||||||
|
IMPORTER[MusicBox Importer]
|
||||||
|
JSPF[pmojspf - Parser playlists]
|
||||||
|
META[pmometadata - Extraction]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Core[pmomusicbox Core]
|
||||||
|
DB[(SQLite Database)]
|
||||||
|
TAXONOMY[Taxonomie Tags]
|
||||||
|
QUERY[Smart Query Engine]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Cache[Cache Layer]
|
||||||
|
AUDIO[pmoaudiocache]
|
||||||
|
COVERS[pmocovers]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Export[Export UPnP]
|
||||||
|
SOURCE[MusicSource Trait]
|
||||||
|
DIDL[DIDL-Lite Generator]
|
||||||
|
BROWSE[Multi-Axis Browser]
|
||||||
|
end
|
||||||
|
|
||||||
|
Sources --> IMPORTER
|
||||||
|
URL --> IMPORTER
|
||||||
|
JSPF --> IMPORTER
|
||||||
|
META --> IMPORTER
|
||||||
|
|
||||||
|
IMPORTER --> DB
|
||||||
|
DB --> TAXONOMY
|
||||||
|
DB --> QUERY
|
||||||
|
|
||||||
|
DB <--> AUDIO
|
||||||
|
DB <--> COVERS
|
||||||
|
|
||||||
|
DB --> SOURCE
|
||||||
|
TAXONOMY --> BROWSE
|
||||||
|
QUERY --> BROWSE
|
||||||
|
SOURCE --> DIDL
|
||||||
|
BROWSE --> DIDL
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄️ Modèle de données (SQLite)
|
||||||
|
|
||||||
|
### Tables principales
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
TAG_CATEGORIES ||--o{ TAGS : contient
|
||||||
|
TAG_CATEGORIES ||--o{ TAG_CATEGORIES : parent
|
||||||
|
TAGS ||--o{ ITEM_TAGS : associe
|
||||||
|
MUSIC_ITEMS ||--o{ ITEM_TAGS : a
|
||||||
|
MUSIC_ITEMS ||--o{ PLAYLIST_ITEMS : dans
|
||||||
|
PLAYLISTS ||--o{ PLAYLIST_ITEMS : contient
|
||||||
|
|
||||||
|
TAG_CATEGORIES {
|
||||||
|
text id PK "Ex: mood, genre"
|
||||||
|
text name "Nom affiché"
|
||||||
|
text parent_id FK "Hiérarchie"
|
||||||
|
text color "Hex color"
|
||||||
|
text icon "Emoji/icon"
|
||||||
|
int display_order
|
||||||
|
}
|
||||||
|
|
||||||
|
TAGS {
|
||||||
|
text id PK "Ex: mood:energetic"
|
||||||
|
text category_id FK
|
||||||
|
text name "energetic, chill"
|
||||||
|
text description
|
||||||
|
text color "Override"
|
||||||
|
}
|
||||||
|
|
||||||
|
MUSIC_ITEMS {
|
||||||
|
text id PK "UUID"
|
||||||
|
text source_type "qobuz, url, local"
|
||||||
|
text source_id "ID source"
|
||||||
|
text original_uri "URI source"
|
||||||
|
text cache_audio_pk FK "pmoaudiocache"
|
||||||
|
text cache_cover_pk FK "pmocovers"
|
||||||
|
text title
|
||||||
|
text artist
|
||||||
|
text album
|
||||||
|
int year
|
||||||
|
int rating "1-5 étoiles"
|
||||||
|
int play_count
|
||||||
|
}
|
||||||
|
|
||||||
|
ITEM_TAGS {
|
||||||
|
text item_id PK,FK
|
||||||
|
text tag_id PK,FK
|
||||||
|
int added_at
|
||||||
|
text source "user, auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
PLAYLISTS {
|
||||||
|
text id PK
|
||||||
|
text name
|
||||||
|
bool is_smart
|
||||||
|
text smart_query "JSON"
|
||||||
|
}
|
||||||
|
|
||||||
|
PLAYLIST_ITEMS {
|
||||||
|
text playlist_id PK,FK
|
||||||
|
text item_id FK
|
||||||
|
int position PK
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tables d'association
|
||||||
|
|
||||||
|
- **`item_tags`** : Liens items ↔ tags (N:M)
|
||||||
|
- **`playlist_items`** : Items dans playlists statiques (position, ordre)
|
||||||
|
- **`tag_synonyms`** : Synonymes pour recherche (ex: "jazz" → "swing")
|
||||||
|
|
||||||
|
### Index & Recherche
|
||||||
|
|
||||||
|
- **Indexes B-tree** : artist, album, genre, year, rating, play_count
|
||||||
|
- **FTS5 (Full-Text Search)** : title, artist, album, comment
|
||||||
|
- **Triggers** : Maintien des tables FTS en sync avec `music_items`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Taxonomie par défaut
|
||||||
|
|
||||||
|
Catégories préchargées à l'initialisation :
|
||||||
|
|
||||||
|
| Catégorie | Description | Exemples de tags |
|
||||||
|
|-------------|----------------------------------|--------------------------------------------|
|
||||||
|
| **Mood** | État d'esprit, émotion | energetic, chill, melancholic, happy |
|
||||||
|
| **Genre** | Style musical | rock, jazz, classical, electronic, metal |
|
||||||
|
| **Era** | Période, décennie | 60s, 70s, 80s, 90s, contemporary |
|
||||||
|
| **Occasion**| Contexte d'écoute | workout, focus, party, driving, sleep |
|
||||||
|
| **Tempo** | Vitesse | slow, medium, fast |
|
||||||
|
| **Instrument** | Instrument dominant | piano, guitar, vocal, synthesizer |
|
||||||
|
| **Quality** | Qualité audio | lossless, high-res, remastered, live |
|
||||||
|
| **Origin** | Origine géographique | usa, uk, france, japan, latin, africa |
|
||||||
|
|
||||||
|
**Extensibilité** : L'utilisateur peut créer ses propres catégories et tags.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Crates architecture
|
||||||
|
|
||||||
|
### 1. **`pmojspf`** - Parser de playlists (utilitaire)
|
||||||
|
|
||||||
|
**But** : Parser/écrire différents formats de playlists vers/depuis un format pivot JSPF (JSON).
|
||||||
|
|
||||||
|
```
|
||||||
|
pmojspf/
|
||||||
|
├── model.rs # Structures JSPF (Playlist, Track, Meta)
|
||||||
|
├── reader/
|
||||||
|
│ ├── jspf.rs # JSON natif
|
||||||
|
│ ├── xspf.rs # XML (via quick-xml ou crate xspf)
|
||||||
|
│ ├── m3u.rs # M3U/M3U8 (parsing ligne par ligne)
|
||||||
|
│ └── pls.rs # PLS (format INI-like)
|
||||||
|
└── writer.rs # Export JSPF
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dépendances** : `serde`, `serde_json`, `quick-xml` (ou `xspf` crate)
|
||||||
|
|
||||||
|
**Usage** : Réutilisé par `pmomusicbox` pour import/export
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **`pmomusicbox`** - Bibliothèque musicale core
|
||||||
|
|
||||||
|
**Responsabilités** :
|
||||||
|
- Gestion base SQLite (CRUD items, tags, playlists)
|
||||||
|
- Import depuis sources PMO (Qobuz, Paradise, Local, URLs)
|
||||||
|
- Smart playlists (query builder + exécution SQL)
|
||||||
|
- Implémentation `MusicSource` trait (exposition UPnP)
|
||||||
|
- Intégration caches audio/covers
|
||||||
|
|
||||||
|
```
|
||||||
|
pmomusicbox/
|
||||||
|
├── db/
|
||||||
|
│ ├── schema.rs # DDL SQLite + migrations
|
||||||
|
│ ├── items.rs # CRUD music_items
|
||||||
|
│ ├── tags.rs # CRUD tags + taxonomie
|
||||||
|
│ ├── playlists.rs # CRUD playlists statiques
|
||||||
|
│ ├── smart.rs # Smart playlists
|
||||||
|
│ └── search.rs # Full-text search (FTS5)
|
||||||
|
│
|
||||||
|
├── import/
|
||||||
|
│ ├── url.rs # Import URL directe
|
||||||
|
│ ├── source.rs # Import depuis MusicSource
|
||||||
|
│ ├── local.rs # Import fichiers locaux (via pmometadata)
|
||||||
|
│ └── playlist.rs # Import JSPF/M3U8 (via pmojspf)
|
||||||
|
│
|
||||||
|
├── export/
|
||||||
|
│ └── playlist.rs # Export playlists (JSPF, M3U8)
|
||||||
|
│
|
||||||
|
├── query/
|
||||||
|
│ ├── builder.rs # SmartPlaylistQuery (DSL)
|
||||||
|
│ └── executor.rs # Génération + exécution SQL
|
||||||
|
│
|
||||||
|
├── didl/
|
||||||
|
│ └── generator.rs # Conversion items → DIDL-Lite
|
||||||
|
│
|
||||||
|
├── source.rs # Impl MusicSource trait
|
||||||
|
├── taxonomy.rs # Taxonomie par défaut + CRUD
|
||||||
|
└── config_ext.rs # Extension pmoconfig
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dépendances** :
|
||||||
|
- `pmosource`, `pmoaudiocache`, `pmocovers`, `pmodidl`, `pmometadata`
|
||||||
|
- `pmojspf` (import/export playlists)
|
||||||
|
- `rusqlite` (features: `bundled`, `serde_json`)
|
||||||
|
- `uuid`, `serde`, `tokio`, `async-trait`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **`pmolocal`** - Source fichiers locaux (à créer)
|
||||||
|
|
||||||
|
**But** : Scanner des répertoires locaux et exposer les fichiers audio via `MusicSource`.
|
||||||
|
|
||||||
|
```
|
||||||
|
pmolocal/
|
||||||
|
├── scanner.rs # Scan récursif de répertoires
|
||||||
|
├── watcher.rs # Hot reload (notify)
|
||||||
|
├── source.rs # Impl MusicSource
|
||||||
|
└── config_ext.rs # Extension pmoconfig
|
||||||
|
```
|
||||||
|
|
||||||
|
**Workflow** :
|
||||||
|
1. `pmolocal` scanne `/home/user/Music`
|
||||||
|
2. `pmomusicbox` importe les items découverts
|
||||||
|
3. Tags automatiques basés sur métadonnées (genre, année)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Flux d'import
|
||||||
|
|
||||||
|
### Import depuis une source PMO (ex: Qobuz)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant QS as Qobuz Source
|
||||||
|
participant MB as MusicBox Importer
|
||||||
|
participant DB as SQLite DB
|
||||||
|
participant AC as pmoaudiocache
|
||||||
|
participant CC as pmocovers
|
||||||
|
|
||||||
|
QS->>MB: get_item(object_id)
|
||||||
|
MB->>QS: resolve_uri(object_id)
|
||||||
|
|
||||||
|
Note over MB: 1. Extraire métadonnées DIDL-Lite<br/>2. Générer UUID
|
||||||
|
|
||||||
|
MB->>DB: INSERT INTO music_items
|
||||||
|
|
||||||
|
opt Auto-cache activé
|
||||||
|
MB->>AC: Cache audio
|
||||||
|
MB->>CC: Cache cover
|
||||||
|
AC-->>DB: Retourner cache_audio_pk
|
||||||
|
CC-->>DB: Retourner cache_cover_pk
|
||||||
|
end
|
||||||
|
|
||||||
|
MB-->>QS: item_id (UUID)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import URL directe
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
URL[URL simple] --> META["pmometadata<br/>Extraction"]
|
||||||
|
META --> UUID[Générer UUID]
|
||||||
|
UUID --> DB[("music_items")]
|
||||||
|
DB --> CACHE{"Auto-cache?"}
|
||||||
|
CACHE -->|Oui| AC[pmoaudiocache]
|
||||||
|
CACHE -->|Non| END[Fin]
|
||||||
|
AC --> END
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import playlist JSPF/M3U8
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
FILE[Fichier playlist] --> JSPF["pmojspf<br/>Parser"]
|
||||||
|
JSPF --> STRUCT[Structure JSPF]
|
||||||
|
STRUCT --> LOOP{"Pour chaque track"}
|
||||||
|
LOOP --> IMPORT[Import comme URL]
|
||||||
|
IMPORT --> DB[("music_items")]
|
||||||
|
DB --> PLAYLIST[Créer playlist statique]
|
||||||
|
PLAYLIST --> LINK[Lier tracks à playlist]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Smart Playlists (Query DSL)
|
||||||
|
|
||||||
|
### Concept
|
||||||
|
|
||||||
|
Les smart playlists sont des **requêtes sauvegardées** qui génèrent dynamiquement une liste de tracks.
|
||||||
|
|
||||||
|
### Structure de requête (JSON)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"include_all_tags": ["mood:energetic", "genre:rock"],
|
||||||
|
"exclude_tags": ["mood:melancholic"],
|
||||||
|
"year_min": 1980,
|
||||||
|
"year_max": 1989,
|
||||||
|
"min_rating": 4,
|
||||||
|
"lossless_only": true,
|
||||||
|
"order_by": "play_count",
|
||||||
|
"order": "desc",
|
||||||
|
"limit": 50
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Traduction SQL
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM music_items
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT item_id FROM item_tags WHERE tag_id IN ('mood:energetic', 'genre:rock')
|
||||||
|
GROUP BY item_id HAVING COUNT(DISTINCT tag_id) = 2 -- ALL tags
|
||||||
|
)
|
||||||
|
AND id NOT IN (
|
||||||
|
SELECT item_id FROM item_tags WHERE tag_id = 'mood:melancholic'
|
||||||
|
)
|
||||||
|
AND year BETWEEN 1980 AND 1989
|
||||||
|
AND rating >= 4
|
||||||
|
AND codec IN ('flac', 'alac')
|
||||||
|
ORDER BY play_count DESC
|
||||||
|
LIMIT 50;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎭 Exposition UPnP (MusicSource)
|
||||||
|
|
||||||
|
### Structure de navigation
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
ROOT[musicbox/] --> ARTIST[by-artist/]
|
||||||
|
ROOT --> ALBUM[by-album/]
|
||||||
|
ROOT --> GENRE[by-genre/]
|
||||||
|
ROOT --> TAG[by-tag/]
|
||||||
|
ROOT --> PLAYLISTS[playlists/]
|
||||||
|
ROOT --> SMART[smart-playlists/]
|
||||||
|
ROOT --> FAV[favorites/]
|
||||||
|
ROOT --> RECENT[recent/]
|
||||||
|
|
||||||
|
ARTIST --> PF[Pink Floyd/]
|
||||||
|
ARTIST --> Q[Queen/]
|
||||||
|
PF --> WALL[The Wall/]
|
||||||
|
PF --> WYWH[Wish You Were Here/]
|
||||||
|
WALL --> ITEM1[Another Brick... 🎵]
|
||||||
|
|
||||||
|
TAG --> MOOD[mood/]
|
||||||
|
TAG --> OCC[occasion/]
|
||||||
|
TAG --> ERA[era/]
|
||||||
|
|
||||||
|
MOOD --> ENRG[energetic/]
|
||||||
|
MOOD --> CHILL[chill/]
|
||||||
|
ENRG --> ITEMS1[items taggués 🎵]
|
||||||
|
|
||||||
|
OCC --> WORK[workout/]
|
||||||
|
OCC --> FOCUS[focus/]
|
||||||
|
|
||||||
|
ERA --> E80[80s/]
|
||||||
|
ERA --> E90[90s/]
|
||||||
|
|
||||||
|
PLAYLISTS --> PL1[My Favorites/]
|
||||||
|
PLAYLISTS --> PL2[Summer 2024/]
|
||||||
|
|
||||||
|
SMART --> SP1[80s Rock Workout/]
|
||||||
|
SMART --> SP2[Jazz Dinner/]
|
||||||
|
|
||||||
|
style ITEM1 fill:#e1f5ff
|
||||||
|
style ITEMS1 fill:#e1f5ff
|
||||||
|
```
|
||||||
|
|
||||||
|
### Object IDs
|
||||||
|
|
||||||
|
```
|
||||||
|
musicbox:by-artist:{artist_name}
|
||||||
|
musicbox:by-album:{album_id}
|
||||||
|
musicbox:by-tag:{category}:{tag_name}
|
||||||
|
musicbox:playlist:{playlist_id}
|
||||||
|
musicbox:smart:{smart_playlist_id}
|
||||||
|
musicbox:item:{item_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 Intégration avec l'écosystème PMOMusic
|
||||||
|
|
||||||
|
### Avec pmoaudiocache
|
||||||
|
|
||||||
|
- Import → Déclencher cache automatique (si `auto_cache: true`)
|
||||||
|
- `resolve_uri()` → Retourner URI cachée si disponible
|
||||||
|
|
||||||
|
### Avec pmocovers
|
||||||
|
|
||||||
|
- Import → Télécharger cover art
|
||||||
|
- Browse → Inclure `album_art` dans DIDL-Lite
|
||||||
|
|
||||||
|
### Avec pmoserver (feature `server`)
|
||||||
|
|
||||||
|
- API REST pour manipulation (CRUD items, tags, playlists)
|
||||||
|
- SSE pour notifications de changements
|
||||||
|
- Endpoints OpenAPI (utoipa)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Plan d'implémentation (Phases)
|
||||||
|
|
||||||
|
### Phase 1 : Fondations
|
||||||
|
- Schéma SQLite complet
|
||||||
|
- Crate `pmojspf` (parser playlists)
|
||||||
|
- CRUD basique dans `pmomusicbox` (items, tags)
|
||||||
|
- Taxonomie par défaut
|
||||||
|
- Import URL simple
|
||||||
|
- Extension pmoconfig
|
||||||
|
|
||||||
|
### Phase 2 : Import cross-sources
|
||||||
|
- Import depuis MusicSource (Qobuz, Paradise)
|
||||||
|
- Import playlists (JSPF/M3U8)
|
||||||
|
- Intégration caches (audio, covers)
|
||||||
|
- Crate `pmolocal` (fichiers locaux)
|
||||||
|
|
||||||
|
### Phase 3 : Smart Playlists
|
||||||
|
- Query builder (DSL)
|
||||||
|
- Exécuteur SQL
|
||||||
|
- CRUD smart playlists
|
||||||
|
- Export JSPF
|
||||||
|
|
||||||
|
### Phase 4 : MusicSource UPnP
|
||||||
|
- Implémentation trait `MusicSource`
|
||||||
|
- Génération DIDL-Lite
|
||||||
|
- Browse multi-axes (artist, album, tag)
|
||||||
|
- Recherche full-text (FTS5)
|
||||||
|
|
||||||
|
### Phase 5 : Fonctionnalités avancées
|
||||||
|
- Statistiques d'écoute (play_count, last_played)
|
||||||
|
- Auto-tagging (genre depuis métadonnées)
|
||||||
|
- API REST (feature `server`)
|
||||||
|
- Recommandations (items similaires)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Cas d'usage
|
||||||
|
|
||||||
|
### Workflow typique
|
||||||
|
|
||||||
|
1. **Découverte** : Écouter Radio Paradise, tomber sur un morceau génial
|
||||||
|
2. **Ajout** : `musicbox.import_from_source(¶dise, "track-123")`
|
||||||
|
3. **Organisation** : Ajouter tags `mood:chill`, `occasion:focus`
|
||||||
|
4. **Playlist** : Smart playlist "Focus Music" avec requête `mood:chill + occasion:focus`
|
||||||
|
5. **Écoute** : Naviguer dans UPnP → `musicbox/smart-playlists/Focus Music/`
|
||||||
|
|
||||||
|
### Scénario : Bibliothèque mixte
|
||||||
|
|
||||||
|
- Albums Qobuz haute résolution
|
||||||
|
- Playlists M3U8 importées depuis iTunes
|
||||||
|
- Fichiers FLAC locaux scannés
|
||||||
|
- URLs de SoundCloud
|
||||||
|
- Tracks Radio Paradise capturés
|
||||||
|
|
||||||
|
**Tout unifié dans MusicBox, accessible via UPnP, organisé par tags.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Références
|
||||||
|
|
||||||
|
### Standards
|
||||||
|
- [JSPF Spec](https://www.xspf.org/jspf)
|
||||||
|
- [XSPF Spec](https://www.xspf.org/spec)
|
||||||
|
- [SQLite FTS5](https://www.sqlite.org/fts5.html)
|
||||||
|
|
||||||
|
### Inspirations
|
||||||
|
- [Beets](https://beets.io/) - Music library manager
|
||||||
|
- [Navidrome](https://www.navidrome.org/) - Music server
|
||||||
|
- [MusicBrainz Picard](https://picard.musicbrainz.org/) - Tagger
|
||||||
570
Blackboard/ToThinkAbout/PlayListSource.md
Normal file
570
Blackboard/ToThinkAbout/PlayListSource.md
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
|
||||||
|
|
||||||
|
# PlaylistSource : MusicSource pour playlists
|
||||||
|
|
||||||
|
Implémenter une source PMOMusic capable de servir un catalogue de playlists hiérarchisé via UPnP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Décisions de conception
|
||||||
|
|
||||||
|
### Format pivot : JSPF (JSON)
|
||||||
|
|
||||||
|
**Choix** : JSPF comme format interne central
|
||||||
|
- Métadonnées riches (title, creator, album, annotation, image, duration, etc.)
|
||||||
|
- JSON natif avec serde (Rust-friendly)
|
||||||
|
- Standard ouvert (Xiph.Org)
|
||||||
|
- Extensible via champ `meta`
|
||||||
|
|
||||||
|
**Formats supportés** :
|
||||||
|
- ✅ **JSPF** (.jspf) - JSON, format natif
|
||||||
|
- ✅ **XSPF** (.xspf) - XML, conversion vers JSPF
|
||||||
|
- ✅ **M3U8** (.m3u8) - Texte, métadonnées limitées
|
||||||
|
- ✅ **PLS** (.pls) - INI-like, très basique
|
||||||
|
|
||||||
|
**Architecture** : 1 Writer (JSPF) + 4 Readers (JSPF, XSPF, M3U8, PLS) → Structure JSPF centrale
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
JSPF[JSPF JSON] --> JR[JspfReader]
|
||||||
|
XSPF[XSPF XML] --> XR[XspfReader]
|
||||||
|
M3U8[M3U8 Text] --> MR[M3uReader]
|
||||||
|
PLS[PLS INI] --> PR[PlsReader]
|
||||||
|
|
||||||
|
JR --> CORE[JSPF Structure]
|
||||||
|
XR --> CORE
|
||||||
|
MR --> CORE
|
||||||
|
PR --> CORE
|
||||||
|
|
||||||
|
CORE --> W[JspfWriter]
|
||||||
|
W --> OUT[.jspf]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂️ Structure du répertoire
|
||||||
|
|
||||||
|
```
|
||||||
|
playlists/
|
||||||
|
├── metadata.json # Métadonnées du conteneur racine
|
||||||
|
├── Jazz/
|
||||||
|
│ ├── metadata.json # Métadonnées catégorie Jazz
|
||||||
|
│ ├── standards.jspf
|
||||||
|
│ ├── bebop.jspf
|
||||||
|
│ └── covers/
|
||||||
|
│ └── standards.webp
|
||||||
|
├── Classical/
|
||||||
|
│ ├── metadata.json
|
||||||
|
│ ├── baroque.jspf
|
||||||
|
│ └── romantic.jspf
|
||||||
|
└── Rock/
|
||||||
|
├── metadata.json
|
||||||
|
└── 70s.jspf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fichier `metadata.json` (conteneur)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"container": {
|
||||||
|
"title": "Collection Jazz",
|
||||||
|
"description": "Mes playlists jazz favorites",
|
||||||
|
"creator": "John Doe",
|
||||||
|
"image": "covers/jazz-collection.webp",
|
||||||
|
"date": "2026-01-15",
|
||||||
|
"meta": [
|
||||||
|
{"rel": "genre", "content": "Jazz"},
|
||||||
|
{"rel": "mood", "content": "Relaxing"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Composants à implémenter
|
||||||
|
|
||||||
|
### 1. Crate `pmojspf` (parsing playlists)
|
||||||
|
|
||||||
|
**Responsabilité** : Parser différents formats de playlist vers structure JSPF unifiée
|
||||||
|
|
||||||
|
#### Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
pmojspf/
|
||||||
|
├── Cargo.toml
|
||||||
|
├── src/
|
||||||
|
│ ├── lib.rs # API publique
|
||||||
|
│ ├── model.rs # Structures JSPF
|
||||||
|
│ ├── writer.rs # JspfWriter
|
||||||
|
│ ├── reader/
|
||||||
|
│ │ ├── mod.rs # Trait PlaylistReader
|
||||||
|
│ │ ├── jspf.rs # Reader JSON natif (serde_json)
|
||||||
|
│ │ ├── xspf.rs # Reader XML (xml-rs)
|
||||||
|
│ │ ├── m3u.rs # Reader M3U8 (parsing ligne par ligne)
|
||||||
|
│ │ └── pls.rs # Reader PLS (format INI-like)
|
||||||
|
│ └── error.rs
|
||||||
|
└── tests/
|
||||||
|
└── fixtures/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Modèle de données
|
||||||
|
|
||||||
|
**Inspiré de la crate [xspf](https://crates.io/crates/xspf) v0.4.2**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Jspf {
|
||||||
|
pub playlist: JspfPlaylist,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct JspfPlaylist {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub creator: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub annotation: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub info: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub location: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub identifier: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub image: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub date: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub license: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||||
|
pub attribution: Vec<JspfAttribution>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||||
|
pub meta: Vec<JspfMeta>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub track: Vec<JspfTrack>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct JspfTrack {
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||||
|
pub location: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||||
|
pub identifier: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub creator: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub annotation: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub info: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub image: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub album: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub track_num: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration: Option<u64>, // millisecondes
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||||
|
pub meta: Vec<JspfMeta>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum JspfAttribution {
|
||||||
|
Location { location: String },
|
||||||
|
Identifier { identifier: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct JspfMeta {
|
||||||
|
pub rel: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Trait PlaylistReader
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
pub trait PlaylistReader {
|
||||||
|
fn read<R: Read>(reader: R) -> Result<Jspf>;
|
||||||
|
fn from_str(s: &str) -> Result<Jspf>;
|
||||||
|
fn from_file<P: AsRef<Path>>(path: P) -> Result<Jspf>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Implémentations des Readers
|
||||||
|
|
||||||
|
##### JspfReader (✅ Simple - serde_json)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct JspfReader;
|
||||||
|
|
||||||
|
impl PlaylistReader for JspfReader {
|
||||||
|
fn read<R: Read>(reader: R) -> Result<Jspf> {
|
||||||
|
serde_json::from_reader(reader)
|
||||||
|
.map_err(|e| Error::ParseError(format!("JSON: {}", e)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dépendances** : `serde_json`
|
||||||
|
|
||||||
|
##### XspfReader (⚠️ Complexe - xml-rs)
|
||||||
|
|
||||||
|
**Approche** : Machine à états XML pour parser `<playlist>`, `<track>`, etc.
|
||||||
|
|
||||||
|
**Alternative** : Utiliser la crate `xspf` existante puis convertir → JSPF
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct XspfReader;
|
||||||
|
|
||||||
|
impl PlaylistReader for XspfReader {
|
||||||
|
fn read<R: Read>(reader: R) -> Result<Jspf> {
|
||||||
|
// Parser XML avec EventReader
|
||||||
|
// État : in_playlist, in_track, current_element
|
||||||
|
// Mapping: <title> → playlist.title, <track> → JspfTrack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dépendances** : `xml-rs` ou réutiliser `xspf` crate
|
||||||
|
|
||||||
|
##### M3uReader (⚙️ Modéré - ligne par ligne)
|
||||||
|
|
||||||
|
**Format** :
|
||||||
|
```m3u
|
||||||
|
#EXTM3U
|
||||||
|
#PLAYLIST:Ma Playlist Jazz
|
||||||
|
#EXTINF:284,John Coltrane - Giant Steps
|
||||||
|
#EXTART:John Coltrane
|
||||||
|
#EXTALB:Giant Steps
|
||||||
|
file:///music/coltrane.flac
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct M3uReader;
|
||||||
|
|
||||||
|
impl PlaylistReader for M3uReader {
|
||||||
|
fn read<R: Read>(reader: R) -> Result<Jspf> {
|
||||||
|
// BufReader ligne par ligne
|
||||||
|
// Parser #EXTINF:duration,artist - title
|
||||||
|
// Gérer extensions non-standard (#EXTART, #EXTALB, #EXTIMG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dépendances** : stdlib uniquement
|
||||||
|
|
||||||
|
**Limitations** : Métadonnées pauvres, beaucoup de champs `None`
|
||||||
|
|
||||||
|
##### PlsReader (⚙️ Modéré - format INI)
|
||||||
|
|
||||||
|
**Format** :
|
||||||
|
```ini
|
||||||
|
[playlist]
|
||||||
|
NumberOfEntries=2
|
||||||
|
File1=file:///music/coltrane.flac
|
||||||
|
Title1=John Coltrane - Giant Steps
|
||||||
|
Length1=284
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PlsReader;
|
||||||
|
|
||||||
|
impl PlaylistReader for PlsReader {
|
||||||
|
fn read<R: Read>(reader: R) -> Result<Jspf> {
|
||||||
|
// HashMap<index, (file, title, duration)>
|
||||||
|
// Parser FileN=..., TitleN=..., LengthN=...
|
||||||
|
// Trier par index et convertir en JspfTrack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dépendances** : stdlib uniquement
|
||||||
|
|
||||||
|
**Limitations** : File, Title, Length seulement
|
||||||
|
|
||||||
|
#### JspfWriter
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct JspfWriter;
|
||||||
|
|
||||||
|
impl JspfWriter {
|
||||||
|
pub fn write<W: Write>(jspf: &Jspf, writer: W) -> Result<()>;
|
||||||
|
pub fn write_pretty<W: Write>(jspf: &Jspf, writer: W) -> Result<()>;
|
||||||
|
pub fn to_string(jspf: &Jspf) -> Result<String>;
|
||||||
|
pub fn to_string_pretty(jspf: &Jspf) -> Result<String>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### API publique
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub use model::{Jspf, JspfPlaylist, JspfTrack, JspfMeta, JspfAttribution};
|
||||||
|
pub use reader::{PlaylistReader, JspfReader, XspfReader, M3uReader, PlsReader};
|
||||||
|
pub use writer::JspfWriter;
|
||||||
|
|
||||||
|
pub enum PlaylistFormat {
|
||||||
|
Jspf,
|
||||||
|
Xspf,
|
||||||
|
M3u8,
|
||||||
|
Pls,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaylistFormat {
|
||||||
|
pub fn from_extension(ext: &str) -> Option<Self>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_playlist<R: Read>(reader: R, format: PlaylistFormat) -> Result<Jspf>;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Crate `pmoplaylists` (PlaylistSource)
|
||||||
|
|
||||||
|
**Responsabilité** : Implémenter `MusicSource` pour servir playlists via UPnP
|
||||||
|
|
||||||
|
#### Structures principales
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PlaylistSource {
|
||||||
|
root_path: PathBuf,
|
||||||
|
playlists: Arc<RwLock<HashMap<String, ParsedPlaylist>>>,
|
||||||
|
containers: Arc<RwLock<HashMap<PathBuf, ContainerMetadata>>>,
|
||||||
|
watcher: Option<notify::RecommendedWatcher>,
|
||||||
|
base_url: String,
|
||||||
|
update_counter: Arc<RwLock<u32>>,
|
||||||
|
last_change: Arc<RwLock<SystemTime>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ParsedPlaylist {
|
||||||
|
pub metadata: PlaylistMetadata,
|
||||||
|
pub tracks: Vec<PlaylistTrack>,
|
||||||
|
pub source_path: PathBuf,
|
||||||
|
pub format: PlaylistFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ContainerMetadata {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub creator: Option<String>,
|
||||||
|
pub image: Option<String>,
|
||||||
|
pub date: Option<String>,
|
||||||
|
pub meta: Vec<MetaEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ContainerMetadataFile {
|
||||||
|
pub container: ContainerMetadata,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Fonctionnalités
|
||||||
|
|
||||||
|
1. **Scan hiérarchique** : Parser récursivement dossiers + `metadata.json` + playlists
|
||||||
|
2. **Cache** : Éviter re-parsing (playlists + conteneurs)
|
||||||
|
3. **Hot reload** : `notify` pour détecter changements
|
||||||
|
4. **Browse UPnP** : Générer DIDL-Lite avec métadonnées conteneurs
|
||||||
|
5. **Content resolution** : Résoudre URIs via `SourceCacheManager`
|
||||||
|
6. **Cover art** : Servir images playlists, tracks, conteneurs
|
||||||
|
|
||||||
|
#### Object IDs
|
||||||
|
|
||||||
|
```
|
||||||
|
playlists # Racine
|
||||||
|
playlists:category:{path} # Catégorie (dossier)
|
||||||
|
playlists:playlist:{id} # Playlist
|
||||||
|
playlists:playlist:{id}:track:{index} # Track dans playlist
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Gestion `metadata.json`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn load_container_metadata(&self, dir_path: &Path) -> Result<ContainerMetadata> {
|
||||||
|
let metadata_path = dir_path.join("metadata.json");
|
||||||
|
|
||||||
|
if metadata_path.exists() {
|
||||||
|
let content = fs::read_to_string(&metadata_path)?;
|
||||||
|
let file: ContainerMetadataFile = serde_json::from_str(&content)?;
|
||||||
|
Ok(file.container)
|
||||||
|
} else {
|
||||||
|
// Fallback : nom du répertoire
|
||||||
|
Ok(ContainerMetadata {
|
||||||
|
title: Some(dir_path.file_name()?.to_str()?.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Extension pmoconfig
|
||||||
|
|
||||||
|
**Fichier** : `pmoplaylists/src/config_ext.rs`
|
||||||
|
|
||||||
|
**Pattern** : [pmoconfig_ext.md](../Architecture/pmoconfig_ext.md)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pmoconfig::Config;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const DEFAULT_PLAYLISTS_DIR: &str = "playlists";
|
||||||
|
|
||||||
|
pub trait PlaylistSourceConfigExt {
|
||||||
|
fn get_playlists_dir(&self) -> PathBuf;
|
||||||
|
fn set_playlists_dir<P: AsRef<Path>>(&self, path: P) -> anyhow::Result<()>;
|
||||||
|
fn get_playlists_enabled(&self) -> bool;
|
||||||
|
fn set_playlists_enabled(&self, enabled: bool) -> anyhow::Result<()>;
|
||||||
|
fn get_playlists_supported_formats(&self) -> Vec<String>;
|
||||||
|
fn set_playlists_supported_formats(&self, formats: Vec<String>) -> anyhow::Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaylistSourceConfigExt for Config {
|
||||||
|
fn get_playlists_dir(&self) -> PathBuf {
|
||||||
|
self.get_managed_dir("sources.playlists.directory", DEFAULT_PLAYLISTS_DIR)
|
||||||
|
.expect("Failed to get playlists directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_playlists_dir<P: AsRef<Path>>(&self, path: P) -> anyhow::Result<()> {
|
||||||
|
self.set_managed_dir("sources.playlists.directory", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_playlists_enabled(&self) -> bool {
|
||||||
|
self.get_value("sources.playlists.enabled")
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
let _ = self.set_value("sources.playlists.enabled", true);
|
||||||
|
true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_playlists_enabled(&self, enabled: bool) -> anyhow::Result<()> {
|
||||||
|
self.set_value("sources.playlists.enabled", enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_playlists_supported_formats(&self) -> Vec<String> {
|
||||||
|
self.get_value("sources.playlists.formats")
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
let default = vec!["jspf".into(), "xspf".into(), "m3u8".into(), "pls".into()];
|
||||||
|
let _ = self.set_value("sources.playlists.formats", &default);
|
||||||
|
default
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_playlists_supported_formats(&self, formats: Vec<String>) -> anyhow::Result<()> {
|
||||||
|
self.set_value("sources.playlists.formats", formats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Config YAML** :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sources:
|
||||||
|
playlists:
|
||||||
|
enabled: true
|
||||||
|
directory: "playlists"
|
||||||
|
formats:
|
||||||
|
- jspf
|
||||||
|
- xspf
|
||||||
|
- m3u8
|
||||||
|
- pls
|
||||||
|
```
|
||||||
|
|
||||||
|
**Utilisation** :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pmoconfig::Config;
|
||||||
|
use pmoplaylists::config_ext::PlaylistSourceConfigExt;
|
||||||
|
|
||||||
|
let config = Config::load()?;
|
||||||
|
|
||||||
|
if config.get_playlists_enabled() {
|
||||||
|
let playlists_dir = config.get_playlists_dir();
|
||||||
|
let playlist_source = PlaylistSource::new(playlists_dir, config.clone())?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 Intégration MusicBrainz (optionnelle - Phase 2)
|
||||||
|
|
||||||
|
### Crate recommandée : `musicbrainz_rs`
|
||||||
|
|
||||||
|
[musicbrainz_rs](https://crates.io/crates/musicbrainz_rs) v0.5+
|
||||||
|
- Client async/blocking
|
||||||
|
- Rate limiting automatique (1 req/sec)
|
||||||
|
- Support CoverArt Archive
|
||||||
|
- MSRV: Rust 1.71.1
|
||||||
|
|
||||||
|
### Cas d'usage
|
||||||
|
|
||||||
|
1. **Résolution d'identifiants** :
|
||||||
|
```json
|
||||||
|
{"identifier": ["musicbrainz://recording/abc123"], "title": null}
|
||||||
|
```
|
||||||
|
→ Récupérer métadonnées depuis MusicBrainz
|
||||||
|
|
||||||
|
2. **Enrichissement playlists pauvres** : M3U8/PLS → MusicBrainz → métadonnées complètes
|
||||||
|
|
||||||
|
3. **Cover art** : CoverArt Archive
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sources:
|
||||||
|
playlists:
|
||||||
|
musicbrainz:
|
||||||
|
enabled: false
|
||||||
|
enrich_metadata: false
|
||||||
|
rate_limit_per_sec: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stratégie** :
|
||||||
|
- **Phase 1 (MVP)** : Ne pas implémenter, stocker identifiants tel quel
|
||||||
|
- **Phase 2** : Dépendance optionnelle, service asynchrone, configurable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Prochaines étapes
|
||||||
|
|
||||||
|
1. ✅ Choix format : JSPF central
|
||||||
|
2. ✅ Modèle données : Structures JSPF
|
||||||
|
3. ✅ Extension pmoconfig : Trait défini
|
||||||
|
4. ⏳ **Implémenter `pmojspf`** :
|
||||||
|
- `JspfReader` (serde_json)
|
||||||
|
- `XspfReader` (xml-rs ou crate xspf)
|
||||||
|
- `M3uReader` (parsing ligne par ligne)
|
||||||
|
- `PlsReader` (format INI)
|
||||||
|
- `JspfWriter` (serde_json)
|
||||||
|
5. ⏳ **Implémenter `pmoplaylists`** :
|
||||||
|
- `PlaylistSource` (trait `MusicSource`)
|
||||||
|
- Scan hiérarchique + cache
|
||||||
|
- Hot reload (notify)
|
||||||
|
- Browse UPnP (DIDL-Lite)
|
||||||
|
- Gestion `metadata.json`
|
||||||
|
6. ⏳ Tests avec clients UPnP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Sources
|
||||||
|
|
||||||
|
### Spécifications
|
||||||
|
- [XSPF Spec](https://www.xspf.org/spec)
|
||||||
|
- [JSPF Spec](https://www.xspf.org/jspf)
|
||||||
|
- [M3U - Wikipedia](https://en.wikipedia.org/wiki/M3U)
|
||||||
|
- [PLS - Wikipedia](https://en.wikipedia.org/wiki/PLS_(file_format))
|
||||||
|
|
||||||
|
### Crates Rust
|
||||||
|
- [xspf](https://crates.io/crates/xspf) - Parser XML XSPF
|
||||||
|
- [musicbrainz_rs](https://crates.io/crates/musicbrainz_rs) - API MusicBrainz
|
||||||
|
- [MusicBrainz API Docs](https://musicbrainz.org/doc/MusicBrainz_API)
|
||||||
17
Blackboard/Todo/config_ext.md
Normal file
17
Blackboard/Todo/config_ext.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
|
||||||
|
|
||||||
|
Partir des fichiers suivants:
|
||||||
|
|
||||||
|
- pmocovers/src/config_ext.rs
|
||||||
|
- pmoaudiocache/src/config_ext.rs
|
||||||
|
- pmoqobuz/src/config_ext.rs
|
||||||
|
- pmocache/src/config_ext.rs
|
||||||
|
- pmoconfig/PASSWORD_ENCRYPTION.md
|
||||||
|
- pmoupnp/src/config_ext.rs
|
||||||
|
- pmoparadise/src/config_ext.rs
|
||||||
|
|
||||||
|
réalise une fiche descriptive sur le pattern à réaliser pour implémenter un trait d'extension de PMOConfig (pmoconfig::Config).
|
||||||
|
|
||||||
|
Le résultat sera une documentation d'implémentation qui sera placé dans le fichier: `Blackboard/Architecture/pmoconfig_ext.md`
|
||||||
|
|
||||||
|
Reste bien focalisé sur l'objectif principal.
|
||||||
14
Blackboard/Todo/music_source.md
Normal file
14
Blackboard/Todo/music_source.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
|
||||||
|
|
||||||
|
Partir des fichiers suivants:
|
||||||
|
|
||||||
|
- pmoparadise/src/source.rs
|
||||||
|
- pmoqobuz/src/source.rs
|
||||||
|
- pmosource/README.md
|
||||||
|
- pmosource/ARCHITECTURE.md
|
||||||
|
|
||||||
|
D'écrire dans un fichier d'architecture L'implémentation d'une nouvelle MusicSource.
|
||||||
|
|
||||||
|
Le résultat sera une documentation d'implémentation qui sera placé dans le fichier: `Blackboard/Architecture/music_source.md`
|
||||||
|
|
||||||
|
Reste bien focalisé sur l'objectif principal.
|
||||||
934
Blackboard_HTML/Architecture_music_source.html
Normal file
934
Blackboard_HTML/Architecture_music_source.html
Normal file
@@ -0,0 +1,934 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>music_source</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
||||||
|
<script type="module">
|
||||||
|
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||||
|
mermaid.initialize({startOnLoad: true, theme: "default"});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
pre.mermaid {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="markdown-body">
|
||||||
|
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
|
||||||
|
<h1 id="guide-dimplémentation-dune-nouvelle-musicsource">Guide
|
||||||
|
d’implémentation d’une nouvelle MusicSource</h1>
|
||||||
|
<p>Ce document décrit comment implémenter une nouvelle source musicale
|
||||||
|
dans l’écosystème PMOMusic en suivant le trait <code>MusicSource</code>
|
||||||
|
défini dans le crate <code>pmosource</code>.</p>
|
||||||
|
<h2 id="table-des-matières">Table des matières</h2>
|
||||||
|
<ol type="1">
|
||||||
|
<li><a href="#vue-densemble">Vue d’ensemble</a></li>
|
||||||
|
<li><a href="#structure-dune-musicsource">Structure d’une
|
||||||
|
MusicSource</a></li>
|
||||||
|
<li><a href="#implémentation-du-trait-musicsource">Implémentation du
|
||||||
|
trait MusicSource</a></li>
|
||||||
|
<li><a href="#patterns-dimplémentation">Patterns
|
||||||
|
d’implémentation</a></li>
|
||||||
|
<li><a href="#intégration-avec-lécosystème-pmomusic">Intégration avec
|
||||||
|
l’écosystème PMOMusic</a></li>
|
||||||
|
<li><a href="#checklist-de-mise-en-œuvre">Checklist de mise en
|
||||||
|
œuvre</a></li>
|
||||||
|
<li><a href="#exemples-de-référence">Exemples de référence</a></li>
|
||||||
|
</ol>
|
||||||
|
<h2 id="vue-densemble">Vue d’ensemble</h2>
|
||||||
|
<p>Une <code>MusicSource</code> est une abstraction qui représente une
|
||||||
|
source de contenu musical dans PMOMusic. Elle peut être :</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Dynamique (FIFO)</strong> : Radio Paradise, streaming radio,
|
||||||
|
playlists live</li>
|
||||||
|
<li><strong>Statique</strong> : Albums Qobuz, bibliothèque locale,
|
||||||
|
playlists fixes</li>
|
||||||
|
</ul>
|
||||||
|
<p>Le trait <code>MusicSource</code> définit une interface unifiée pour
|
||||||
|
: - La navigation UPnP ContentDirectory (browse) - La résolution d’URI
|
||||||
|
audio (avec cache) - La gestion de playlists FIFO (pour les sources
|
||||||
|
dynamiques) - Le suivi des changements (update_id, last_change)</p>
|
||||||
|
<h2 id="structure-dune-musicsource">Structure d’une MusicSource</h2>
|
||||||
|
<h3 id="organisation-du-code">Organisation du code</h3>
|
||||||
|
<pre><code>pmo<votre-source>/
|
||||||
|
├── src/
|
||||||
|
│ ├── lib.rs # Exports publics
|
||||||
|
│ ├── source.rs # Implémentation MusicSource
|
||||||
|
│ ├── client.rs # Client API (optionnel)
|
||||||
|
│ ├── models.rs # Structures de données
|
||||||
|
│ ├── config.rs # Configuration
|
||||||
|
│ └── didl.rs # Conversion DIDL-Lite (optionnel)
|
||||||
|
├── assets/
|
||||||
|
│ └── default.webp # Logo 300x300px
|
||||||
|
├── Cargo.toml
|
||||||
|
└── README.md</pre>
|
||||||
|
<h3 id="dépendances-principales">Dépendances principales</h3>
|
||||||
|
<div class="sourceCode" id="cb2"><pre
|
||||||
|
class="sourceCode toml"><code class="sourceCode toml"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a><span class="kw">[dependencies]</span></span>
|
||||||
|
<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a><span class="dt">pmosource</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">path</span><span class="op"> =</span> <span class="st">"../pmosource"</span><span class="op"> }</span></span>
|
||||||
|
<span id="cb2-3"><a href="#cb2-3" aria-hidden="true" tabindex="-1"></a><span class="dt">pmodidl</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">path</span><span class="op"> =</span> <span class="st">"../pmodidl"</span><span class="op"> }</span></span>
|
||||||
|
<span id="cb2-4"><a href="#cb2-4" aria-hidden="true" tabindex="-1"></a><span class="dt">pmoplaylist</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">path</span><span class="op"> =</span> <span class="st">"../pmoplaylist"</span><span class="op">, </span><span class="dt">optional</span><span class="op"> =</span> <span class="cn">true</span><span class="op"> }</span> <span class="co"># Si FIFO</span></span>
|
||||||
|
<span id="cb2-5"><a href="#cb2-5" aria-hidden="true" tabindex="-1"></a><span class="dt">pmoaudiocache</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">path</span><span class="op"> =</span> <span class="st">"../pmoaudiocache"</span><span class="op">, </span><span class="dt">optional</span><span class="op"> =</span> <span class="cn">true</span><span class="op"> }</span> <span class="co"># Si cache</span></span>
|
||||||
|
<span id="cb2-6"><a href="#cb2-6" aria-hidden="true" tabindex="-1"></a><span class="dt">pmocovers</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">path</span><span class="op"> =</span> <span class="st">"../pmocovers"</span><span class="op">, </span><span class="dt">optional</span><span class="op"> =</span> <span class="cn">true</span><span class="op"> }</span> <span class="co"># Si cache</span></span>
|
||||||
|
<span id="cb2-7"><a href="#cb2-7" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb2-8"><a href="#cb2-8" aria-hidden="true" tabindex="-1"></a><span class="dt">async-trait</span> <span class="op">=</span> <span class="st">"0.1"</span></span>
|
||||||
|
<span id="cb2-9"><a href="#cb2-9" aria-hidden="true" tabindex="-1"></a><span class="dt">tokio</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">version</span><span class="op"> =</span> <span class="st">"1"</span><span class="op">, </span><span class="dt">features</span><span class="op"> =</span> <span class="op">[</span><span class="st">"sync"</span><span class="op">] }</span></span>
|
||||||
|
<span id="cb2-10"><a href="#cb2-10" aria-hidden="true" tabindex="-1"></a><span class="dt">serde</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">version</span><span class="op"> =</span> <span class="st">"1"</span><span class="op">, </span><span class="dt">features</span><span class="op"> =</span> <span class="op">[</span><span class="st">"derive"</span><span class="op">] }</span></span>
|
||||||
|
<span id="cb2-11"><a href="#cb2-11" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb2-12"><a href="#cb2-12" aria-hidden="true" tabindex="-1"></a><span class="kw">[features]</span></span>
|
||||||
|
<span id="cb2-13"><a href="#cb2-13" aria-hidden="true" tabindex="-1"></a><span class="dt">default</span> <span class="op">=</span> <span class="op">[</span><span class="st">"cache"</span><span class="op">]</span></span>
|
||||||
|
<span id="cb2-14"><a href="#cb2-14" aria-hidden="true" tabindex="-1"></a><span class="dt">cache</span> <span class="op">=</span> <span class="op">[</span><span class="st">"pmoaudiocache"</span><span class="op">,</span> <span class="st">"pmocovers"</span><span class="op">]</span></span>
|
||||||
|
<span id="cb2-15"><a href="#cb2-15" aria-hidden="true" tabindex="-1"></a><span class="dt">playlist</span> <span class="op">=</span> <span class="op">[</span><span class="st">"pmoplaylist"</span><span class="op">]</span></span></pre></div>
|
||||||
|
<h2 id="implémentation-du-trait-musicsource">Implémentation du trait
|
||||||
|
MusicSource</h2>
|
||||||
|
<h3 id="informations-de-base">1. Informations de base</h3>
|
||||||
|
<p>Chaque source doit fournir :</p>
|
||||||
|
<div class="sourceCode" id="cb3"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmosource::</span><span class="op">{</span>async_trait<span class="op">,</span> MusicSource<span class="op">};</span></span>
|
||||||
|
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Clone</span><span class="op">,</span> <span class="bu">Debug</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> MyMusicSource <span class="op">{</span></span>
|
||||||
|
<span id="cb3-5"><a href="#cb3-5" aria-hidden="true" tabindex="-1"></a> <span class="co">// Champs internes</span></span>
|
||||||
|
<span id="cb3-6"><a href="#cb3-6" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb3-7"><a href="#cb3-7" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb3-8"><a href="#cb3-8" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
|
||||||
|
<span id="cb3-9"><a href="#cb3-9" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> MusicSource <span class="cf">for</span> MyMusicSource <span class="op">{</span></span>
|
||||||
|
<span id="cb3-10"><a href="#cb3-10" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> name(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="op">&</span><span class="dt">str</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb3-11"><a href="#cb3-11" aria-hidden="true" tabindex="-1"></a> <span class="st">"Ma Source Musicale"</span> <span class="co">// Nom affiché dans l'UI</span></span>
|
||||||
|
<span id="cb3-12"><a href="#cb3-12" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb3-13"><a href="#cb3-13" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb3-14"><a href="#cb3-14" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> id(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="op">&</span><span class="dt">str</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb3-15"><a href="#cb3-15" aria-hidden="true" tabindex="-1"></a> <span class="st">"my-music-source"</span> <span class="co">// ID unique (format: lowercase-kebab-case)</span></span>
|
||||||
|
<span id="cb3-16"><a href="#cb3-16" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb3-17"><a href="#cb3-17" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb3-18"><a href="#cb3-18" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> default_image(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="op">&</span>[<span class="dt">u8</span>] <span class="op">{</span></span>
|
||||||
|
<span id="cb3-19"><a href="#cb3-19" aria-hidden="true" tabindex="-1"></a> <span class="co">// Logo WebP 300x300px inclus dans le binaire</span></span>
|
||||||
|
<span id="cb3-20"><a href="#cb3-20" aria-hidden="true" tabindex="-1"></a> <span class="pp">include_bytes!</span>(<span class="st">"../assets/default.webp"</span>)</span>
|
||||||
|
<span id="cb3-21"><a href="#cb3-21" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb3-22"><a href="#cb3-22" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb3-23"><a href="#cb3-23" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> default_image_mime_type(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="op">&</span><span class="dt">str</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb3-24"><a href="#cb3-24" aria-hidden="true" tabindex="-1"></a> <span class="st">"image/webp"</span> <span class="co">// Toujours WebP</span></span>
|
||||||
|
<span id="cb3-25"><a href="#cb3-25" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb3-26"><a href="#cb3-26" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<p><strong>Règles :</strong> - <code>id()</code> doit être unique parmi
|
||||||
|
toutes les sources - <code>id()</code> doit être en lowercase-kebab-case
|
||||||
|
- <code>default_image()</code> doit être un WebP 300x300px</p>
|
||||||
|
<h3 id="navigation-contentdirectory">2. Navigation ContentDirectory</h3>
|
||||||
|
<h4 id="container-racine">2.1 Container racine</h4>
|
||||||
|
<div class="sourceCode" id="cb4"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> root_container(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Container<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(Container <span class="op">{</span></span>
|
||||||
|
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a> id<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>id()<span class="op">.</span>to_string()<span class="op">,</span> <span class="co">// "my-music-source"</span></span>
|
||||||
|
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a> parent_id<span class="op">:</span> <span class="st">"0"</span><span class="op">.</span>to_string()<span class="op">,</span> <span class="co">// Toujours "0" pour la racine</span></span>
|
||||||
|
<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a> restricted<span class="op">:</span> <span class="cn">Some</span>(<span class="st">"1"</span><span class="op">.</span>to_string())<span class="op">,</span></span>
|
||||||
|
<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a> child_count<span class="op">:</span> <span class="cn">None</span><span class="op">,</span> <span class="co">// Optionnel</span></span>
|
||||||
|
<span id="cb4-7"><a href="#cb4-7" aria-hidden="true" tabindex="-1"></a> searchable<span class="op">:</span> <span class="cn">Some</span>(<span class="st">"1"</span><span class="op">.</span>to_string())<span class="op">,</span></span>
|
||||||
|
<span id="cb4-8"><a href="#cb4-8" aria-hidden="true" tabindex="-1"></a> title<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>name()<span class="op">.</span>to_string()<span class="op">,</span></span>
|
||||||
|
<span id="cb4-9"><a href="#cb4-9" aria-hidden="true" tabindex="-1"></a> class<span class="op">:</span> <span class="st">"object.container"</span><span class="op">.</span>to_string()<span class="op">,</span></span>
|
||||||
|
<span id="cb4-10"><a href="#cb4-10" aria-hidden="true" tabindex="-1"></a> artist<span class="op">:</span> <span class="cn">None</span><span class="op">,</span></span>
|
||||||
|
<span id="cb4-11"><a href="#cb4-11" aria-hidden="true" tabindex="-1"></a> album_art<span class="op">:</span> <span class="cn">None</span><span class="op">,</span></span>
|
||||||
|
<span id="cb4-12"><a href="#cb4-12" aria-hidden="true" tabindex="-1"></a> containers<span class="op">:</span> <span class="pp">vec!</span>[]<span class="op">,</span></span>
|
||||||
|
<span id="cb4-13"><a href="#cb4-13" aria-hidden="true" tabindex="-1"></a> items<span class="op">:</span> <span class="pp">vec!</span>[]<span class="op">,</span></span>
|
||||||
|
<span id="cb4-14"><a href="#cb4-14" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
|
||||||
|
<span id="cb4-15"><a href="#cb4-15" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h4 id="browse">2.2 Browse</h4>
|
||||||
|
<p>La méthode <code>browse()</code> est le cœur de la navigation :</p>
|
||||||
|
<div class="sourceCode" id="cb5"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> browse(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> object_id<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>BrowseResult<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a> <span class="cf">match</span> <span class="kw">self</span><span class="op">.</span>parse_object_id(object_id) <span class="op">{</span></span>
|
||||||
|
<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a> <span class="pp">ObjectIdType::</span>Root <span class="op">=></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb5-4"><a href="#cb5-4" aria-hidden="true" tabindex="-1"></a> <span class="co">// Retourner les sous-containers principaux</span></span>
|
||||||
|
<span id="cb5-5"><a href="#cb5-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> containers <span class="op">=</span> <span class="pp">vec!</span>[</span>
|
||||||
|
<span id="cb5-6"><a href="#cb5-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>build_albums_container()<span class="op">,</span></span>
|
||||||
|
<span id="cb5-7"><a href="#cb5-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>build_playlists_container()<span class="op">,</span></span>
|
||||||
|
<span id="cb5-8"><a href="#cb5-8" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>build_favorites_container()<span class="op">,</span></span>
|
||||||
|
<span id="cb5-9"><a href="#cb5-9" aria-hidden="true" tabindex="-1"></a> ]<span class="op">;</span></span>
|
||||||
|
<span id="cb5-10"><a href="#cb5-10" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(<span class="pp">BrowseResult::</span>Containers(containers))</span>
|
||||||
|
<span id="cb5-11"><a href="#cb5-11" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb5-12"><a href="#cb5-12" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb5-13"><a href="#cb5-13" aria-hidden="true" tabindex="-1"></a> <span class="pp">ObjectIdType::</span>Album <span class="op">{</span> album_id <span class="op">}</span> <span class="op">=></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb5-14"><a href="#cb5-14" aria-hidden="true" tabindex="-1"></a> <span class="co">// Retourner le container + ses tracks</span></span>
|
||||||
|
<span id="cb5-15"><a href="#cb5-15" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> album_container <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>build_album_container(<span class="op">&</span>album_id)<span class="op">;</span></span>
|
||||||
|
<span id="cb5-16"><a href="#cb5-16" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> tracks <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>get_album_tracks(<span class="op">&</span>album_id)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb5-17"><a href="#cb5-17" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(<span class="pp">BrowseResult::</span>Mixed <span class="op">{</span></span>
|
||||||
|
<span id="cb5-18"><a href="#cb5-18" aria-hidden="true" tabindex="-1"></a> containers<span class="op">:</span> <span class="pp">vec!</span>[album_container]<span class="op">,</span></span>
|
||||||
|
<span id="cb5-19"><a href="#cb5-19" aria-hidden="true" tabindex="-1"></a> items<span class="op">:</span> tracks<span class="op">,</span></span>
|
||||||
|
<span id="cb5-20"><a href="#cb5-20" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
|
||||||
|
<span id="cb5-21"><a href="#cb5-21" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb5-22"><a href="#cb5-22" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb5-23"><a href="#cb5-23" aria-hidden="true" tabindex="-1"></a> <span class="pp">ObjectIdType::</span>Track <span class="op">{</span> track_id <span class="op">}</span> <span class="op">=></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb5-24"><a href="#cb5-24" aria-hidden="true" tabindex="-1"></a> <span class="co">// Retourner les détails d'un track</span></span>
|
||||||
|
<span id="cb5-25"><a href="#cb5-25" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> track <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>get_track_item(<span class="op">&</span>track_id)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb5-26"><a href="#cb5-26" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(<span class="pp">BrowseResult::</span>Items(<span class="pp">vec!</span>[track]))</span>
|
||||||
|
<span id="cb5-27"><a href="#cb5-27" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb5-28"><a href="#cb5-28" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb5-29"><a href="#cb5-29" aria-hidden="true" tabindex="-1"></a> _ <span class="op">=></span> <span class="cn">Err</span>(<span class="pp">MusicSourceError::</span>ObjectNotFound(</span>
|
||||||
|
<span id="cb5-30"><a href="#cb5-30" aria-hidden="true" tabindex="-1"></a> <span class="pp">format!</span>(<span class="st">"Unknown object: {}"</span><span class="op">,</span> object_id)</span>
|
||||||
|
<span id="cb5-31"><a href="#cb5-31" aria-hidden="true" tabindex="-1"></a> ))</span>
|
||||||
|
<span id="cb5-32"><a href="#cb5-32" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb5-33"><a href="#cb5-33" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<p><strong>Schema d’Object ID recommandé :</strong></p>
|
||||||
|
<pre><code><source-id> # Racine
|
||||||
|
<source-id>:albums # Container albums
|
||||||
|
<source-id>:album:<album_id> # Album spécifique
|
||||||
|
<source-id>:track:<track_id> # Track spécifique
|
||||||
|
<source-id>:playlist:<playlist_id> # Playlist spécifique</pre>
|
||||||
|
<p><strong>Types de BrowseResult :</strong> -
|
||||||
|
<code>Containers(Vec<Container>)</code> : Liste de containers
|
||||||
|
(navigation) - <code>Items(Vec<Item>)</code> : Liste de tracks
|
||||||
|
(lecture) - <code>Mixed { containers, items }</code> : Les deux (album
|
||||||
|
avec tracks)</p>
|
||||||
|
<h4 id="résolution-duri">2.3 Résolution d’URI</h4>
|
||||||
|
<div class="sourceCode" id="cb7"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb7-1"><a href="#cb7-1" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> resolve_uri(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> object_id<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">String</span><span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb7-2"><a href="#cb7-2" aria-hidden="true" tabindex="-1"></a> <span class="co">// Étape 1 : Vérifier le cache audio</span></span>
|
||||||
|
<span id="cb7-3"><a href="#cb7-3" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(cached_pk) <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>get_cached_audio_pk(object_id)<span class="op">.</span><span class="kw">await</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb7-4"><a href="#cb7-4" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span> <span class="cn">Ok</span>(<span class="pp">format!</span>(<span class="st">"{}/audio/flac/{}"</span><span class="op">,</span> <span class="kw">self</span><span class="op">.</span>base_url<span class="op">,</span> cached_pk))<span class="op">;</span></span>
|
||||||
|
<span id="cb7-5"><a href="#cb7-5" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb7-6"><a href="#cb7-6" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb7-7"><a href="#cb7-7" aria-hidden="true" tabindex="-1"></a> <span class="co">// Étape 2 : Retourner l'URI originale</span></span>
|
||||||
|
<span id="cb7-8"><a href="#cb7-8" aria-hidden="true" tabindex="-1"></a> <span class="cf">match</span> <span class="kw">self</span><span class="op">.</span>parse_object_id(object_id) <span class="op">{</span></span>
|
||||||
|
<span id="cb7-9"><a href="#cb7-9" aria-hidden="true" tabindex="-1"></a> <span class="pp">ObjectIdType::</span>Track <span class="op">{</span> track_id <span class="op">}</span> <span class="op">=></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb7-10"><a href="#cb7-10" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> stream_url <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>get_stream_url(<span class="op">&</span>track_id)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb7-11"><a href="#cb7-11" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(stream_url)</span>
|
||||||
|
<span id="cb7-12"><a href="#cb7-12" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb7-13"><a href="#cb7-13" aria-hidden="true" tabindex="-1"></a> _ <span class="op">=></span> <span class="cn">Err</span>(<span class="pp">MusicSourceError::</span>UriResolutionError(</span>
|
||||||
|
<span id="cb7-14"><a href="#cb7-14" aria-hidden="true" tabindex="-1"></a> <span class="pp">format!</span>(<span class="st">"Cannot resolve URI for: {}"</span><span class="op">,</span> object_id)</span>
|
||||||
|
<span id="cb7-15"><a href="#cb7-15" aria-hidden="true" tabindex="-1"></a> ))</span>
|
||||||
|
<span id="cb7-16"><a href="#cb7-16" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb7-17"><a href="#cb7-17" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<p><strong>Ordre de résolution :</strong> 1. Cache audio local (si
|
||||||
|
disponible) 2. URI originale (API streaming, fichier local, etc.)</p>
|
||||||
|
<h3 id="support-fifo-sources-dynamiques">3. Support FIFO (sources
|
||||||
|
dynamiques)</h3>
|
||||||
|
<p>Si votre source est dynamique (radio, streaming live) :</p>
|
||||||
|
<div class="sourceCode" id="cb8"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb8-1"><a href="#cb8-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmoplaylist::</span>PlaylistManager<span class="op">;</span></span>
|
||||||
|
<span id="cb8-2"><a href="#cb8-2" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">std::sync::</span>Arc<span class="op">;</span></span>
|
||||||
|
<span id="cb8-3"><a href="#cb8-3" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">tokio::sync::</span>RwLock<span class="op">;</span></span>
|
||||||
|
<span id="cb8-4"><a href="#cb8-4" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-5"><a href="#cb8-5" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Clone</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb8-6"><a href="#cb8-6" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> RadioSource <span class="op">{</span></span>
|
||||||
|
<span id="cb8-7"><a href="#cb8-7" aria-hidden="true" tabindex="-1"></a> playlist_id<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
|
||||||
|
<span id="cb8-8"><a href="#cb8-8" aria-hidden="true" tabindex="-1"></a> update_counter<span class="op">:</span> Arc<span class="op"><</span>RwLock<span class="op"><</span><span class="dt">u32</span><span class="op">>>,</span></span>
|
||||||
|
<span id="cb8-9"><a href="#cb8-9" aria-hidden="true" tabindex="-1"></a> last_change<span class="op">:</span> Arc<span class="op"><</span>RwLock<span class="op"><</span>SystemTime<span class="op">>>,</span></span>
|
||||||
|
<span id="cb8-10"><a href="#cb8-10" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb8-11"><a href="#cb8-11" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-12"><a href="#cb8-12" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
|
||||||
|
<span id="cb8-13"><a href="#cb8-13" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> MusicSource <span class="cf">for</span> RadioSource <span class="op">{</span></span>
|
||||||
|
<span id="cb8-14"><a href="#cb8-14" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> supports_fifo(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">bool</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb8-15"><a href="#cb8-15" aria-hidden="true" tabindex="-1"></a> <span class="cn">true</span> <span class="co">// Cette source utilise une FIFO</span></span>
|
||||||
|
<span id="cb8-16"><a href="#cb8-16" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb8-17"><a href="#cb8-17" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-18"><a href="#cb8-18" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> append_track(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> track<span class="op">:</span> Item) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb8-19"><a href="#cb8-19" aria-hidden="true" tabindex="-1"></a> <span class="co">// Récupérer le gestionnaire de playlist</span></span>
|
||||||
|
<span id="cb8-20"><a href="#cb8-20" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> manager <span class="op">=</span> PlaylistManager()<span class="op">;</span></span>
|
||||||
|
<span id="cb8-21"><a href="#cb8-21" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> writer <span class="op">=</span> manager</span>
|
||||||
|
<span id="cb8-22"><a href="#cb8-22" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>get_persistent_write_handle(<span class="kw">self</span><span class="op">.</span>playlist_id<span class="op">.</span>clone())</span>
|
||||||
|
<span id="cb8-23"><a href="#cb8-23" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span></span>
|
||||||
|
<span id="cb8-24"><a href="#cb8-24" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">MusicSourceError::</span>PlaylistError(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
|
||||||
|
<span id="cb8-25"><a href="#cb8-25" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-26"><a href="#cb8-26" aria-hidden="true" tabindex="-1"></a> <span class="co">// Extraire le PK depuis l'URI du track</span></span>
|
||||||
|
<span id="cb8-27"><a href="#cb8-27" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> pk <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>extract_pk_from_item(<span class="op">&</span>track)<span class="op">?;</span></span>
|
||||||
|
<span id="cb8-28"><a href="#cb8-28" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-29"><a href="#cb8-29" aria-hidden="true" tabindex="-1"></a> <span class="co">// Ajouter à la playlist</span></span>
|
||||||
|
<span id="cb8-30"><a href="#cb8-30" aria-hidden="true" tabindex="-1"></a> writer</span>
|
||||||
|
<span id="cb8-31"><a href="#cb8-31" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>push_lazy(pk)</span>
|
||||||
|
<span id="cb8-32"><a href="#cb8-32" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span></span>
|
||||||
|
<span id="cb8-33"><a href="#cb8-33" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">MusicSourceError::</span>PlaylistError(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
|
||||||
|
<span id="cb8-34"><a href="#cb8-34" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-35"><a href="#cb8-35" aria-hidden="true" tabindex="-1"></a> <span class="co">// Incrémenter update_id</span></span>
|
||||||
|
<span id="cb8-36"><a href="#cb8-36" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>bump_update_counter()<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
|
||||||
|
<span id="cb8-37"><a href="#cb8-37" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-38"><a href="#cb8-38" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(())</span>
|
||||||
|
<span id="cb8-39"><a href="#cb8-39" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb8-40"><a href="#cb8-40" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-41"><a href="#cb8-41" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> remove_oldest(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">Option</span><span class="op"><</span>Item<span class="op">>></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb8-42"><a href="#cb8-42" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> manager <span class="op">=</span> PlaylistManager()<span class="op">;</span></span>
|
||||||
|
<span id="cb8-43"><a href="#cb8-43" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> reader <span class="op">=</span> manager</span>
|
||||||
|
<span id="cb8-44"><a href="#cb8-44" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>get_read_handle(<span class="op">&</span><span class="kw">self</span><span class="op">.</span>playlist_id)</span>
|
||||||
|
<span id="cb8-45"><a href="#cb8-45" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span></span>
|
||||||
|
<span id="cb8-46"><a href="#cb8-46" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">MusicSourceError::</span>PlaylistError(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
|
||||||
|
<span id="cb8-47"><a href="#cb8-47" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-48"><a href="#cb8-48" aria-hidden="true" tabindex="-1"></a> <span class="co">// Récupérer le plus ancien</span></span>
|
||||||
|
<span id="cb8-49"><a href="#cb8-49" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> items <span class="op">=</span> reader<span class="op">.</span>to_items(<span class="dv">1</span>)<span class="op">.</span><span class="kw">await</span></span>
|
||||||
|
<span id="cb8-50"><a href="#cb8-50" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">MusicSourceError::</span>PlaylistError(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
|
||||||
|
<span id="cb8-51"><a href="#cb8-51" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-52"><a href="#cb8-52" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(item) <span class="op">=</span> items<span class="op">.</span>first() <span class="op">{</span></span>
|
||||||
|
<span id="cb8-53"><a href="#cb8-53" aria-hidden="true" tabindex="-1"></a> <span class="co">// Adapter l'item au schéma de la source</span></span>
|
||||||
|
<span id="cb8-54"><a href="#cb8-54" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> adapted <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>adapt_item_to_schema(item<span class="op">.</span>clone())<span class="op">;</span></span>
|
||||||
|
<span id="cb8-55"><a href="#cb8-55" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>bump_update_counter()<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
|
||||||
|
<span id="cb8-56"><a href="#cb8-56" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(<span class="cn">Some</span>(adapted))</span>
|
||||||
|
<span id="cb8-57"><a href="#cb8-57" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span> <span class="cf">else</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb8-58"><a href="#cb8-58" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(<span class="cn">None</span>)</span>
|
||||||
|
<span id="cb8-59"><a href="#cb8-59" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb8-60"><a href="#cb8-60" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb8-61"><a href="#cb8-61" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-62"><a href="#cb8-62" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> update_id(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">u32</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb8-63"><a href="#cb8-63" aria-hidden="true" tabindex="-1"></a> <span class="op">*</span><span class="kw">self</span><span class="op">.</span>update_counter<span class="op">.</span>read()<span class="op">.</span><span class="kw">await</span></span>
|
||||||
|
<span id="cb8-64"><a href="#cb8-64" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb8-65"><a href="#cb8-65" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-66"><a href="#cb8-66" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> last_change(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">Option</span><span class="op"><</span>SystemTime<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb8-67"><a href="#cb8-67" aria-hidden="true" tabindex="-1"></a> <span class="cn">Some</span>(<span class="op">*</span><span class="kw">self</span><span class="op">.</span>last_change<span class="op">.</span>read()<span class="op">.</span><span class="kw">await</span>)</span>
|
||||||
|
<span id="cb8-68"><a href="#cb8-68" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb8-69"><a href="#cb8-69" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-70"><a href="#cb8-70" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> get_items(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> offset<span class="op">:</span> <span class="dt">usize</span><span class="op">,</span> count<span class="op">:</span> <span class="dt">usize</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">Vec</span><span class="op"><</span>Item<span class="op">>></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb8-71"><a href="#cb8-71" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> manager <span class="op">=</span> PlaylistManager()<span class="op">;</span></span>
|
||||||
|
<span id="cb8-72"><a href="#cb8-72" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> reader <span class="op">=</span> manager</span>
|
||||||
|
<span id="cb8-73"><a href="#cb8-73" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>get_read_handle(<span class="op">&</span><span class="kw">self</span><span class="op">.</span>playlist_id)</span>
|
||||||
|
<span id="cb8-74"><a href="#cb8-74" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span></span>
|
||||||
|
<span id="cb8-75"><a href="#cb8-75" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">MusicSourceError::</span>PlaylistError(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
|
||||||
|
<span id="cb8-76"><a href="#cb8-76" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-77"><a href="#cb8-77" aria-hidden="true" tabindex="-1"></a> <span class="co">// Récupérer les items</span></span>
|
||||||
|
<span id="cb8-78"><a href="#cb8-78" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> items <span class="op">=</span> reader</span>
|
||||||
|
<span id="cb8-79"><a href="#cb8-79" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>to_items(count)</span>
|
||||||
|
<span id="cb8-80"><a href="#cb8-80" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span></span>
|
||||||
|
<span id="cb8-81"><a href="#cb8-81" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">MusicSourceError::</span>PlaylistError(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
|
||||||
|
<span id="cb8-82"><a href="#cb8-82" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-83"><a href="#cb8-83" aria-hidden="true" tabindex="-1"></a> <span class="co">// Adapter au schéma de la source</span></span>
|
||||||
|
<span id="cb8-84"><a href="#cb8-84" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> adapted <span class="op">=</span> items<span class="op">.</span>into_iter()</span>
|
||||||
|
<span id="cb8-85"><a href="#cb8-85" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map(<span class="op">|</span>item<span class="op">|</span> <span class="kw">self</span><span class="op">.</span>adapt_item_to_schema(item))</span>
|
||||||
|
<span id="cb8-86"><a href="#cb8-86" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>collect()<span class="op">;</span></span>
|
||||||
|
<span id="cb8-87"><a href="#cb8-87" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-88"><a href="#cb8-88" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(adapted)</span>
|
||||||
|
<span id="cb8-89"><a href="#cb8-89" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb8-90"><a href="#cb8-90" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb8-91"><a href="#cb8-91" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-92"><a href="#cb8-92" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> RadioSource <span class="op">{</span></span>
|
||||||
|
<span id="cb8-93"><a href="#cb8-93" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> bump_update_counter(<span class="op">&</span><span class="kw">self</span>) <span class="op">{</span></span>
|
||||||
|
<span id="cb8-94"><a href="#cb8-94" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> counter <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>update_counter<span class="op">.</span>write()<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
|
||||||
|
<span id="cb8-95"><a href="#cb8-95" aria-hidden="true" tabindex="-1"></a> <span class="op">*</span>counter <span class="op">=</span> counter<span class="op">.</span>wrapping_add(<span class="dv">1</span>)<span class="op">.</span>max(<span class="dv">1</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb8-96"><a href="#cb8-96" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> last <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>last_change<span class="op">.</span>write()<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
|
||||||
|
<span id="cb8-97"><a href="#cb8-97" aria-hidden="true" tabindex="-1"></a> <span class="op">*</span>last <span class="op">=</span> <span class="pp">SystemTime::</span>now()<span class="op">;</span></span>
|
||||||
|
<span id="cb8-98"><a href="#cb8-98" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb8-99"><a href="#cb8-99" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<p><strong>Points clés :</strong> - Utiliser
|
||||||
|
<code>pmoplaylist::PlaylistManager</code> singleton - Incrémenter
|
||||||
|
<code>update_id</code> à chaque modification - Mettre à jour
|
||||||
|
<code>last_change</code> à chaque modification - Adapter les IDs des
|
||||||
|
items au schéma de la source</p>
|
||||||
|
<h3 id="support-statique-albums-bibliothèques">4. Support statique
|
||||||
|
(albums, bibliothèques)</h3>
|
||||||
|
<p>Si votre source est statique (catalogue, albums) :</p>
|
||||||
|
<div class="sourceCode" id="cb9"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb9-1"><a href="#cb9-1" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
|
||||||
|
<span id="cb9-2"><a href="#cb9-2" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> MusicSource <span class="cf">for</span> CatalogSource <span class="op">{</span></span>
|
||||||
|
<span id="cb9-3"><a href="#cb9-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> supports_fifo(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">bool</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb9-4"><a href="#cb9-4" aria-hidden="true" tabindex="-1"></a> <span class="cn">false</span> <span class="co">// Pas de FIFO</span></span>
|
||||||
|
<span id="cb9-5"><a href="#cb9-5" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb9-6"><a href="#cb9-6" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb9-7"><a href="#cb9-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> append_track(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> _track<span class="op">:</span> Item) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb9-8"><a href="#cb9-8" aria-hidden="true" tabindex="-1"></a> <span class="cn">Err</span>(<span class="pp">MusicSourceError::</span>NotSupported(</span>
|
||||||
|
<span id="cb9-9"><a href="#cb9-9" aria-hidden="true" tabindex="-1"></a> <span class="st">"This source is read-only"</span><span class="op">.</span>to_string()</span>
|
||||||
|
<span id="cb9-10"><a href="#cb9-10" aria-hidden="true" tabindex="-1"></a> ))</span>
|
||||||
|
<span id="cb9-11"><a href="#cb9-11" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb9-12"><a href="#cb9-12" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb9-13"><a href="#cb9-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> remove_oldest(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">Option</span><span class="op"><</span>Item<span class="op">>></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb9-14"><a href="#cb9-14" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(<span class="cn">None</span>) <span class="co">// Pas de suppression</span></span>
|
||||||
|
<span id="cb9-15"><a href="#cb9-15" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb9-16"><a href="#cb9-16" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb9-17"><a href="#cb9-17" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> update_id(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">u32</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb9-18"><a href="#cb9-18" aria-hidden="true" tabindex="-1"></a> <span class="dv">0</span> <span class="co">// Jamais de changement</span></span>
|
||||||
|
<span id="cb9-19"><a href="#cb9-19" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb9-20"><a href="#cb9-20" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb9-21"><a href="#cb9-21" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> last_change(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">Option</span><span class="op"><</span>SystemTime<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb9-22"><a href="#cb9-22" aria-hidden="true" tabindex="-1"></a> <span class="cn">None</span> <span class="co">// Pas de suivi des changements</span></span>
|
||||||
|
<span id="cb9-23"><a href="#cb9-23" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb9-24"><a href="#cb9-24" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb9-25"><a href="#cb9-25" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> get_items(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> offset<span class="op">:</span> <span class="dt">usize</span><span class="op">,</span> count<span class="op">:</span> <span class="dt">usize</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">Vec</span><span class="op"><</span>Item<span class="op">>></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb9-26"><a href="#cb9-26" aria-hidden="true" tabindex="-1"></a> <span class="co">// Retourner une liste paginée depuis le catalogue</span></span>
|
||||||
|
<span id="cb9-27"><a href="#cb9-27" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>get_catalog_items(offset<span class="op">,</span> count)<span class="op">.</span><span class="kw">await</span></span>
|
||||||
|
<span id="cb9-28"><a href="#cb9-28" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb9-29"><a href="#cb9-29" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h2 id="patterns-dimplémentation">Patterns d’implémentation</h2>
|
||||||
|
<h3 id="pattern-1-source-dynamique-avec-fifo-radio-paradise">Pattern 1 :
|
||||||
|
Source dynamique avec FIFO (Radio Paradise)</h3>
|
||||||
|
<p><strong>Caractéristiques :</strong> - Flux continu de tracks -
|
||||||
|
Capacité limitée (50-100 tracks) - Suppression automatique des plus
|
||||||
|
anciens - <code>supports_fifo() = true</code></p>
|
||||||
|
<p><strong>Structure :</strong></p>
|
||||||
|
<div class="sourceCode" id="cb10"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb10-1"><a href="#cb10-1" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Clone</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb10-2"><a href="#cb10-2" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> RadioParadiseSource <span class="op">{</span></span>
|
||||||
|
<span id="cb10-3"><a href="#cb10-3" aria-hidden="true" tabindex="-1"></a> base_url<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-4"><a href="#cb10-4" aria-hidden="true" tabindex="-1"></a> update_counter<span class="op">:</span> Arc<span class="op"><</span>RwLock<span class="op"><</span><span class="dt">u32</span><span class="op">>>,</span></span>
|
||||||
|
<span id="cb10-5"><a href="#cb10-5" aria-hidden="true" tabindex="-1"></a> last_change<span class="op">:</span> Arc<span class="op"><</span>RwLock<span class="op"><</span>SystemTime<span class="op">>>,</span></span>
|
||||||
|
<span id="cb10-6"><a href="#cb10-6" aria-hidden="true" tabindex="-1"></a> callback_tokens<span class="op">:</span> Arc<span class="op"><</span><span class="pp">std::sync::</span>Mutex<span class="op"><</span><span class="dt">Vec</span><span class="op"><</span><span class="dt">u64</span><span class="op">>>>,</span></span>
|
||||||
|
<span id="cb10-7"><a href="#cb10-7" aria-hidden="true" tabindex="-1"></a> container_notifier<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span>Arc<span class="op"><</span><span class="kw">dyn</span> <span class="bu">Fn</span>(<span class="op">&</span>[<span class="dt">String</span>]) <span class="op">+</span> <span class="bu">Send</span> <span class="op">+</span> <span class="bu">Sync</span><span class="op">>>,</span></span>
|
||||||
|
<span id="cb10-8"><a href="#cb10-8" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb10-9"><a href="#cb10-9" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb10-10"><a href="#cb10-10" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> RadioParadiseSource <span class="op">{</span></span>
|
||||||
|
<span id="cb10-11"><a href="#cb10-11" aria-hidden="true" tabindex="-1"></a> <span class="co">// Enregistrer des callbacks sur les playlists pour notifier les changements</span></span>
|
||||||
|
<span id="cb10-12"><a href="#cb10-12" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> <span class="kw">fn</span> attach_playlist_callbacks(<span class="kw">self</span><span class="op">:</span> <span class="op">&</span>Arc<span class="op"><</span><span class="dt">Self</span><span class="op">></span>) <span class="op">{</span></span>
|
||||||
|
<span id="cb10-13"><a href="#cb10-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> playlist_ids <span class="op">=</span> <span class="pp">vec!</span>[</span>
|
||||||
|
<span id="cb10-14"><a href="#cb10-14" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>live_playlist_id()<span class="op">,</span></span>
|
||||||
|
<span id="cb10-15"><a href="#cb10-15" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>history_playlist_id()<span class="op">,</span></span>
|
||||||
|
<span id="cb10-16"><a href="#cb10-16" aria-hidden="true" tabindex="-1"></a> ]<span class="op">;</span></span>
|
||||||
|
<span id="cb10-17"><a href="#cb10-17" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb10-18"><a href="#cb10-18" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> manager <span class="op">=</span> PlaylistManager()<span class="op">;</span></span>
|
||||||
|
<span id="cb10-19"><a href="#cb10-19" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> tokens <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>callback_tokens<span class="op">.</span>lock()<span class="op">.</span>unwrap()<span class="op">;</span></span>
|
||||||
|
<span id="cb10-20"><a href="#cb10-20" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb10-21"><a href="#cb10-21" aria-hidden="true" tabindex="-1"></a> <span class="cf">for</span> pid <span class="kw">in</span> playlist_ids <span class="op">{</span></span>
|
||||||
|
<span id="cb10-22"><a href="#cb10-22" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> weak <span class="op">=</span> <span class="pp">Arc::</span>downgrade(<span class="kw">self</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb10-23"><a href="#cb10-23" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> pid_clone <span class="op">=</span> pid<span class="op">.</span>clone()<span class="op">;</span></span>
|
||||||
|
<span id="cb10-24"><a href="#cb10-24" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> token <span class="op">=</span> manager<span class="op">.</span>register_callback(<span class="kw">move</span> <span class="op">|</span>event<span class="op">|</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb10-25"><a href="#cb10-25" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> event<span class="op">.</span>playlist_id <span class="op">==</span> pid_clone <span class="op">{</span></span>
|
||||||
|
<span id="cb10-26"><a href="#cb10-26" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(strong) <span class="op">=</span> weak<span class="op">.</span>upgrade() <span class="op">{</span></span>
|
||||||
|
<span id="cb10-27"><a href="#cb10-27" aria-hidden="true" tabindex="-1"></a> <span class="pp">tokio::</span>spawn(<span class="kw">async</span> <span class="kw">move</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb10-28"><a href="#cb10-28" aria-hidden="true" tabindex="-1"></a> strong<span class="op">.</span>bump_update_counter()<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
|
||||||
|
<span id="cb10-29"><a href="#cb10-29" aria-hidden="true" tabindex="-1"></a> <span class="co">// Notifier ContentDirectory</span></span>
|
||||||
|
<span id="cb10-30"><a href="#cb10-30" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(notifier) <span class="op">=</span> strong<span class="op">.</span>container_notifier<span class="op">.</span>as_ref() <span class="op">{</span></span>
|
||||||
|
<span id="cb10-31"><a href="#cb10-31" aria-hidden="true" tabindex="-1"></a> notifier(<span class="op">&</span>[<span class="pp">format!</span>(<span class="st">"radio-paradise:history"</span>)])<span class="op">;</span></span>
|
||||||
|
<span id="cb10-32"><a href="#cb10-32" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb10-33"><a href="#cb10-33" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb10-34"><a href="#cb10-34" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb10-35"><a href="#cb10-35" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb10-36"><a href="#cb10-36" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb10-37"><a href="#cb10-37" aria-hidden="true" tabindex="-1"></a> tokens<span class="op">.</span>push(token)<span class="op">;</span></span>
|
||||||
|
<span id="cb10-38"><a href="#cb10-38" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb10-39"><a href="#cb10-39" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb10-40"><a href="#cb10-40" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<p><strong>Points clés :</strong> - Callbacks sur
|
||||||
|
<code>pmoplaylist</code> pour détecter les changements - Notification du
|
||||||
|
ContentDirectory via un notifier injecté - <code>update_counter</code>
|
||||||
|
partagé via <code>Arc<RwLock<u32>></code></p>
|
||||||
|
<h3 id="pattern-2-source-catalogue-avec-playlists-lazy-qobuz">Pattern 2
|
||||||
|
: Source catalogue avec playlists lazy (Qobuz)</h3>
|
||||||
|
<p><strong>Caractéristiques :</strong> - Catalogue vaste (millions de
|
||||||
|
tracks) - Playlists créées à la demande - Cache lazy (cover eager, audio
|
||||||
|
lazy) - <code>supports_fifo() = false</code></p>
|
||||||
|
<p><strong>Structure :</strong></p>
|
||||||
|
<div class="sourceCode" id="cb11"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb11-1"><a href="#cb11-1" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Clone</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb11-2"><a href="#cb11-2" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> QobuzSource <span class="op">{</span></span>
|
||||||
|
<span id="cb11-3"><a href="#cb11-3" aria-hidden="true" tabindex="-1"></a> inner<span class="op">:</span> Arc<span class="op"><</span>QobuzSourceInner<span class="op">>,</span></span>
|
||||||
|
<span id="cb11-4"><a href="#cb11-4" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb11-5"><a href="#cb11-5" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-6"><a href="#cb11-6" aria-hidden="true" tabindex="-1"></a><span class="kw">struct</span> QobuzSourceInner <span class="op">{</span></span>
|
||||||
|
<span id="cb11-7"><a href="#cb11-7" aria-hidden="true" tabindex="-1"></a> client<span class="op">:</span> Arc<span class="op"><</span>QobuzClient<span class="op">>,</span></span>
|
||||||
|
<span id="cb11-8"><a href="#cb11-8" aria-hidden="true" tabindex="-1"></a> cache_manager<span class="op">:</span> SourceCacheManager<span class="op">,</span></span>
|
||||||
|
<span id="cb11-9"><a href="#cb11-9" aria-hidden="true" tabindex="-1"></a> base_url<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
|
||||||
|
<span id="cb11-10"><a href="#cb11-10" aria-hidden="true" tabindex="-1"></a> update_counter<span class="op">:</span> <span class="pp">tokio::sync::</span>RwLock<span class="op"><</span><span class="dt">u32</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb11-11"><a href="#cb11-11" aria-hidden="true" tabindex="-1"></a> last_change<span class="op">:</span> <span class="pp">tokio::sync::</span>RwLock<span class="op"><</span>SystemTime<span class="op">>,</span></span>
|
||||||
|
<span id="cb11-12"><a href="#cb11-12" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb11-13"><a href="#cb11-13" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-14"><a href="#cb11-14" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> QobuzSource <span class="op">{</span></span>
|
||||||
|
<span id="cb11-15"><a href="#cb11-15" aria-hidden="true" tabindex="-1"></a> <span class="co">// Ajouter un track avec cache lazy</span></span>
|
||||||
|
<span id="cb11-16"><a href="#cb11-16" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> <span class="kw">async</span> <span class="kw">fn</span> add_track_lazy(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> track<span class="op">:</span> <span class="op">&</span>Track) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>(<span class="dt">String</span><span class="op">,</span> <span class="dt">String</span>)<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb11-17"><a href="#cb11-17" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> track_id <span class="op">=</span> <span class="pp">format!</span>(<span class="st">"qobuz://track/{}"</span><span class="op">,</span> track<span class="op">.</span>id)<span class="op">;</span></span>
|
||||||
|
<span id="cb11-18"><a href="#cb11-18" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> lazy_pk <span class="op">=</span> <span class="pp">format!</span>(<span class="st">"QOBUZ:{}"</span><span class="op">,</span> track<span class="op">.</span>id)<span class="op">;</span></span>
|
||||||
|
<span id="cb11-19"><a href="#cb11-19" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-20"><a href="#cb11-20" aria-hidden="true" tabindex="-1"></a> <span class="co">// 1. Cache cover EAGERLY (petit, UI en a besoin)</span></span>
|
||||||
|
<span id="cb11-21"><a href="#cb11-21" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> cached_cover_pk <span class="op">=</span> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(<span class="kw">ref</span> image_url) <span class="op">=</span> track<span class="op">.</span>album<span class="op">.</span>as_ref()</span>
|
||||||
|
<span id="cb11-22"><a href="#cb11-22" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>and_then(<span class="op">|</span>a<span class="op">|</span> a<span class="op">.</span>image<span class="op">.</span>as_ref()) <span class="op">{</span></span>
|
||||||
|
<span id="cb11-23"><a href="#cb11-23" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>inner<span class="op">.</span>cache_manager<span class="op">.</span>cache_cover(image_url)<span class="op">.</span><span class="kw">await</span><span class="op">.</span>ok()</span>
|
||||||
|
<span id="cb11-24"><a href="#cb11-24" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span> <span class="cf">else</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb11-25"><a href="#cb11-25" aria-hidden="true" tabindex="-1"></a> <span class="cn">None</span></span>
|
||||||
|
<span id="cb11-26"><a href="#cb11-26" aria-hidden="true" tabindex="-1"></a> <span class="op">};</span></span>
|
||||||
|
<span id="cb11-27"><a href="#cb11-27" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-28"><a href="#cb11-28" aria-hidden="true" tabindex="-1"></a> <span class="co">// 2. Préparer metadata</span></span>
|
||||||
|
<span id="cb11-29"><a href="#cb11-29" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> metadata <span class="op">=</span> AudioMetadata <span class="op">{</span></span>
|
||||||
|
<span id="cb11-30"><a href="#cb11-30" aria-hidden="true" tabindex="-1"></a> title<span class="op">:</span> <span class="cn">Some</span>(track<span class="op">.</span>title<span class="op">.</span>clone())<span class="op">,</span></span>
|
||||||
|
<span id="cb11-31"><a href="#cb11-31" aria-hidden="true" tabindex="-1"></a> artist<span class="op">:</span> track<span class="op">.</span>performer<span class="op">.</span>as_ref()<span class="op">.</span>map(<span class="op">|</span>p<span class="op">|</span> p<span class="op">.</span>name<span class="op">.</span>clone())<span class="op">,</span></span>
|
||||||
|
<span id="cb11-32"><a href="#cb11-32" aria-hidden="true" tabindex="-1"></a> album<span class="op">:</span> track<span class="op">.</span>album<span class="op">.</span>as_ref()<span class="op">.</span>map(<span class="op">|</span>a<span class="op">|</span> a<span class="op">.</span>title<span class="op">.</span>clone())<span class="op">,</span></span>
|
||||||
|
<span id="cb11-33"><a href="#cb11-33" aria-hidden="true" tabindex="-1"></a> duration_secs<span class="op">:</span> <span class="cn">Some</span>(track<span class="op">.</span>duration <span class="kw">as</span> <span class="dt">u64</span>)<span class="op">,</span></span>
|
||||||
|
<span id="cb11-34"><a href="#cb11-34" aria-hidden="true" tabindex="-1"></a> <span class="co">// ... autres champs</span></span>
|
||||||
|
<span id="cb11-35"><a href="#cb11-35" aria-hidden="true" tabindex="-1"></a> <span class="op">};</span></span>
|
||||||
|
<span id="cb11-36"><a href="#cb11-36" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-37"><a href="#cb11-37" aria-hidden="true" tabindex="-1"></a> <span class="co">// 3. Cache audio LAZILY (grand, téléchargé à la demande)</span></span>
|
||||||
|
<span id="cb11-38"><a href="#cb11-38" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> cached_audio_pk <span class="op">=</span> <span class="kw">self</span></span>
|
||||||
|
<span id="cb11-39"><a href="#cb11-39" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>inner</span>
|
||||||
|
<span id="cb11-40"><a href="#cb11-40" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>cache_manager</span>
|
||||||
|
<span id="cb11-41"><a href="#cb11-41" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>cache_audio_lazy_with_provider(</span>
|
||||||
|
<span id="cb11-42"><a href="#cb11-42" aria-hidden="true" tabindex="-1"></a> <span class="op">&</span>lazy_pk<span class="op">,</span></span>
|
||||||
|
<span id="cb11-43"><a href="#cb11-43" aria-hidden="true" tabindex="-1"></a> <span class="cn">Some</span>(metadata<span class="op">.</span>clone())<span class="op">,</span></span>
|
||||||
|
<span id="cb11-44"><a href="#cb11-44" aria-hidden="true" tabindex="-1"></a> cached_cover_pk<span class="op">.</span>clone()<span class="op">,</span></span>
|
||||||
|
<span id="cb11-45"><a href="#cb11-45" aria-hidden="true" tabindex="-1"></a> )</span>
|
||||||
|
<span id="cb11-46"><a href="#cb11-46" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb11-47"><a href="#cb11-47" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-48"><a href="#cb11-48" aria-hidden="true" tabindex="-1"></a> <span class="co">// 4. Stocker metadata</span></span>
|
||||||
|
<span id="cb11-49"><a href="#cb11-49" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>inner<span class="op">.</span>cache_manager<span class="op">.</span>update_metadata(</span>
|
||||||
|
<span id="cb11-50"><a href="#cb11-50" aria-hidden="true" tabindex="-1"></a> track_id<span class="op">.</span>clone()<span class="op">,</span></span>
|
||||||
|
<span id="cb11-51"><a href="#cb11-51" aria-hidden="true" tabindex="-1"></a> <span class="pp">pmosource::</span>TrackMetadata <span class="op">{</span></span>
|
||||||
|
<span id="cb11-52"><a href="#cb11-52" aria-hidden="true" tabindex="-1"></a> original_uri<span class="op">:</span> stream_url<span class="op">,</span></span>
|
||||||
|
<span id="cb11-53"><a href="#cb11-53" aria-hidden="true" tabindex="-1"></a> cached_audio_pk<span class="op">:</span> <span class="cn">Some</span>(cached_audio_pk<span class="op">.</span>clone())<span class="op">,</span></span>
|
||||||
|
<span id="cb11-54"><a href="#cb11-54" aria-hidden="true" tabindex="-1"></a> cached_cover_pk<span class="op">,</span></span>
|
||||||
|
<span id="cb11-55"><a href="#cb11-55" aria-hidden="true" tabindex="-1"></a> <span class="op">},</span></span>
|
||||||
|
<span id="cb11-56"><a href="#cb11-56" aria-hidden="true" tabindex="-1"></a> )<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
|
||||||
|
<span id="cb11-57"><a href="#cb11-57" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-58"><a href="#cb11-58" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>((track_id<span class="op">,</span> cached_audio_pk))</span>
|
||||||
|
<span id="cb11-59"><a href="#cb11-59" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb11-60"><a href="#cb11-60" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-61"><a href="#cb11-61" aria-hidden="true" tabindex="-1"></a> <span class="co">// Créer une playlist d'album avec TTL</span></span>
|
||||||
|
<span id="cb11-62"><a href="#cb11-62" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> get_or_create_album_playlist_items(</span>
|
||||||
|
<span id="cb11-63"><a href="#cb11-63" aria-hidden="true" tabindex="-1"></a> <span class="op">&</span><span class="kw">self</span><span class="op">,</span></span>
|
||||||
|
<span id="cb11-64"><a href="#cb11-64" aria-hidden="true" tabindex="-1"></a> album_id<span class="op">:</span> <span class="op">&</span><span class="dt">str</span><span class="op">,</span></span>
|
||||||
|
<span id="cb11-65"><a href="#cb11-65" aria-hidden="true" tabindex="-1"></a> limit<span class="op">:</span> <span class="dt">usize</span><span class="op">,</span></span>
|
||||||
|
<span id="cb11-66"><a href="#cb11-66" aria-hidden="true" tabindex="-1"></a> ) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">Vec</span><span class="op"><</span>Item<span class="op">>></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb11-67"><a href="#cb11-67" aria-hidden="true" tabindex="-1"></a> <span class="kw">const</span> ALBUM_PLAYLIST_TTL<span class="op">:</span> Duration <span class="op">=</span> <span class="pp">Duration::</span>from_secs(<span class="dv">7</span> <span class="op">*</span> <span class="dv">24</span> <span class="op">*</span> <span class="dv">3600</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb11-68"><a href="#cb11-68" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-69"><a href="#cb11-69" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> playlist_id <span class="op">=</span> <span class="pp">format!</span>(<span class="st">"qobuz-album-{}"</span><span class="op">,</span> album_id)<span class="op">;</span></span>
|
||||||
|
<span id="cb11-70"><a href="#cb11-70" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> playlist_manager <span class="op">=</span> PlaylistManager()<span class="op">;</span></span>
|
||||||
|
<span id="cb11-71"><a href="#cb11-71" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-72"><a href="#cb11-72" aria-hidden="true" tabindex="-1"></a> <span class="co">// Vérifier validité (existe ET non expirée ET non vide)</span></span>
|
||||||
|
<span id="cb11-73"><a href="#cb11-73" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> is_valid <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>is_album_playlist_valid(<span class="op">&</span>playlist_id)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb11-74"><a href="#cb11-74" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-75"><a href="#cb11-75" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> is_valid <span class="op">{</span></span>
|
||||||
|
<span id="cb11-76"><a href="#cb11-76" aria-hidden="true" tabindex="-1"></a> <span class="co">// Récupérer depuis playlist existante</span></span>
|
||||||
|
<span id="cb11-77"><a href="#cb11-77" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> reader <span class="op">=</span> playlist_manager<span class="op">.</span>get_read_handle(<span class="op">&</span>playlist_id)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb11-78"><a href="#cb11-78" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> items <span class="op">=</span> reader<span class="op">.</span>to_items(limit)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb11-79"><a href="#cb11-79" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span> <span class="kw">self</span><span class="op">.</span>adapt_playlist_items_to_qobuz(items<span class="op">,</span> album_id)<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
|
||||||
|
<span id="cb11-80"><a href="#cb11-80" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb11-81"><a href="#cb11-81" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-82"><a href="#cb11-82" aria-hidden="true" tabindex="-1"></a> <span class="co">// Créer nouvelle playlist</span></span>
|
||||||
|
<span id="cb11-83"><a href="#cb11-83" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> writer <span class="op">=</span> playlist_manager</span>
|
||||||
|
<span id="cb11-84"><a href="#cb11-84" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>create_persistent_playlist_with_role(</span>
|
||||||
|
<span id="cb11-85"><a href="#cb11-85" aria-hidden="true" tabindex="-1"></a> playlist_id<span class="op">.</span>clone()<span class="op">,</span></span>
|
||||||
|
<span id="cb11-86"><a href="#cb11-86" aria-hidden="true" tabindex="-1"></a> <span class="pp">pmoplaylist::PlaylistRole::</span>Album<span class="op">,</span></span>
|
||||||
|
<span id="cb11-87"><a href="#cb11-87" aria-hidden="true" tabindex="-1"></a> )</span>
|
||||||
|
<span id="cb11-88"><a href="#cb11-88" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb11-89"><a href="#cb11-89" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-90"><a href="#cb11-90" aria-hidden="true" tabindex="-1"></a> <span class="co">// Ajouter tracks avec cache lazy</span></span>
|
||||||
|
<span id="cb11-91"><a href="#cb11-91" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_album_to_playlist(<span class="op">&</span>playlist_id<span class="op">,</span> album_id)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb11-92"><a href="#cb11-92" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-93"><a href="#cb11-93" aria-hidden="true" tabindex="-1"></a> <span class="co">// Récupérer items</span></span>
|
||||||
|
<span id="cb11-94"><a href="#cb11-94" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> reader <span class="op">=</span> playlist_manager<span class="op">.</span>get_read_handle(<span class="op">&</span>playlist_id)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb11-95"><a href="#cb11-95" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> items <span class="op">=</span> reader<span class="op">.</span>to_items(limit)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb11-96"><a href="#cb11-96" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>adapt_playlist_items_to_qobuz(items<span class="op">,</span> album_id)<span class="op">.</span><span class="kw">await</span></span>
|
||||||
|
<span id="cb11-97"><a href="#cb11-97" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb11-98"><a href="#cb11-98" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<p><strong>Points clés :</strong> - Cache lazy pour l’audio (téléchargé
|
||||||
|
à la demande) - Cache eager pour les covers (petit, UI en a besoin) -
|
||||||
|
Playlists avec TTL (7 jours) - <code>LazyProvider</code> pour
|
||||||
|
télécharger l’audio lors de la lecture</p>
|
||||||
|
<h3 id="pattern-3-adaptation-des-ids-entre-playlist-et-source">Pattern 3
|
||||||
|
: Adaptation des IDs entre playlist et source</h3>
|
||||||
|
<p>Lorsqu’une source utilise <code>pmoplaylist</code>, les items
|
||||||
|
retournés ont des IDs génériques. Il faut les adapter au schéma de la
|
||||||
|
source :</p>
|
||||||
|
<div class="sourceCode" id="cb12"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb12-1"><a href="#cb12-1" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> adapt_playlist_items_to_source(</span>
|
||||||
|
<span id="cb12-2"><a href="#cb12-2" aria-hidden="true" tabindex="-1"></a> <span class="op">&</span><span class="kw">self</span><span class="op">,</span></span>
|
||||||
|
<span id="cb12-3"><a href="#cb12-3" aria-hidden="true" tabindex="-1"></a> items<span class="op">:</span> <span class="dt">Vec</span><span class="op"><</span>Item<span class="op">>,</span></span>
|
||||||
|
<span id="cb12-4"><a href="#cb12-4" aria-hidden="true" tabindex="-1"></a> parent_id<span class="op">:</span> <span class="op">&</span><span class="dt">str</span><span class="op">,</span></span>
|
||||||
|
<span id="cb12-5"><a href="#cb12-5" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">Vec</span><span class="op"><</span>Item<span class="op">>></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb12-6"><a href="#cb12-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> adapted <span class="op">=</span> <span class="dt">Vec</span><span class="pp">::</span>with_capacity(items<span class="op">.</span>len())<span class="op">;</span></span>
|
||||||
|
<span id="cb12-7"><a href="#cb12-7" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb12-8"><a href="#cb12-8" aria-hidden="true" tabindex="-1"></a> <span class="cf">for</span> <span class="kw">mut</span> item <span class="kw">in</span> items <span class="op">{</span></span>
|
||||||
|
<span id="cb12-9"><a href="#cb12-9" aria-hidden="true" tabindex="-1"></a> <span class="co">// Extraire cache_pk depuis l'URL du resource</span></span>
|
||||||
|
<span id="cb12-10"><a href="#cb12-10" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> cache_pk <span class="op">=</span> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(resource) <span class="op">=</span> item<span class="op">.</span>resources<span class="op">.</span>first() <span class="op">{</span></span>
|
||||||
|
<span id="cb12-11"><a href="#cb12-11" aria-hidden="true" tabindex="-1"></a> resource</span>
|
||||||
|
<span id="cb12-12"><a href="#cb12-12" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>url</span>
|
||||||
|
<span id="cb12-13"><a href="#cb12-13" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>strip_prefix(<span class="st">"/audio/flac/"</span>)</span>
|
||||||
|
<span id="cb12-14"><a href="#cb12-14" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map(<span class="op">|</span>s<span class="op">|</span> s<span class="op">.</span>to_string())</span>
|
||||||
|
<span id="cb12-15"><a href="#cb12-15" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span> <span class="cf">else</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb12-16"><a href="#cb12-16" aria-hidden="true" tabindex="-1"></a> <span class="cn">None</span></span>
|
||||||
|
<span id="cb12-17"><a href="#cb12-17" aria-hidden="true" tabindex="-1"></a> <span class="op">};</span></span>
|
||||||
|
<span id="cb12-18"><a href="#cb12-18" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb12-19"><a href="#cb12-19" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(pk) <span class="op">=</span> cache_pk <span class="op">{</span></span>
|
||||||
|
<span id="cb12-20"><a href="#cb12-20" aria-hidden="true" tabindex="-1"></a> <span class="co">// Récupérer source_track_id depuis metadata</span></span>
|
||||||
|
<span id="cb12-21"><a href="#cb12-21" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Ok</span>(<span class="cn">Some</span>(track_id_value)) <span class="op">=</span> <span class="kw">self</span></span>
|
||||||
|
<span id="cb12-22"><a href="#cb12-22" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>cache_manager</span>
|
||||||
|
<span id="cb12-23"><a href="#cb12-23" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>get_audio_metadata(<span class="op">&</span>pk<span class="op">,</span> <span class="st">"source_track_id"</span>)</span>
|
||||||
|
<span id="cb12-24"><a href="#cb12-24" aria-hidden="true" tabindex="-1"></a> <span class="op">{</span></span>
|
||||||
|
<span id="cb12-25"><a href="#cb12-25" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(track_id) <span class="op">=</span> track_id_value<span class="op">.</span>as_str() <span class="op">{</span></span>
|
||||||
|
<span id="cb12-26"><a href="#cb12-26" aria-hidden="true" tabindex="-1"></a> item<span class="op">.</span>id <span class="op">=</span> <span class="pp">format!</span>(<span class="st">"my-source:track:{}"</span><span class="op">,</span> track_id)<span class="op">;</span></span>
|
||||||
|
<span id="cb12-27"><a href="#cb12-27" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb12-28"><a href="#cb12-28" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb12-29"><a href="#cb12-29" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb12-30"><a href="#cb12-30" aria-hidden="true" tabindex="-1"></a> <span class="co">// Convertir URL relative en absolue</span></span>
|
||||||
|
<span id="cb12-31"><a href="#cb12-31" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(resource) <span class="op">=</span> item<span class="op">.</span>resources<span class="op">.</span>first_mut() <span class="op">{</span></span>
|
||||||
|
<span id="cb12-32"><a href="#cb12-32" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> resource<span class="op">.</span>url<span class="op">.</span>starts_with(<span class="ch">'/'</span>) <span class="op">{</span></span>
|
||||||
|
<span id="cb12-33"><a href="#cb12-33" aria-hidden="true" tabindex="-1"></a> resource<span class="op">.</span>url <span class="op">=</span> <span class="pp">format!</span>(<span class="st">"{}{}"</span><span class="op">,</span> <span class="kw">self</span><span class="op">.</span>base_url<span class="op">,</span> resource<span class="op">.</span>url)<span class="op">;</span></span>
|
||||||
|
<span id="cb12-34"><a href="#cb12-34" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb12-35"><a href="#cb12-35" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb12-36"><a href="#cb12-36" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb12-37"><a href="#cb12-37" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb12-38"><a href="#cb12-38" aria-hidden="true" tabindex="-1"></a> item<span class="op">.</span>parent_id <span class="op">=</span> parent_id<span class="op">.</span>to_string()<span class="op">;</span></span>
|
||||||
|
<span id="cb12-39"><a href="#cb12-39" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb12-40"><a href="#cb12-40" aria-hidden="true" tabindex="-1"></a> <span class="co">// Normaliser album art</span></span>
|
||||||
|
<span id="cb12-41"><a href="#cb12-41" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(art) <span class="op">=</span> item<span class="op">.</span>album_art<span class="op">.</span>as_mut() <span class="op">{</span></span>
|
||||||
|
<span id="cb12-42"><a href="#cb12-42" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> art<span class="op">.</span>starts_with(<span class="ch">'/'</span>) <span class="op">{</span></span>
|
||||||
|
<span id="cb12-43"><a href="#cb12-43" aria-hidden="true" tabindex="-1"></a> <span class="op">*</span>art <span class="op">=</span> <span class="pp">format!</span>(<span class="st">"{}{}"</span><span class="op">,</span> <span class="kw">self</span><span class="op">.</span>base_url<span class="op">,</span> art)<span class="op">;</span></span>
|
||||||
|
<span id="cb12-44"><a href="#cb12-44" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb12-45"><a href="#cb12-45" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span> <span class="cf">else</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb12-46"><a href="#cb12-46" aria-hidden="true" tabindex="-1"></a> item<span class="op">.</span>album_art <span class="op">=</span> <span class="cn">Some</span>(<span class="kw">self</span><span class="op">.</span>default_cover_url())<span class="op">;</span></span>
|
||||||
|
<span id="cb12-47"><a href="#cb12-47" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb12-48"><a href="#cb12-48" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb12-49"><a href="#cb12-49" aria-hidden="true" tabindex="-1"></a> <span class="co">// Ajouter genre par défaut si absent (requis par certains clients)</span></span>
|
||||||
|
<span id="cb12-50"><a href="#cb12-50" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> item<span class="op">.</span>genre<span class="op">.</span>is_none() <span class="op">{</span></span>
|
||||||
|
<span id="cb12-51"><a href="#cb12-51" aria-hidden="true" tabindex="-1"></a> item<span class="op">.</span>genre <span class="op">=</span> <span class="cn">Some</span>(<span class="st">"Music"</span><span class="op">.</span>to_string())<span class="op">;</span></span>
|
||||||
|
<span id="cb12-52"><a href="#cb12-52" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb12-53"><a href="#cb12-53" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb12-54"><a href="#cb12-54" aria-hidden="true" tabindex="-1"></a> adapted<span class="op">.</span>push(item)<span class="op">;</span></span>
|
||||||
|
<span id="cb12-55"><a href="#cb12-55" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb12-56"><a href="#cb12-56" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb12-57"><a href="#cb12-57" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(adapted)</span>
|
||||||
|
<span id="cb12-58"><a href="#cb12-58" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<p><strong>Points clés :</strong> - Stocker <code>source_track_id</code>
|
||||||
|
dans les metadata du cache audio - Reconstituer l’ID correct lors de la
|
||||||
|
récupération depuis playlist - Normaliser URLs (relatives → absolues) -
|
||||||
|
Ajouter champs requis par certains clients UPnP</p>
|
||||||
|
<h2 id="intégration-avec-lécosystème-pmomusic">Intégration avec
|
||||||
|
l’écosystème PMOMusic</h2>
|
||||||
|
<h3 id="avec-pmoplaylist">Avec pmoplaylist</h3>
|
||||||
|
<p>Pour les sources dynamiques et les catalogues :</p>
|
||||||
|
<div class="sourceCode" id="cb13"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb13-1"><a href="#cb13-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmoplaylist::</span><span class="op">{</span>PlaylistManager<span class="op">,</span> PlaylistRole<span class="op">};</span></span>
|
||||||
|
<span id="cb13-2"><a href="#cb13-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb13-3"><a href="#cb13-3" aria-hidden="true" tabindex="-1"></a><span class="co">// Créer une playlist persistante</span></span>
|
||||||
|
<span id="cb13-4"><a href="#cb13-4" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> manager <span class="op">=</span> PlaylistManager()<span class="op">;</span></span>
|
||||||
|
<span id="cb13-5"><a href="#cb13-5" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> writer <span class="op">=</span> manager</span>
|
||||||
|
<span id="cb13-6"><a href="#cb13-6" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>create_persistent_playlist_with_role(</span>
|
||||||
|
<span id="cb13-7"><a href="#cb13-7" aria-hidden="true" tabindex="-1"></a> <span class="st">"my-source-album-123"</span><span class="op">.</span>to_string()<span class="op">,</span></span>
|
||||||
|
<span id="cb13-8"><a href="#cb13-8" aria-hidden="true" tabindex="-1"></a> <span class="pp">PlaylistRole::</span>Album<span class="op">,</span></span>
|
||||||
|
<span id="cb13-9"><a href="#cb13-9" aria-hidden="true" tabindex="-1"></a> )</span>
|
||||||
|
<span id="cb13-10"><a href="#cb13-10" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb13-11"><a href="#cb13-11" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb13-12"><a href="#cb13-12" aria-hidden="true" tabindex="-1"></a><span class="co">// Configurer metadata</span></span>
|
||||||
|
<span id="cb13-13"><a href="#cb13-13" aria-hidden="true" tabindex="-1"></a>writer<span class="op">.</span>set_title(<span class="st">"Album Title"</span><span class="op">.</span>to_string())<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb13-14"><a href="#cb13-14" aria-hidden="true" tabindex="-1"></a>writer<span class="op">.</span>set_artist(<span class="cn">Some</span>(<span class="st">"Artist Name"</span><span class="op">.</span>to_string()))<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb13-15"><a href="#cb13-15" aria-hidden="true" tabindex="-1"></a>writer<span class="op">.</span>set_cover_pk(<span class="cn">Some</span>(<span class="st">"cover-pk"</span><span class="op">.</span>to_string()))<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb13-16"><a href="#cb13-16" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb13-17"><a href="#cb13-17" aria-hidden="true" tabindex="-1"></a><span class="co">// Ajouter tracks avec cache lazy</span></span>
|
||||||
|
<span id="cb13-18"><a href="#cb13-18" aria-hidden="true" tabindex="-1"></a>writer<span class="op">.</span>push_lazy_batch(<span class="pp">vec!</span>[<span class="st">"pk1"</span><span class="op">,</span> <span class="st">"pk2"</span><span class="op">,</span> <span class="st">"pk3"</span>])<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb13-19"><a href="#cb13-19" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb13-20"><a href="#cb13-20" aria-hidden="true" tabindex="-1"></a><span class="co">// Activer mode lazy (lookahead 2 tracks)</span></span>
|
||||||
|
<span id="cb13-21"><a href="#cb13-21" aria-hidden="true" tabindex="-1"></a>manager<span class="op">.</span>enable_lazy_mode(<span class="st">"my-source-album-123"</span><span class="op">,</span> <span class="dv">2</span>)<span class="op">;</span></span></pre></div>
|
||||||
|
<h3 id="avec-pmoaudiocache-et-pmocovers-via-sourcecachemanager">Avec
|
||||||
|
pmoaudiocache et pmocovers (via SourceCacheManager)</h3>
|
||||||
|
<div class="sourceCode" id="cb14"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb14-1"><a href="#cb14-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmosource::</span>SourceCacheManager<span class="op">;</span></span>
|
||||||
|
<span id="cb14-2"><a href="#cb14-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb14-3"><a href="#cb14-3" aria-hidden="true" tabindex="-1"></a><span class="co">// Créer le manager centralisé</span></span>
|
||||||
|
<span id="cb14-4"><a href="#cb14-4" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> cache_manager <span class="op">=</span> <span class="pp">SourceCacheManager::</span>from_registry(<span class="st">"my-source"</span><span class="op">.</span>to_string())<span class="op">?;</span></span>
|
||||||
|
<span id="cb14-5"><a href="#cb14-5" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb14-6"><a href="#cb14-6" aria-hidden="true" tabindex="-1"></a><span class="co">// Enregistrer un LazyProvider</span></span>
|
||||||
|
<span id="cb14-7"><a href="#cb14-7" aria-hidden="true" tabindex="-1"></a>cache_manager<span class="op">.</span>register_lazy_provider(<span class="pp">Arc::</span>new(<span class="pp">MyLazyProvider::</span>new(client)))<span class="op">;</span></span>
|
||||||
|
<span id="cb14-8"><a href="#cb14-8" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb14-9"><a href="#cb14-9" aria-hidden="true" tabindex="-1"></a><span class="co">// Cache eager (cover)</span></span>
|
||||||
|
<span id="cb14-10"><a href="#cb14-10" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> cover_pk <span class="op">=</span> cache_manager<span class="op">.</span>cache_cover(<span class="st">"https://example.com/cover.jpg"</span>)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb14-11"><a href="#cb14-11" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb14-12"><a href="#cb14-12" aria-hidden="true" tabindex="-1"></a><span class="co">// Cache lazy (audio)</span></span>
|
||||||
|
<span id="cb14-13"><a href="#cb14-13" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> audio_pk <span class="op">=</span> cache_manager</span>
|
||||||
|
<span id="cb14-14"><a href="#cb14-14" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>cache_audio_lazy_with_provider(</span>
|
||||||
|
<span id="cb14-15"><a href="#cb14-15" aria-hidden="true" tabindex="-1"></a> <span class="st">"MY-SOURCE:123"</span><span class="op">,</span> <span class="co">// Lazy PK</span></span>
|
||||||
|
<span id="cb14-16"><a href="#cb14-16" aria-hidden="true" tabindex="-1"></a> <span class="cn">Some</span>(metadata)<span class="op">,</span></span>
|
||||||
|
<span id="cb14-17"><a href="#cb14-17" aria-hidden="true" tabindex="-1"></a> <span class="cn">Some</span>(cover_pk)<span class="op">,</span></span>
|
||||||
|
<span id="cb14-18"><a href="#cb14-18" aria-hidden="true" tabindex="-1"></a> )</span>
|
||||||
|
<span id="cb14-19"><a href="#cb14-19" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb14-20"><a href="#cb14-20" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb14-21"><a href="#cb14-21" aria-hidden="true" tabindex="-1"></a><span class="co">// Récupérer metadata</span></span>
|
||||||
|
<span id="cb14-22"><a href="#cb14-22" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> value <span class="op">=</span> cache_manager<span class="op">.</span>get_audio_metadata(<span class="op">&</span>audio_pk<span class="op">,</span> <span class="st">"key"</span>)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span></pre></div>
|
||||||
|
<p><strong>LazyProvider personnalisé :</strong></p>
|
||||||
|
<div class="sourceCode" id="cb15"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb15-1"><a href="#cb15-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmoaudiocache::</span><span class="op">{</span>LazyProvider<span class="op">,</span> LazyProviderError<span class="op">};</span></span>
|
||||||
|
<span id="cb15-2"><a href="#cb15-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb15-3"><a href="#cb15-3" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> MyLazyProvider <span class="op">{</span></span>
|
||||||
|
<span id="cb15-4"><a href="#cb15-4" aria-hidden="true" tabindex="-1"></a> client<span class="op">:</span> Arc<span class="op"><</span>MyClient<span class="op">>,</span></span>
|
||||||
|
<span id="cb15-5"><a href="#cb15-5" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb15-6"><a href="#cb15-6" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb15-7"><a href="#cb15-7" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
|
||||||
|
<span id="cb15-8"><a href="#cb15-8" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> LazyProvider <span class="cf">for</span> MyLazyProvider <span class="op">{</span></span>
|
||||||
|
<span id="cb15-9"><a href="#cb15-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> fetch_audio(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> lazy_pk<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">Vec</span><span class="op"><</span><span class="dt">u8</span><span class="op">>,</span> LazyProviderError<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb15-10"><a href="#cb15-10" aria-hidden="true" tabindex="-1"></a> <span class="co">// Extraire l'ID depuis le lazy_pk</span></span>
|
||||||
|
<span id="cb15-11"><a href="#cb15-11" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> id <span class="op">=</span> lazy_pk</span>
|
||||||
|
<span id="cb15-12"><a href="#cb15-12" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>strip_prefix(<span class="st">"MY-SOURCE:"</span>)</span>
|
||||||
|
<span id="cb15-13"><a href="#cb15-13" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>ok_or_else(<span class="op">||</span> <span class="pp">LazyProviderError::</span>InvalidKey)<span class="op">?;</span></span>
|
||||||
|
<span id="cb15-14"><a href="#cb15-14" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb15-15"><a href="#cb15-15" aria-hidden="true" tabindex="-1"></a> <span class="co">// Récupérer l'URL de streaming</span></span>
|
||||||
|
<span id="cb15-16"><a href="#cb15-16" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> stream_url <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>client<span class="op">.</span>get_stream_url(id)<span class="op">.</span><span class="kw">await</span></span>
|
||||||
|
<span id="cb15-17"><a href="#cb15-17" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">LazyProviderError::</span>FetchFailed(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
|
||||||
|
<span id="cb15-18"><a href="#cb15-18" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb15-19"><a href="#cb15-19" aria-hidden="true" tabindex="-1"></a> <span class="co">// Télécharger l'audio</span></span>
|
||||||
|
<span id="cb15-20"><a href="#cb15-20" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> response <span class="op">=</span> <span class="pp">reqwest::</span>get(<span class="op">&</span>stream_url)<span class="op">.</span><span class="kw">await</span></span>
|
||||||
|
<span id="cb15-21"><a href="#cb15-21" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">LazyProviderError::</span>FetchFailed(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
|
||||||
|
<span id="cb15-22"><a href="#cb15-22" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb15-23"><a href="#cb15-23" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> bytes <span class="op">=</span> response<span class="op">.</span>bytes()<span class="op">.</span><span class="kw">await</span></span>
|
||||||
|
<span id="cb15-24"><a href="#cb15-24" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">LazyProviderError::</span>FetchFailed(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
|
||||||
|
<span id="cb15-25"><a href="#cb15-25" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb15-26"><a href="#cb15-26" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(bytes<span class="op">.</span>to_vec())</span>
|
||||||
|
<span id="cb15-27"><a href="#cb15-27" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb15-28"><a href="#cb15-28" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h3 id="avec-pmodidl">Avec pmodidl</h3>
|
||||||
|
<p>Conversion de vos structures en DIDL-Lite :</p>
|
||||||
|
<div class="sourceCode" id="cb16"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb16-1"><a href="#cb16-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmodidl::</span><span class="op">{</span>Container<span class="op">,</span> Item<span class="op">,</span> Resource<span class="op">};</span></span>
|
||||||
|
<span id="cb16-2"><a href="#cb16-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb16-3"><a href="#cb16-3" aria-hidden="true" tabindex="-1"></a><span class="co">// Container</span></span>
|
||||||
|
<span id="cb16-4"><a href="#cb16-4" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">trait</span> ToDIDLContainer <span class="op">{</span></span>
|
||||||
|
<span id="cb16-5"><a href="#cb16-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> to_didl_container(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> parent_id<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Container<span class="op">>;</span></span>
|
||||||
|
<span id="cb16-6"><a href="#cb16-6" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb16-7"><a href="#cb16-7" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb16-8"><a href="#cb16-8" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> ToDIDLContainer <span class="cf">for</span> MyAlbum <span class="op">{</span></span>
|
||||||
|
<span id="cb16-9"><a href="#cb16-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> to_didl_container(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> parent_id<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Container<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb16-10"><a href="#cb16-10" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(Container <span class="op">{</span></span>
|
||||||
|
<span id="cb16-11"><a href="#cb16-11" aria-hidden="true" tabindex="-1"></a> id<span class="op">:</span> <span class="pp">format!</span>(<span class="st">"my-source:album:{}"</span><span class="op">,</span> <span class="kw">self</span><span class="op">.</span>id)<span class="op">,</span></span>
|
||||||
|
<span id="cb16-12"><a href="#cb16-12" aria-hidden="true" tabindex="-1"></a> parent_id<span class="op">:</span> parent_id<span class="op">.</span>to_string()<span class="op">,</span></span>
|
||||||
|
<span id="cb16-13"><a href="#cb16-13" aria-hidden="true" tabindex="-1"></a> restricted<span class="op">:</span> <span class="cn">Some</span>(<span class="st">"1"</span><span class="op">.</span>to_string())<span class="op">,</span></span>
|
||||||
|
<span id="cb16-14"><a href="#cb16-14" aria-hidden="true" tabindex="-1"></a> child_count<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>tracks_count<span class="op">.</span>map(<span class="op">|</span>c<span class="op">|</span> c<span class="op">.</span>to_string())<span class="op">,</span></span>
|
||||||
|
<span id="cb16-15"><a href="#cb16-15" aria-hidden="true" tabindex="-1"></a> searchable<span class="op">:</span> <span class="cn">Some</span>(<span class="st">"1"</span><span class="op">.</span>to_string())<span class="op">,</span></span>
|
||||||
|
<span id="cb16-16"><a href="#cb16-16" aria-hidden="true" tabindex="-1"></a> title<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>title<span class="op">.</span>clone()<span class="op">,</span></span>
|
||||||
|
<span id="cb16-17"><a href="#cb16-17" aria-hidden="true" tabindex="-1"></a> class<span class="op">:</span> <span class="st">"object.container.album.musicAlbum"</span><span class="op">.</span>to_string()<span class="op">,</span></span>
|
||||||
|
<span id="cb16-18"><a href="#cb16-18" aria-hidden="true" tabindex="-1"></a> artist<span class="op">:</span> <span class="cn">Some</span>(<span class="kw">self</span><span class="op">.</span>artist<span class="op">.</span>name<span class="op">.</span>clone())<span class="op">,</span></span>
|
||||||
|
<span id="cb16-19"><a href="#cb16-19" aria-hidden="true" tabindex="-1"></a> album_art<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>cover_url<span class="op">.</span>clone()<span class="op">,</span></span>
|
||||||
|
<span id="cb16-20"><a href="#cb16-20" aria-hidden="true" tabindex="-1"></a> containers<span class="op">:</span> <span class="pp">vec!</span>[]<span class="op">,</span></span>
|
||||||
|
<span id="cb16-21"><a href="#cb16-21" aria-hidden="true" tabindex="-1"></a> items<span class="op">:</span> <span class="pp">vec!</span>[]<span class="op">,</span></span>
|
||||||
|
<span id="cb16-22"><a href="#cb16-22" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
|
||||||
|
<span id="cb16-23"><a href="#cb16-23" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb16-24"><a href="#cb16-24" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb16-25"><a href="#cb16-25" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb16-26"><a href="#cb16-26" aria-hidden="true" tabindex="-1"></a><span class="co">// Item</span></span>
|
||||||
|
<span id="cb16-27"><a href="#cb16-27" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">trait</span> ToDIDLItem <span class="op">{</span></span>
|
||||||
|
<span id="cb16-28"><a href="#cb16-28" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> to_didl_item(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> parent_id<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Item<span class="op">>;</span></span>
|
||||||
|
<span id="cb16-29"><a href="#cb16-29" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb16-30"><a href="#cb16-30" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb16-31"><a href="#cb16-31" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> ToDIDLItem <span class="cf">for</span> MyTrack <span class="op">{</span></span>
|
||||||
|
<span id="cb16-32"><a href="#cb16-32" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> to_didl_item(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> parent_id<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Item<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb16-33"><a href="#cb16-33" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(Item <span class="op">{</span></span>
|
||||||
|
<span id="cb16-34"><a href="#cb16-34" aria-hidden="true" tabindex="-1"></a> id<span class="op">:</span> <span class="pp">format!</span>(<span class="st">"my-source:track:{}"</span><span class="op">,</span> <span class="kw">self</span><span class="op">.</span>id)<span class="op">,</span></span>
|
||||||
|
<span id="cb16-35"><a href="#cb16-35" aria-hidden="true" tabindex="-1"></a> parent_id<span class="op">:</span> parent_id<span class="op">.</span>to_string()<span class="op">,</span></span>
|
||||||
|
<span id="cb16-36"><a href="#cb16-36" aria-hidden="true" tabindex="-1"></a> restricted<span class="op">:</span> <span class="cn">Some</span>(<span class="st">"1"</span><span class="op">.</span>to_string())<span class="op">,</span></span>
|
||||||
|
<span id="cb16-37"><a href="#cb16-37" aria-hidden="true" tabindex="-1"></a> title<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>title<span class="op">.</span>clone()<span class="op">,</span></span>
|
||||||
|
<span id="cb16-38"><a href="#cb16-38" aria-hidden="true" tabindex="-1"></a> creator<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>artist<span class="op">.</span>as_ref()<span class="op">.</span>map(<span class="op">|</span>a<span class="op">|</span> a<span class="op">.</span>name<span class="op">.</span>clone())<span class="op">,</span></span>
|
||||||
|
<span id="cb16-39"><a href="#cb16-39" aria-hidden="true" tabindex="-1"></a> class<span class="op">:</span> <span class="st">"object.item.audioItem.musicTrack"</span><span class="op">.</span>to_string()<span class="op">,</span></span>
|
||||||
|
<span id="cb16-40"><a href="#cb16-40" aria-hidden="true" tabindex="-1"></a> artist<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>artist<span class="op">.</span>as_ref()<span class="op">.</span>map(<span class="op">|</span>a<span class="op">|</span> a<span class="op">.</span>name<span class="op">.</span>clone())<span class="op">,</span></span>
|
||||||
|
<span id="cb16-41"><a href="#cb16-41" aria-hidden="true" tabindex="-1"></a> album<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>album<span class="op">.</span>as_ref()<span class="op">.</span>map(<span class="op">|</span>a<span class="op">|</span> a<span class="op">.</span>title<span class="op">.</span>clone())<span class="op">,</span></span>
|
||||||
|
<span id="cb16-42"><a href="#cb16-42" aria-hidden="true" tabindex="-1"></a> genre<span class="op">:</span> <span class="cn">Some</span>(<span class="st">"Music"</span><span class="op">.</span>to_string())<span class="op">,</span></span>
|
||||||
|
<span id="cb16-43"><a href="#cb16-43" aria-hidden="true" tabindex="-1"></a> album_art<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>cover_url<span class="op">.</span>clone()<span class="op">,</span></span>
|
||||||
|
<span id="cb16-44"><a href="#cb16-44" aria-hidden="true" tabindex="-1"></a> album_art_pk<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>cover_pk<span class="op">.</span>clone()<span class="op">,</span></span>
|
||||||
|
<span id="cb16-45"><a href="#cb16-45" aria-hidden="true" tabindex="-1"></a> date<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>release_date<span class="op">.</span>clone()<span class="op">,</span></span>
|
||||||
|
<span id="cb16-46"><a href="#cb16-46" aria-hidden="true" tabindex="-1"></a> original_track_number<span class="op">:</span> <span class="cn">Some</span>(<span class="kw">self</span><span class="op">.</span>track_number)<span class="op">,</span></span>
|
||||||
|
<span id="cb16-47"><a href="#cb16-47" aria-hidden="true" tabindex="-1"></a> resources<span class="op">:</span> <span class="pp">vec!</span>[Resource <span class="op">{</span></span>
|
||||||
|
<span id="cb16-48"><a href="#cb16-48" aria-hidden="true" tabindex="-1"></a> protocol_info<span class="op">:</span> <span class="st">"http-get:*:audio/flac:*"</span><span class="op">.</span>to_string()<span class="op">,</span></span>
|
||||||
|
<span id="cb16-49"><a href="#cb16-49" aria-hidden="true" tabindex="-1"></a> bits_per_sample<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>bit_depth<span class="op">.</span>map(<span class="op">|</span>b<span class="op">|</span> b<span class="op">.</span>to_string())<span class="op">,</span></span>
|
||||||
|
<span id="cb16-50"><a href="#cb16-50" aria-hidden="true" tabindex="-1"></a> sample_frequency<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>sample_rate<span class="op">.</span>map(<span class="op">|</span>s<span class="op">|</span> s<span class="op">.</span>to_string())<span class="op">,</span></span>
|
||||||
|
<span id="cb16-51"><a href="#cb16-51" aria-hidden="true" tabindex="-1"></a> nr_audio_channels<span class="op">:</span> <span class="cn">Some</span>(<span class="st">"2"</span><span class="op">.</span>to_string())<span class="op">,</span></span>
|
||||||
|
<span id="cb16-52"><a href="#cb16-52" aria-hidden="true" tabindex="-1"></a> duration<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>duration_as_upnp_format()<span class="op">,</span></span>
|
||||||
|
<span id="cb16-53"><a href="#cb16-53" aria-hidden="true" tabindex="-1"></a> url<span class="op">:</span> <span class="pp">format!</span>(<span class="st">"/audio/flac/{}"</span><span class="op">,</span> <span class="kw">self</span><span class="op">.</span>cache_pk)<span class="op">,</span></span>
|
||||||
|
<span id="cb16-54"><a href="#cb16-54" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>]<span class="op">,</span></span>
|
||||||
|
<span id="cb16-55"><a href="#cb16-55" aria-hidden="true" tabindex="-1"></a> descriptions<span class="op">:</span> <span class="pp">vec!</span>[]<span class="op">,</span></span>
|
||||||
|
<span id="cb16-56"><a href="#cb16-56" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
|
||||||
|
<span id="cb16-57"><a href="#cb16-57" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb16-58"><a href="#cb16-58" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h2 id="checklist-de-mise-en-œuvre">Checklist de mise en œuvre</h2>
|
||||||
|
<h3 id="phase-1-structure-de-base">Phase 1 : Structure de base</h3>
|
||||||
|
<ul class="task-list">
|
||||||
|
<li><label><input type="checkbox" />Créer le crate
|
||||||
|
<code>pmo<votre-source></code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Ajouter les dépendances dans
|
||||||
|
<code>Cargo.toml</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Créer le logo WebP 300x300px dans
|
||||||
|
<code>assets/</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Définir la structure
|
||||||
|
principale</label></li>
|
||||||
|
<li><label><input type="checkbox" />Implémenter <code>name()</code>,
|
||||||
|
<code>id()</code>, <code>default_image()</code></label></li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="phase-2-navigation-contentdirectory">Phase 2 : Navigation
|
||||||
|
ContentDirectory</h3>
|
||||||
|
<ul class="task-list">
|
||||||
|
<li><label><input type="checkbox" />Définir le schéma d’Object
|
||||||
|
ID</label></li>
|
||||||
|
<li><label><input type="checkbox" />Implémenter
|
||||||
|
<code>root_container()</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Implémenter <code>browse()</code>
|
||||||
|
pour la racine</label></li>
|
||||||
|
<li><label><input type="checkbox" />Implémenter <code>browse()</code>
|
||||||
|
pour les sous-containers</label></li>
|
||||||
|
<li><label><input type="checkbox" />Implémenter <code>browse()</code>
|
||||||
|
pour les items</label></li>
|
||||||
|
<li><label><input type="checkbox" />Tester la navigation avec un client
|
||||||
|
UPnP</label></li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="phase-3-résolution-duri">Phase 3 : Résolution d’URI</h3>
|
||||||
|
<ul class="task-list">
|
||||||
|
<li><label><input type="checkbox" />Implémenter
|
||||||
|
<code>resolve_uri()</code> avec fallback</label></li>
|
||||||
|
<li><label><input type="checkbox" />Intégrer avec
|
||||||
|
<code>SourceCacheManager</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Implémenter
|
||||||
|
<code>LazyProvider</code> si cache lazy</label></li>
|
||||||
|
<li><label><input type="checkbox" />Tester la lecture audio</label></li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="phase-4-support-fifo-si-dynamique">Phase 4 : Support FIFO (si
|
||||||
|
dynamique)</h3>
|
||||||
|
<ul class="task-list">
|
||||||
|
<li><label><input type="checkbox" />Décider de la stratégie
|
||||||
|
FIFO</label></li>
|
||||||
|
<li><label><input type="checkbox" />Implémenter
|
||||||
|
<code>supports_fifo() = true</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Implémenter
|
||||||
|
<code>append_track()</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Implémenter
|
||||||
|
<code>remove_oldest()</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Implémenter <code>update_id()</code>
|
||||||
|
et <code>last_change()</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Enregistrer callbacks sur
|
||||||
|
playlists</label></li>
|
||||||
|
<li><label><input type="checkbox" />Tester ajout/suppression de
|
||||||
|
tracks</label></li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="phase-5-support-statique-si-catalogue">Phase 5 : Support
|
||||||
|
statique (si catalogue)</h3>
|
||||||
|
<ul class="task-list">
|
||||||
|
<li><label><input type="checkbox" />Implémenter
|
||||||
|
<code>supports_fifo() = false</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Implémenter <code>get_items()</code>
|
||||||
|
avec pagination</label></li>
|
||||||
|
<li><label><input type="checkbox" />Implémenter <code>search()</code> si
|
||||||
|
applicable</label></li>
|
||||||
|
<li><label><input type="checkbox" />Tester browsing du
|
||||||
|
catalogue</label></li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="phase-6-intégration-avancée">Phase 6 : Intégration avancée</h3>
|
||||||
|
<ul class="task-list">
|
||||||
|
<li><label><input type="checkbox" />Implémenter <code>get_item()</code>
|
||||||
|
pour metadata</label></li>
|
||||||
|
<li><label><input type="checkbox" />Implémenter
|
||||||
|
<code>capabilities()</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Implémenter
|
||||||
|
<code>get_available_formats()</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Ajouter gestion d’erreurs
|
||||||
|
robuste</label></li>
|
||||||
|
<li><label><input type="checkbox" />Documenter le code</label></li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="phase-7-tests-et-validation">Phase 7 : Tests et validation</h3>
|
||||||
|
<ul class="task-list">
|
||||||
|
<li><label><input type="checkbox" />Écrire tests unitaires</label></li>
|
||||||
|
<li><label><input type="checkbox" />Écrire tests
|
||||||
|
d’intégration</label></li>
|
||||||
|
<li><label><input type="checkbox" />Tester avec différents clients
|
||||||
|
UPnP</label></li>
|
||||||
|
<li><label><input type="checkbox" />Valider les
|
||||||
|
performances</label></li>
|
||||||
|
<li><label><input type="checkbox" />Documenter les
|
||||||
|
limitations</label></li>
|
||||||
|
</ul>
|
||||||
|
<h2 id="exemples-de-référence">Exemples de référence</h2>
|
||||||
|
<h3 id="radio-paradise-source-dynamique-fifo">Radio Paradise (source
|
||||||
|
dynamique FIFO)</h3>
|
||||||
|
<p><strong>Fichier :</strong> <code>pmoparadise/src/source.rs</code></p>
|
||||||
|
<p><strong>Points d’intérêt :</strong> - Structure avec
|
||||||
|
<code>Arc<RwLock<>></code> pour l’état partagé - Callbacks
|
||||||
|
sur playlists pour détecter les changements - Notifier injecté pour
|
||||||
|
ContentDirectory - Adaptation des IDs playlist → Radio Paradise -
|
||||||
|
Support de 4 canaux avec sous-containers</p>
|
||||||
|
<p><strong>Schema d’Object ID :</strong></p>
|
||||||
|
<pre><code>radio-paradise # Racine
|
||||||
|
radio-paradise:channel:{slug} # Canal (main, mellow, rock, eclectic)
|
||||||
|
radio-paradise:channel:{slug}:live # Stream live
|
||||||
|
radio-paradise:channel:{slug}:liveplaylist # Playlist live (queue)
|
||||||
|
radio-paradise:channel:{slug}:liveplaylist:track:{pk} # Track dans queue
|
||||||
|
radio-paradise:channel:{slug}:history # Historique
|
||||||
|
radio-paradise:channel:{slug}:history:track:{pk} # Track dans historique</pre>
|
||||||
|
<h3 id="qobuz-source-catalogue-avec-playlists-lazy">Qobuz (source
|
||||||
|
catalogue avec playlists lazy)</h3>
|
||||||
|
<p><strong>Fichier :</strong> <code>pmoqobuz/src/source.rs</code></p>
|
||||||
|
<p><strong>Points d’intérêt :</strong> - <code>SourceCacheManager</code>
|
||||||
|
centralisé - Cache lazy pour audio, eager pour covers -
|
||||||
|
<code>LazyProvider</code> personnalisé - Playlists d’albums avec TTL (7
|
||||||
|
jours) - Adaptation IDs playlist → Qobuz - Navigation hiérarchique
|
||||||
|
complexe (Discover, Genres, Favorites)</p>
|
||||||
|
<p><strong>Schema d’Object ID :</strong></p>
|
||||||
|
<pre><code>qobuz # Racine
|
||||||
|
qobuz:discover # Discover Catalog
|
||||||
|
qobuz:discover:albums:ideal # Albums (Ideal Discography)
|
||||||
|
qobuz:discover:artists # Artistes Featured
|
||||||
|
qobuz:genres # Discover Genres
|
||||||
|
qobuz:genre:{id} # Genre spécifique
|
||||||
|
qobuz:genre:{id}:new-releases # Nouveautés du genre
|
||||||
|
qobuz:favorites # My Music
|
||||||
|
qobuz:favorites:albums # Albums favoris
|
||||||
|
qobuz:album:{id} # Album spécifique
|
||||||
|
qobuz:track:{id} # Track spécifique
|
||||||
|
qobuz:playlist:{id} # Playlist spécifique
|
||||||
|
qobuz:artist:{id} # Artiste spécifique</pre>
|
||||||
|
<h2 id="conseils-dimplémentation">Conseils d’implémentation</h2>
|
||||||
|
<h3 id="performance">Performance</h3>
|
||||||
|
<ol type="1">
|
||||||
|
<li><strong>Cache agressif</strong> : Utilisez
|
||||||
|
<code>SourceCacheManager</code> pour tout</li>
|
||||||
|
<li><strong>Pagination</strong> : Limitez le nombre d’items retournés
|
||||||
|
(max 100)</li>
|
||||||
|
<li><strong>Lazy loading</strong> : Ne chargez que ce qui est
|
||||||
|
demandé</li>
|
||||||
|
<li><strong>Rate limiting</strong> : Respectez les limites API de la
|
||||||
|
source</li>
|
||||||
|
<li><strong>Arc<></strong> : Partagez les données coûteuses</li>
|
||||||
|
</ol>
|
||||||
|
<h3 id="compatibilité-upnp">Compatibilité UPnP</h3>
|
||||||
|
<ol type="1">
|
||||||
|
<li><strong>Genre obligatoire</strong> : Certains clients (gupnp-av-cp)
|
||||||
|
requièrent <code><upnp:genre></code></li>
|
||||||
|
<li><strong>URLs absolues</strong> : Toujours retourner des URLs
|
||||||
|
complètes (pas de chemins relatifs)</li>
|
||||||
|
<li><strong>Protocol Info</strong> : Utilisez
|
||||||
|
<code>http-get:*:audio/flac:*</code> pour FLAC</li>
|
||||||
|
<li><strong>Duration</strong> : Format <code>H:MM:SS</code> (ex:
|
||||||
|
<code>0:03:45</code>)</li>
|
||||||
|
<li><strong>childCount</strong> : Optionnel mais recommandé pour
|
||||||
|
l’UI</li>
|
||||||
|
</ol>
|
||||||
|
<h3 id="gestion-derreurs">Gestion d’erreurs</h3>
|
||||||
|
<ol type="1">
|
||||||
|
<li><strong>ObjectNotFound</strong> : ID invalide</li>
|
||||||
|
<li><strong>BrowseError</strong> : Erreur générique de navigation</li>
|
||||||
|
<li><strong>UriResolutionError</strong> : Impossible de résoudre
|
||||||
|
l’URI</li>
|
||||||
|
<li><strong>PlaylistError</strong> : Erreur d’interaction avec
|
||||||
|
pmoplaylist</li>
|
||||||
|
<li><strong>CacheError</strong> : Erreur de cache</li>
|
||||||
|
</ol>
|
||||||
|
<h3 id="thread-safety">Thread Safety</h3>
|
||||||
|
<ol type="1">
|
||||||
|
<li><strong>Arc<RwLock<>></strong> : Pour l’état mutable
|
||||||
|
partagé</li>
|
||||||
|
<li><strong>tokio::sync::RwLock</strong> : Pour l’async</li>
|
||||||
|
<li><strong>Éviter Rc<></strong> : Pas thread-safe</li>
|
||||||
|
<li><strong>Clone</strong> : Implémentez <code>Clone</code> pour
|
||||||
|
<code>Arc<></code></li>
|
||||||
|
</ol>
|
||||||
|
<h2 id="conclusion">Conclusion</h2>
|
||||||
|
<p>L’implémentation d’une nouvelle <code>MusicSource</code> suit ces
|
||||||
|
étapes :</p>
|
||||||
|
<ol type="1">
|
||||||
|
<li><strong>Définir le schéma d’Object ID</strong> : Hiérarchie claire
|
||||||
|
et cohérente</li>
|
||||||
|
<li><strong>Implémenter la navigation</strong> : <code>browse()</code>
|
||||||
|
pour tous les niveaux</li>
|
||||||
|
<li><strong>Résoudre les URIs</strong> : Cache local d’abord, puis
|
||||||
|
original</li>
|
||||||
|
<li><strong>Gérer le cache</strong> : <code>SourceCacheManager</code> +
|
||||||
|
<code>LazyProvider</code></li>
|
||||||
|
<li><strong>Adapter les IDs</strong> : Playlist → Schema de la
|
||||||
|
source</li>
|
||||||
|
<li><strong>Notifier les changements</strong> : <code>update_id</code> +
|
||||||
|
callbacks</li>
|
||||||
|
</ol>
|
||||||
|
<p>Les exemples Radio Paradise et Qobuz couvrent les deux patterns
|
||||||
|
principaux : - <strong>Dynamique FIFO</strong> : Radio Paradise -
|
||||||
|
<strong>Catalogue lazy</strong> : Qobuz</p>
|
||||||
|
<p>En suivant ces patterns, vous obtiendrez une source musicale
|
||||||
|
performante, compatible UPnP, et bien intégrée dans l’écosystème
|
||||||
|
PMOMusic.</p>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1023
Blackboard_HTML/Architecture_pmoconfig_ext.html
Normal file
1023
Blackboard_HTML/Architecture_pmoconfig_ext.html
Normal file
File diff suppressed because it is too large
Load Diff
929
Blackboard_HTML/Architecture_pmoserver_ext.html
Normal file
929
Blackboard_HTML/Architecture_pmoserver_ext.html
Normal file
@@ -0,0 +1,929 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>pmoserver_ext</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
||||||
|
<script type="module">
|
||||||
|
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||||
|
mermaid.initialize({startOnLoad: true, theme: "default"});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
pre.mermaid {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="markdown-body">
|
||||||
|
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
|
||||||
|
<h1 id="pattern-dextension-pmoserver-pmoserver_ext">Pattern d’extension
|
||||||
|
PMOServer (<code>pmoserver_ext</code>)</h1>
|
||||||
|
<h2 id="vue-densemble">Vue d’ensemble</h2>
|
||||||
|
<p>Le pattern <code>pmoserver_ext</code> permet d’étendre les
|
||||||
|
fonctionnalités du serveur HTTP <code>pmoserver</code> de manière
|
||||||
|
modulaire et découplée. Chaque crate spécialisée peut ajouter ses
|
||||||
|
propres routes HTTP sans que <code>pmoserver</code> ne dépende de ces
|
||||||
|
crates.</p>
|
||||||
|
<p><strong>Principe</strong> : Définir un trait d’extension que
|
||||||
|
<code>pmoserver::Server</code> implémente via une feature Cargo.</p>
|
||||||
|
<h2 id="anatomie-dune-extension">Anatomie d’une extension</h2>
|
||||||
|
<h3 id="structure-du-module">1. Structure du module</h3>
|
||||||
|
<p>Créer un module <code>pmoserver_ext.rs</code> dans la crate :</p>
|
||||||
|
<div class="sourceCode" id="cb1"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="co">// pmoXXX/src/pmoserver_ext.rs</span></span>
|
||||||
|
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">"pmoserver"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb1-4"><a href="#cb1-4" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="kw">crate</span><span class="pp">::</span><span class="op">{</span><span class="co">/* types internes de la crate */</span><span class="op">};</span></span>
|
||||||
|
<span id="cb1-5"><a href="#cb1-5" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">"pmoserver"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb1-6"><a href="#cb1-6" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">async_trait::</span>async_trait<span class="op">;</span></span>
|
||||||
|
<span id="cb1-7"><a href="#cb1-7" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">"pmoserver"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb1-8"><a href="#cb1-8" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">axum::</span><span class="op">{</span>Router<span class="op">,</span> <span class="pp">routing::</span>get<span class="op">,</span> Json<span class="op">,</span> <span class="pp">extract::</span><span class="op">{</span>State<span class="op">,</span> <span class="dt">Path</span><span class="op">}};</span></span>
|
||||||
|
<span id="cb1-9"><a href="#cb1-9" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">"pmoserver"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb1-10"><a href="#cb1-10" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">std::sync::</span>Arc<span class="op">;</span></span></pre></div>
|
||||||
|
<p>Déclarer le module dans <code>lib.rs</code> :</p>
|
||||||
|
<div class="sourceCode" id="cb2"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a><span class="co">// pmoXXX/src/lib.rs</span></span>
|
||||||
|
<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">"pmoserver"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb2-3"><a href="#cb2-3" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">mod</span> pmoserver_ext<span class="op">;</span></span>
|
||||||
|
<span id="cb2-4"><a href="#cb2-4" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb2-5"><a href="#cb2-5" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">"pmoserver"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb2-6"><a href="#cb2-6" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">use</span> <span class="pp">pmoserver_ext::</span>XXXExt<span class="op">;</span></span></pre></div>
|
||||||
|
<p>Ajouter la feature dans <code>Cargo.toml</code> :</p>
|
||||||
|
<div class="sourceCode" id="cb3"><pre
|
||||||
|
class="sourceCode toml"><code class="sourceCode toml"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="kw">[features]</span></span>
|
||||||
|
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a><span class="dt">pmoserver</span> <span class="op">=</span> <span class="op">[</span><span class="st">"dep:axum"</span><span class="op">,</span> <span class="st">"dep:async-trait"</span><span class="op">]</span></span>
|
||||||
|
<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a><span class="kw">[dependencies]</span></span>
|
||||||
|
<span id="cb3-5"><a href="#cb3-5" aria-hidden="true" tabindex="-1"></a><span class="dt">axum</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">version</span><span class="op"> =</span> <span class="st">"0.8"</span><span class="op">, </span><span class="dt">optional</span><span class="op"> =</span> <span class="cn">true</span><span class="op"> }</span></span>
|
||||||
|
<span id="cb3-6"><a href="#cb3-6" aria-hidden="true" tabindex="-1"></a><span class="dt">async-trait</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">version</span><span class="op"> =</span> <span class="st">"0.1"</span><span class="op">, </span><span class="dt">optional</span><span class="op"> =</span> <span class="cn">true</span><span class="op"> }</span></span>
|
||||||
|
<span id="cb3-7"><a href="#cb3-7" aria-hidden="true" tabindex="-1"></a><span class="dt">pmoserver</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">path</span><span class="op"> =</span> <span class="st">"../pmoserver"</span><span class="op"> }</span></span></pre></div>
|
||||||
|
<h3 id="définir-le-trait-dextension">2. Définir le trait
|
||||||
|
d’extension</h3>
|
||||||
|
<p><strong>Convention de nommage</strong> : <code>{Domaine}Ext</code>
|
||||||
|
avec méthodes préfixées <code>init_*</code></p>
|
||||||
|
<div class="sourceCode" id="cb4"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="co">/// Trait pour étendre pmoserver avec les fonctionnalités XXX</span></span>
|
||||||
|
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">"pmoserver"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
|
||||||
|
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">trait</span> XXXExt <span class="op">{</span></span>
|
||||||
|
<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Initialise l'extension XXX et enregistre les routes HTTP</span></span>
|
||||||
|
<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a> <span class="co">///</span></span>
|
||||||
|
<span id="cb4-7"><a href="#cb4-7" aria-hidden="true" tabindex="-1"></a> <span class="co">/// # Arguments</span></span>
|
||||||
|
<span id="cb4-8"><a href="#cb4-8" aria-hidden="true" tabindex="-1"></a> <span class="co">/// * `param1` - Description du paramètre</span></span>
|
||||||
|
<span id="cb4-9"><a href="#cb4-9" aria-hidden="true" tabindex="-1"></a> <span class="co">///</span></span>
|
||||||
|
<span id="cb4-10"><a href="#cb4-10" aria-hidden="true" tabindex="-1"></a> <span class="co">/// # Returns</span></span>
|
||||||
|
<span id="cb4-11"><a href="#cb4-11" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Instance partagée de la ressource créée</span></span>
|
||||||
|
<span id="cb4-12"><a href="#cb4-12" aria-hidden="true" tabindex="-1"></a> <span class="co">///</span></span>
|
||||||
|
<span id="cb4-13"><a href="#cb4-13" aria-hidden="true" tabindex="-1"></a> <span class="co">/// # Exemple</span></span>
|
||||||
|
<span id="cb4-14"><a href="#cb4-14" aria-hidden="true" tabindex="-1"></a> <span class="co">/// ```ignore</span></span>
|
||||||
|
<span id="cb4-15"><a href="#cb4-15" aria-hidden="true" tabindex="-1"></a> <span class="co">/// use pmoserver::ServerBuilder;</span></span>
|
||||||
|
<span id="cb4-16"><a href="#cb4-16" aria-hidden="true" tabindex="-1"></a> <span class="co">/// use pmoXXX::XXXExt;</span></span>
|
||||||
|
<span id="cb4-17"><a href="#cb4-17" aria-hidden="true" tabindex="-1"></a> <span class="co">///</span></span>
|
||||||
|
<span id="cb4-18"><a href="#cb4-18" aria-hidden="true" tabindex="-1"></a> <span class="co">/// let mut server = ServerBuilder::new(...).build();</span></span>
|
||||||
|
<span id="cb4-19"><a href="#cb4-19" aria-hidden="true" tabindex="-1"></a> <span class="co">/// let resource = server.init_xxx(param1).await?;</span></span>
|
||||||
|
<span id="cb4-20"><a href="#cb4-20" aria-hidden="true" tabindex="-1"></a> <span class="co">/// ```</span></span>
|
||||||
|
<span id="cb4-21"><a href="#cb4-21" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> init_xxx(<span class="op">&</span><span class="kw">mut</span> <span class="kw">self</span><span class="op">,</span> param1<span class="op">:</span> <span class="dt">String</span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>Arc<span class="op"><</span>Resource<span class="op">>>;</span></span>
|
||||||
|
<span id="cb4-22"><a href="#cb4-22" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h3 id="implémenter-le-trait">3. Implémenter le trait</h3>
|
||||||
|
<p>Implémenter le trait pour <code>pmoserver::Server</code> :</p>
|
||||||
|
<div class="sourceCode" id="cb5"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">"pmoserver"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
|
||||||
|
<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> XXXExt <span class="cf">for</span> <span class="pp">pmoserver::</span>Server <span class="op">{</span></span>
|
||||||
|
<span id="cb5-4"><a href="#cb5-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> init_xxx(<span class="op">&</span><span class="kw">mut</span> <span class="kw">self</span><span class="op">,</span> param1<span class="op">:</span> <span class="dt">String</span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>Arc<span class="op"><</span>Resource<span class="op">>></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb5-5"><a href="#cb5-5" aria-hidden="true" tabindex="-1"></a> <span class="co">// 1. Créer la ressource interne</span></span>
|
||||||
|
<span id="cb5-6"><a href="#cb5-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> resource <span class="op">=</span> <span class="pp">Arc::</span>new(<span class="pp">Resource::</span>new(param1)<span class="op">?</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb5-7"><a href="#cb5-7" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb5-8"><a href="#cb5-8" aria-hidden="true" tabindex="-1"></a> <span class="co">// 2. Créer l'état partagé pour les handlers</span></span>
|
||||||
|
<span id="cb5-9"><a href="#cb5-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> state <span class="op">=</span> <span class="pp">XxxState::</span>new(resource<span class="op">.</span>clone())<span class="op">;</span></span>
|
||||||
|
<span id="cb5-10"><a href="#cb5-10" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb5-11"><a href="#cb5-11" aria-hidden="true" tabindex="-1"></a> <span class="co">// 3. Créer le router avec les routes</span></span>
|
||||||
|
<span id="cb5-12"><a href="#cb5-12" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> router <span class="op">=</span> create_xxx_router(state)<span class="op">;</span></span>
|
||||||
|
<span id="cb5-13"><a href="#cb5-13" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb5-14"><a href="#cb5-14" aria-hidden="true" tabindex="-1"></a> <span class="co">// 4. Enregistrer le router sur le serveur</span></span>
|
||||||
|
<span id="cb5-15"><a href="#cb5-15" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_router(<span class="st">"/api/xxx"</span><span class="op">,</span> router)<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
|
||||||
|
<span id="cb5-16"><a href="#cb5-16" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb5-17"><a href="#cb5-17" aria-hidden="true" tabindex="-1"></a> <span class="co">// 5. Retourner la ressource pour usage ultérieur</span></span>
|
||||||
|
<span id="cb5-18"><a href="#cb5-18" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(resource)</span>
|
||||||
|
<span id="cb5-19"><a href="#cb5-19" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb5-20"><a href="#cb5-20" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h3 id="état-partagé-state">4. État partagé (State)</h3>
|
||||||
|
<p>Créer une structure d’état cloneable pour les handlers :</p>
|
||||||
|
<div class="sourceCode" id="cb6"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb6-1"><a href="#cb6-1" aria-hidden="true" tabindex="-1"></a><span class="co">/// État partagé pour les handlers XXX</span></span>
|
||||||
|
<span id="cb6-2"><a href="#cb6-2" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Clone</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb6-3"><a href="#cb6-3" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> XxxState <span class="op">{</span></span>
|
||||||
|
<span id="cb6-4"><a href="#cb6-4" aria-hidden="true" tabindex="-1"></a> resource<span class="op">:</span> Arc<span class="op"><</span>Resource<span class="op">>,</span></span>
|
||||||
|
<span id="cb6-5"><a href="#cb6-5" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb6-6"><a href="#cb6-6" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb6-7"><a href="#cb6-7" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> XxxState <span class="op">{</span></span>
|
||||||
|
<span id="cb6-8"><a href="#cb6-8" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> <span class="kw">fn</span> new(resource<span class="op">:</span> Arc<span class="op"><</span>Resource<span class="op">></span>) <span class="op">-></span> <span class="dt">Self</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb6-9"><a href="#cb6-9" aria-hidden="true" tabindex="-1"></a> <span class="dt">Self</span> <span class="op">{</span> resource <span class="op">}</span></span>
|
||||||
|
<span id="cb6-10"><a href="#cb6-10" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb6-11"><a href="#cb6-11" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h3 id="créer-le-router">5. Créer le router</h3>
|
||||||
|
<p>Définir les routes et handlers :</p>
|
||||||
|
<div class="sourceCode" id="cb7"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb7-1"><a href="#cb7-1" aria-hidden="true" tabindex="-1"></a><span class="co">/// Crée le router pour l'API XXX</span></span>
|
||||||
|
<span id="cb7-2"><a href="#cb7-2" aria-hidden="true" tabindex="-1"></a><span class="kw">fn</span> create_xxx_router(state<span class="op">:</span> XxxState) <span class="op">-></span> Router <span class="op">{</span></span>
|
||||||
|
<span id="cb7-3"><a href="#cb7-3" aria-hidden="true" tabindex="-1"></a> <span class="pp">Router::</span>new()</span>
|
||||||
|
<span id="cb7-4"><a href="#cb7-4" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>route(<span class="st">"/items"</span><span class="op">,</span> get(list_items)<span class="op">.</span>post(create_item))</span>
|
||||||
|
<span id="cb7-5"><a href="#cb7-5" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>route(<span class="st">"/items/{id}"</span><span class="op">,</span> get(get_item)<span class="op">.</span>delete(delete_item))</span>
|
||||||
|
<span id="cb7-6"><a href="#cb7-6" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>with_state(state)</span>
|
||||||
|
<span id="cb7-7"><a href="#cb7-7" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb7-8"><a href="#cb7-8" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb7-9"><a href="#cb7-9" aria-hidden="true" tabindex="-1"></a><span class="co">// Handlers</span></span>
|
||||||
|
<span id="cb7-10"><a href="#cb7-10" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> list_items(</span>
|
||||||
|
<span id="cb7-11"><a href="#cb7-11" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op"><</span>XxxState<span class="op">></span></span>
|
||||||
|
<span id="cb7-12"><a href="#cb7-12" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> Json<span class="op"><</span><span class="dt">Vec</span><span class="op"><</span>ItemSummary<span class="op">>></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb7-13"><a href="#cb7-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> items <span class="op">=</span> state<span class="op">.</span>resource<span class="op">.</span>list_items()<span class="op">;</span></span>
|
||||||
|
<span id="cb7-14"><a href="#cb7-14" aria-hidden="true" tabindex="-1"></a> Json(items)</span>
|
||||||
|
<span id="cb7-15"><a href="#cb7-15" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb7-16"><a href="#cb7-16" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb7-17"><a href="#cb7-17" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> get_item(</span>
|
||||||
|
<span id="cb7-18"><a href="#cb7-18" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op"><</span>XxxState<span class="op">>,</span></span>
|
||||||
|
<span id="cb7-19"><a href="#cb7-19" aria-hidden="true" tabindex="-1"></a> <span class="dt">Path</span>(id)<span class="op">:</span> <span class="dt">Path</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb7-20"><a href="#cb7-20" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Json<span class="op"><</span>Item<span class="op">>,</span> StatusCode<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb7-21"><a href="#cb7-21" aria-hidden="true" tabindex="-1"></a> state<span class="op">.</span>resource<span class="op">.</span>get_item(<span class="op">&</span>id)</span>
|
||||||
|
<span id="cb7-22"><a href="#cb7-22" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>ok_or(<span class="pp">StatusCode::</span>NOT_FOUND)</span>
|
||||||
|
<span id="cb7-23"><a href="#cb7-23" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map(Json)</span>
|
||||||
|
<span id="cb7-24"><a href="#cb7-24" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h2 id="méthodes-disponibles-du-serveur">Méthodes disponibles du
|
||||||
|
serveur</h2>
|
||||||
|
<p><code>pmoserver::Server</code> expose ces méthodes pour enregistrer
|
||||||
|
des routes :</p>
|
||||||
|
<table>
|
||||||
|
<colgroup>
|
||||||
|
<col style="width: 56%" />
|
||||||
|
<col style="width: 43%" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Méthode</th>
|
||||||
|
<th>Usage</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>add_handler(path, handler)</code></td>
|
||||||
|
<td>Ajoute un handler simple sans état</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>add_handler_with_state(path, handler, state)</code></td>
|
||||||
|
<td>Ajoute un handler avec état partagé</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>add_router(path, router)</code></td>
|
||||||
|
<td>Monte un sous-router Axum</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>add_openapi(router, doc, tag)</code></td>
|
||||||
|
<td>Enregistre une API avec documentation OpenAPI</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>add_spa::<W>(path)</code></td>
|
||||||
|
<td>Sert une Single Page Application (RustEmbed)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>base_url()</code></td>
|
||||||
|
<td>Récupère l’URL de base du serveur</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h2 id="documentation-openapi-avec-utoipa">Documentation OpenAPI avec
|
||||||
|
utoipa</h2>
|
||||||
|
<p>La documentation OpenAPI est essentielle pour une extension
|
||||||
|
<code>pmoserver</code>. Elle génère automatiquement une interface
|
||||||
|
Swagger UI et documente les endpoints de l’API.</p>
|
||||||
|
<h3 id="configuration-de-base">Configuration de base</h3>
|
||||||
|
<p>Ajouter <code>utoipa</code> dans <code>Cargo.toml</code> :</p>
|
||||||
|
<div class="sourceCode" id="cb8"><pre
|
||||||
|
class="sourceCode toml"><code class="sourceCode toml"><span id="cb8-1"><a href="#cb8-1" aria-hidden="true" tabindex="-1"></a><span class="kw">[dependencies]</span></span>
|
||||||
|
<span id="cb8-2"><a href="#cb8-2" aria-hidden="true" tabindex="-1"></a><span class="dt">utoipa</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">version</span><span class="op"> =</span> <span class="st">"5"</span><span class="op">, </span><span class="dt">features</span><span class="op"> =</span> <span class="op">[</span><span class="st">"axum_extras"</span><span class="op">] }</span></span>
|
||||||
|
<span id="cb8-3"><a href="#cb8-3" aria-hidden="true" tabindex="-1"></a><span class="dt">serde</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">version</span><span class="op"> =</span> <span class="st">"1"</span><span class="op">, </span><span class="dt">features</span><span class="op"> =</span> <span class="op">[</span><span class="st">"derive"</span><span class="op">] }</span></span></pre></div>
|
||||||
|
<h3 id="définir-les-schémas-de-données">1. Définir les schémas de
|
||||||
|
données</h3>
|
||||||
|
<p>Annoter les structures de réponse/requête avec
|
||||||
|
<code>#[derive(ToSchema)]</code> :</p>
|
||||||
|
<div class="sourceCode" id="cb9"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb9-1"><a href="#cb9-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">serde::</span><span class="op">{</span>Serialize<span class="op">,</span> Deserialize<span class="op">};</span></span>
|
||||||
|
<span id="cb9-2"><a href="#cb9-2" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">utoipa::</span>ToSchema<span class="op">;</span></span>
|
||||||
|
<span id="cb9-3"><a href="#cb9-3" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb9-4"><a href="#cb9-4" aria-hidden="true" tabindex="-1"></a><span class="co">/// Information sur un item</span></span>
|
||||||
|
<span id="cb9-5"><a href="#cb9-5" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> Deserialize<span class="op">,</span> ToSchema<span class="at">)]</span></span>
|
||||||
|
<span id="cb9-6"><a href="#cb9-6" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> ItemInfo <span class="op">{</span></span>
|
||||||
|
<span id="cb9-7"><a href="#cb9-7" aria-hidden="true" tabindex="-1"></a> <span class="co">/// ID unique de l'item</span></span>
|
||||||
|
<span id="cb9-8"><a href="#cb9-8" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="st">"item-123"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb9-9"><a href="#cb9-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> id<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
|
||||||
|
<span id="cb9-10"><a href="#cb9-10" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb9-11"><a href="#cb9-11" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Nom de l'item</span></span>
|
||||||
|
<span id="cb9-12"><a href="#cb9-12" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="st">"Mon Item"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb9-13"><a href="#cb9-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> name<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
|
||||||
|
<span id="cb9-14"><a href="#cb9-14" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb9-15"><a href="#cb9-15" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Description optionnelle</span></span>
|
||||||
|
<span id="cb9-16"><a href="#cb9-16" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="st">"Une description détaillée"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb9-17"><a href="#cb9-17" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> description<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb9-18"><a href="#cb9-18" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb9-19"><a href="#cb9-19" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Timestamp de création (millisecondes)</span></span>
|
||||||
|
<span id="cb9-20"><a href="#cb9-20" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="dv">1234567890</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb9-21"><a href="#cb9-21" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> created_at<span class="op">:</span> <span class="dt">u64</span><span class="op">,</span></span>
|
||||||
|
<span id="cb9-22"><a href="#cb9-22" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb9-23"><a href="#cb9-23" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb9-24"><a href="#cb9-24" aria-hidden="true" tabindex="-1"></a><span class="co">/// Liste d'items</span></span>
|
||||||
|
<span id="cb9-25"><a href="#cb9-25" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> ToSchema<span class="at">)]</span></span>
|
||||||
|
<span id="cb9-26"><a href="#cb9-26" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> ItemList <span class="op">{</span></span>
|
||||||
|
<span id="cb9-27"><a href="#cb9-27" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Nombre total d'items</span></span>
|
||||||
|
<span id="cb9-28"><a href="#cb9-28" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> total<span class="op">:</span> <span class="dt">usize</span><span class="op">,</span></span>
|
||||||
|
<span id="cb9-29"><a href="#cb9-29" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb9-30"><a href="#cb9-30" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Items de la page courante</span></span>
|
||||||
|
<span id="cb9-31"><a href="#cb9-31" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> items<span class="op">:</span> <span class="dt">Vec</span><span class="op"><</span>ItemInfo<span class="op">>,</span></span>
|
||||||
|
<span id="cb9-32"><a href="#cb9-32" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb9-33"><a href="#cb9-33" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb9-34"><a href="#cb9-34" aria-hidden="true" tabindex="-1"></a><span class="co">/// Requête de création d'item</span></span>
|
||||||
|
<span id="cb9-35"><a href="#cb9-35" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Deserialize<span class="op">,</span> ToSchema<span class="at">)]</span></span>
|
||||||
|
<span id="cb9-36"><a href="#cb9-36" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> CreateItemRequest <span class="op">{</span></span>
|
||||||
|
<span id="cb9-37"><a href="#cb9-37" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Nom de l'item à créer</span></span>
|
||||||
|
<span id="cb9-38"><a href="#cb9-38" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="st">"Nouvel Item"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb9-39"><a href="#cb9-39" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> name<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
|
||||||
|
<span id="cb9-40"><a href="#cb9-40" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb9-41"><a href="#cb9-41" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Description optionnelle</span></span>
|
||||||
|
<span id="cb9-42"><a href="#cb9-42" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> description<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb9-43"><a href="#cb9-43" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb9-44"><a href="#cb9-44" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb9-45"><a href="#cb9-45" aria-hidden="true" tabindex="-1"></a><span class="co">/// Réponse d'erreur standard</span></span>
|
||||||
|
<span id="cb9-46"><a href="#cb9-46" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> ToSchema<span class="at">)]</span></span>
|
||||||
|
<span id="cb9-47"><a href="#cb9-47" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> ErrorResponse <span class="op">{</span></span>
|
||||||
|
<span id="cb9-48"><a href="#cb9-48" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Message d'erreur</span></span>
|
||||||
|
<span id="cb9-49"><a href="#cb9-49" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="st">"Item not found"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb9-50"><a href="#cb9-50" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> error<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
|
||||||
|
<span id="cb9-51"><a href="#cb9-51" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<p><strong>Points clés</strong> : -
|
||||||
|
<code>#[schema(example = "...")]</code> : Fournit des exemples pour la
|
||||||
|
doc Swagger - Documenter chaque champ avec <code>///</code> pour
|
||||||
|
apparaître dans l’API - Utiliser <code>Option<T></code> pour les
|
||||||
|
champs optionnels</p>
|
||||||
|
<h3 id="annoter-les-handlers">2. Annoter les handlers</h3>
|
||||||
|
<p>Utiliser <code>#[utoipa::path(...)]</code> pour documenter chaque
|
||||||
|
endpoint :</p>
|
||||||
|
<div class="sourceCode" id="cb10"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb10-1"><a href="#cb10-1" aria-hidden="true" tabindex="-1"></a><span class="co">/// GET /items - Liste tous les items</span></span>
|
||||||
|
<span id="cb10-2"><a href="#cb10-2" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span><span class="pp">utoipa::</span>path<span class="at">(</span></span>
|
||||||
|
<span id="cb10-3"><a href="#cb10-3" aria-hidden="true" tabindex="-1"></a> get<span class="op">,</span></span>
|
||||||
|
<span id="cb10-4"><a href="#cb10-4" aria-hidden="true" tabindex="-1"></a> path <span class="op">=</span> <span class="st">"/items"</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-5"><a href="#cb10-5" aria-hidden="true" tabindex="-1"></a> params<span class="at">(</span></span>
|
||||||
|
<span id="cb10-6"><a href="#cb10-6" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span><span class="st">"limit"</span> <span class="op">=</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">u32</span><span class="op">>,</span> Query<span class="op">,</span> description <span class="op">=</span> <span class="st">"Nombre max d'items à retourner"</span><span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-7"><a href="#cb10-7" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span><span class="st">"offset"</span> <span class="op">=</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">u32</span><span class="op">>,</span> Query<span class="op">,</span> description <span class="op">=</span> <span class="st">"Offset pour la pagination"</span><span class="at">)</span></span>
|
||||||
|
<span id="cb10-8"><a href="#cb10-8" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-9"><a href="#cb10-9" aria-hidden="true" tabindex="-1"></a> responses<span class="at">(</span></span>
|
||||||
|
<span id="cb10-10"><a href="#cb10-10" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">200</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"Liste des items"</span><span class="op">,</span> body <span class="op">=</span> ItemList<span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-11"><a href="#cb10-11" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">500</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"Erreur serveur"</span><span class="op">,</span> body <span class="op">=</span> ErrorResponse<span class="at">)</span></span>
|
||||||
|
<span id="cb10-12"><a href="#cb10-12" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-13"><a href="#cb10-13" aria-hidden="true" tabindex="-1"></a> tag <span class="op">=</span> <span class="st">"items"</span></span>
|
||||||
|
<span id="cb10-14"><a href="#cb10-14" aria-hidden="true" tabindex="-1"></a><span class="at">)]</span></span>
|
||||||
|
<span id="cb10-15"><a href="#cb10-15" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> list_items(</span>
|
||||||
|
<span id="cb10-16"><a href="#cb10-16" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op"><</span>XxxState<span class="op">>,</span></span>
|
||||||
|
<span id="cb10-17"><a href="#cb10-17" aria-hidden="true" tabindex="-1"></a> Query(params)<span class="op">:</span> Query<span class="op"><</span>ListParams<span class="op">>,</span></span>
|
||||||
|
<span id="cb10-18"><a href="#cb10-18" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Json<span class="op"><</span>ItemList<span class="op">>,</span> (StatusCode<span class="op">,</span> Json<span class="op"><</span>ErrorResponse<span class="op">></span>)<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb10-19"><a href="#cb10-19" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> items <span class="op">=</span> state<span class="op">.</span>resource<span class="op">.</span>list_items(params<span class="op">.</span>limit<span class="op">,</span> params<span class="op">.</span>offset)</span>
|
||||||
|
<span id="cb10-20"><a href="#cb10-20" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> (</span>
|
||||||
|
<span id="cb10-21"><a href="#cb10-21" aria-hidden="true" tabindex="-1"></a> <span class="pp">StatusCode::</span>INTERNAL_SERVER_ERROR<span class="op">,</span></span>
|
||||||
|
<span id="cb10-22"><a href="#cb10-22" aria-hidden="true" tabindex="-1"></a> Json(ErrorResponse <span class="op">{</span> error<span class="op">:</span> e<span class="op">.</span>to_string() <span class="op">}</span>)</span>
|
||||||
|
<span id="cb10-23"><a href="#cb10-23" aria-hidden="true" tabindex="-1"></a> ))<span class="op">?;</span></span>
|
||||||
|
<span id="cb10-24"><a href="#cb10-24" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb10-25"><a href="#cb10-25" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(Json(ItemList <span class="op">{</span></span>
|
||||||
|
<span id="cb10-26"><a href="#cb10-26" aria-hidden="true" tabindex="-1"></a> total<span class="op">:</span> items<span class="op">.</span>len()<span class="op">,</span></span>
|
||||||
|
<span id="cb10-27"><a href="#cb10-27" aria-hidden="true" tabindex="-1"></a> items<span class="op">,</span></span>
|
||||||
|
<span id="cb10-28"><a href="#cb10-28" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>))</span>
|
||||||
|
<span id="cb10-29"><a href="#cb10-29" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb10-30"><a href="#cb10-30" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb10-31"><a href="#cb10-31" aria-hidden="true" tabindex="-1"></a><span class="co">/// GET /items/{id} - Récupère un item spécifique</span></span>
|
||||||
|
<span id="cb10-32"><a href="#cb10-32" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span><span class="pp">utoipa::</span>path<span class="at">(</span></span>
|
||||||
|
<span id="cb10-33"><a href="#cb10-33" aria-hidden="true" tabindex="-1"></a> get<span class="op">,</span></span>
|
||||||
|
<span id="cb10-34"><a href="#cb10-34" aria-hidden="true" tabindex="-1"></a> path <span class="op">=</span> <span class="st">"/items/{id}"</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-35"><a href="#cb10-35" aria-hidden="true" tabindex="-1"></a> params<span class="at">(</span></span>
|
||||||
|
<span id="cb10-36"><a href="#cb10-36" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span><span class="st">"id"</span> <span class="op">=</span> <span class="dt">String</span><span class="op">,</span> <span class="dt">Path</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"ID unique de l'item"</span><span class="at">)</span></span>
|
||||||
|
<span id="cb10-37"><a href="#cb10-37" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-38"><a href="#cb10-38" aria-hidden="true" tabindex="-1"></a> responses<span class="at">(</span></span>
|
||||||
|
<span id="cb10-39"><a href="#cb10-39" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">200</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"Item trouvé"</span><span class="op">,</span> body <span class="op">=</span> ItemInfo<span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-40"><a href="#cb10-40" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">404</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"Item non trouvé"</span><span class="op">,</span> body <span class="op">=</span> ErrorResponse<span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-41"><a href="#cb10-41" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">500</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"Erreur serveur"</span><span class="op">,</span> body <span class="op">=</span> ErrorResponse<span class="at">)</span></span>
|
||||||
|
<span id="cb10-42"><a href="#cb10-42" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-43"><a href="#cb10-43" aria-hidden="true" tabindex="-1"></a> tag <span class="op">=</span> <span class="st">"items"</span></span>
|
||||||
|
<span id="cb10-44"><a href="#cb10-44" aria-hidden="true" tabindex="-1"></a><span class="at">)]</span></span>
|
||||||
|
<span id="cb10-45"><a href="#cb10-45" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> get_item(</span>
|
||||||
|
<span id="cb10-46"><a href="#cb10-46" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op"><</span>XxxState<span class="op">>,</span></span>
|
||||||
|
<span id="cb10-47"><a href="#cb10-47" aria-hidden="true" tabindex="-1"></a> <span class="dt">Path</span>(id)<span class="op">:</span> <span class="dt">Path</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb10-48"><a href="#cb10-48" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Json<span class="op"><</span>ItemInfo<span class="op">>,</span> (StatusCode<span class="op">,</span> Json<span class="op"><</span>ErrorResponse<span class="op">></span>)<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb10-49"><a href="#cb10-49" aria-hidden="true" tabindex="-1"></a> state<span class="op">.</span>resource<span class="op">.</span>get_item(<span class="op">&</span>id)</span>
|
||||||
|
<span id="cb10-50"><a href="#cb10-50" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>ok_or_else(<span class="op">||</span> (</span>
|
||||||
|
<span id="cb10-51"><a href="#cb10-51" aria-hidden="true" tabindex="-1"></a> <span class="pp">StatusCode::</span>NOT_FOUND<span class="op">,</span></span>
|
||||||
|
<span id="cb10-52"><a href="#cb10-52" aria-hidden="true" tabindex="-1"></a> Json(ErrorResponse <span class="op">{</span></span>
|
||||||
|
<span id="cb10-53"><a href="#cb10-53" aria-hidden="true" tabindex="-1"></a> error<span class="op">:</span> <span class="pp">format!</span>(<span class="st">"Item {} not found"</span><span class="op">,</span> id)</span>
|
||||||
|
<span id="cb10-54"><a href="#cb10-54" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
|
||||||
|
<span id="cb10-55"><a href="#cb10-55" aria-hidden="true" tabindex="-1"></a> ))</span>
|
||||||
|
<span id="cb10-56"><a href="#cb10-56" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map(Json)</span>
|
||||||
|
<span id="cb10-57"><a href="#cb10-57" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb10-58"><a href="#cb10-58" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb10-59"><a href="#cb10-59" aria-hidden="true" tabindex="-1"></a><span class="co">/// POST /items - Crée un nouvel item</span></span>
|
||||||
|
<span id="cb10-60"><a href="#cb10-60" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span><span class="pp">utoipa::</span>path<span class="at">(</span></span>
|
||||||
|
<span id="cb10-61"><a href="#cb10-61" aria-hidden="true" tabindex="-1"></a> post<span class="op">,</span></span>
|
||||||
|
<span id="cb10-62"><a href="#cb10-62" aria-hidden="true" tabindex="-1"></a> path <span class="op">=</span> <span class="st">"/items"</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-63"><a href="#cb10-63" aria-hidden="true" tabindex="-1"></a> request_body <span class="op">=</span> CreateItemRequest<span class="op">,</span></span>
|
||||||
|
<span id="cb10-64"><a href="#cb10-64" aria-hidden="true" tabindex="-1"></a> responses<span class="at">(</span></span>
|
||||||
|
<span id="cb10-65"><a href="#cb10-65" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">201</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"Item créé"</span><span class="op">,</span> body <span class="op">=</span> ItemInfo<span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-66"><a href="#cb10-66" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">400</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"Requête invalide"</span><span class="op">,</span> body <span class="op">=</span> ErrorResponse<span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-67"><a href="#cb10-67" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">500</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"Erreur serveur"</span><span class="op">,</span> body <span class="op">=</span> ErrorResponse<span class="at">)</span></span>
|
||||||
|
<span id="cb10-68"><a href="#cb10-68" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-69"><a href="#cb10-69" aria-hidden="true" tabindex="-1"></a> tag <span class="op">=</span> <span class="st">"items"</span></span>
|
||||||
|
<span id="cb10-70"><a href="#cb10-70" aria-hidden="true" tabindex="-1"></a><span class="at">)]</span></span>
|
||||||
|
<span id="cb10-71"><a href="#cb10-71" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> create_item(</span>
|
||||||
|
<span id="cb10-72"><a href="#cb10-72" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op"><</span>XxxState<span class="op">>,</span></span>
|
||||||
|
<span id="cb10-73"><a href="#cb10-73" aria-hidden="true" tabindex="-1"></a> Json(req)<span class="op">:</span> Json<span class="op"><</span>CreateItemRequest<span class="op">>,</span></span>
|
||||||
|
<span id="cb10-74"><a href="#cb10-74" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>(StatusCode<span class="op">,</span> Json<span class="op"><</span>ItemInfo<span class="op">></span>)<span class="op">,</span> (StatusCode<span class="op">,</span> Json<span class="op"><</span>ErrorResponse<span class="op">></span>)<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb10-75"><a href="#cb10-75" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> item <span class="op">=</span> state<span class="op">.</span>resource<span class="op">.</span>create_item(req<span class="op">.</span>name<span class="op">,</span> req<span class="op">.</span>description)</span>
|
||||||
|
<span id="cb10-76"><a href="#cb10-76" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> (</span>
|
||||||
|
<span id="cb10-77"><a href="#cb10-77" aria-hidden="true" tabindex="-1"></a> <span class="pp">StatusCode::</span>INTERNAL_SERVER_ERROR<span class="op">,</span></span>
|
||||||
|
<span id="cb10-78"><a href="#cb10-78" aria-hidden="true" tabindex="-1"></a> Json(ErrorResponse <span class="op">{</span> error<span class="op">:</span> e<span class="op">.</span>to_string() <span class="op">}</span>)</span>
|
||||||
|
<span id="cb10-79"><a href="#cb10-79" aria-hidden="true" tabindex="-1"></a> ))<span class="op">?;</span></span>
|
||||||
|
<span id="cb10-80"><a href="#cb10-80" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb10-81"><a href="#cb10-81" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>((<span class="pp">StatusCode::</span>CREATED<span class="op">,</span> Json(item)))</span>
|
||||||
|
<span id="cb10-82"><a href="#cb10-82" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb10-83"><a href="#cb10-83" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb10-84"><a href="#cb10-84" aria-hidden="true" tabindex="-1"></a><span class="co">/// DELETE /items/{id} - Supprime un item</span></span>
|
||||||
|
<span id="cb10-85"><a href="#cb10-85" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span><span class="pp">utoipa::</span>path<span class="at">(</span></span>
|
||||||
|
<span id="cb10-86"><a href="#cb10-86" aria-hidden="true" tabindex="-1"></a> delete<span class="op">,</span></span>
|
||||||
|
<span id="cb10-87"><a href="#cb10-87" aria-hidden="true" tabindex="-1"></a> path <span class="op">=</span> <span class="st">"/items/{id}"</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-88"><a href="#cb10-88" aria-hidden="true" tabindex="-1"></a> params<span class="at">(</span></span>
|
||||||
|
<span id="cb10-89"><a href="#cb10-89" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span><span class="st">"id"</span> <span class="op">=</span> <span class="dt">String</span><span class="op">,</span> <span class="dt">Path</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"ID unique de l'item"</span><span class="at">)</span></span>
|
||||||
|
<span id="cb10-90"><a href="#cb10-90" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-91"><a href="#cb10-91" aria-hidden="true" tabindex="-1"></a> responses<span class="at">(</span></span>
|
||||||
|
<span id="cb10-92"><a href="#cb10-92" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">204</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"Item supprimé"</span><span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-93"><a href="#cb10-93" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">404</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"Item non trouvé"</span><span class="op">,</span> body <span class="op">=</span> ErrorResponse<span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-94"><a href="#cb10-94" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">500</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"Erreur serveur"</span><span class="op">,</span> body <span class="op">=</span> ErrorResponse<span class="at">)</span></span>
|
||||||
|
<span id="cb10-95"><a href="#cb10-95" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb10-96"><a href="#cb10-96" aria-hidden="true" tabindex="-1"></a> tag <span class="op">=</span> <span class="st">"items"</span></span>
|
||||||
|
<span id="cb10-97"><a href="#cb10-97" aria-hidden="true" tabindex="-1"></a><span class="at">)]</span></span>
|
||||||
|
<span id="cb10-98"><a href="#cb10-98" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> delete_item(</span>
|
||||||
|
<span id="cb10-99"><a href="#cb10-99" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op"><</span>XxxState<span class="op">>,</span></span>
|
||||||
|
<span id="cb10-100"><a href="#cb10-100" aria-hidden="true" tabindex="-1"></a> <span class="dt">Path</span>(id)<span class="op">:</span> <span class="dt">Path</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb10-101"><a href="#cb10-101" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>StatusCode<span class="op">,</span> (StatusCode<span class="op">,</span> Json<span class="op"><</span>ErrorResponse<span class="op">></span>)<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb10-102"><a href="#cb10-102" aria-hidden="true" tabindex="-1"></a> state<span class="op">.</span>resource<span class="op">.</span>delete_item(<span class="op">&</span>id)</span>
|
||||||
|
<span id="cb10-103"><a href="#cb10-103" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> (</span>
|
||||||
|
<span id="cb10-104"><a href="#cb10-104" aria-hidden="true" tabindex="-1"></a> <span class="pp">StatusCode::</span>INTERNAL_SERVER_ERROR<span class="op">,</span></span>
|
||||||
|
<span id="cb10-105"><a href="#cb10-105" aria-hidden="true" tabindex="-1"></a> Json(ErrorResponse <span class="op">{</span> error<span class="op">:</span> e<span class="op">.</span>to_string() <span class="op">}</span>)</span>
|
||||||
|
<span id="cb10-106"><a href="#cb10-106" aria-hidden="true" tabindex="-1"></a> ))<span class="op">?;</span></span>
|
||||||
|
<span id="cb10-107"><a href="#cb10-107" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb10-108"><a href="#cb10-108" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(<span class="pp">StatusCode::</span>NO_CONTENT)</span>
|
||||||
|
<span id="cb10-109"><a href="#cb10-109" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<p><strong>Structure de <code>#[utoipa::path]</code></strong> : -
|
||||||
|
<strong>Méthode HTTP</strong> : <code>get</code>, <code>post</code>,
|
||||||
|
<code>put</code>, <code>delete</code>, <code>patch</code> -
|
||||||
|
<strong><code>path</code></strong> : Chemin de l’endpoint (doit
|
||||||
|
correspondre au router) - <strong><code>params</code></strong> :
|
||||||
|
Paramètres Path ou Query avec description -
|
||||||
|
<strong><code>request_body</code></strong> : Type du body pour POST/PUT
|
||||||
|
- <strong><code>responses</code></strong> : Liste des réponses possibles
|
||||||
|
avec codes HTTP - <strong><code>tag</code></strong> : Groupe d’endpoints
|
||||||
|
dans Swagger UI</p>
|
||||||
|
<h3 id="créer-la-structure-openapi">3. Créer la structure OpenAPI</h3>
|
||||||
|
<p>Définir une structure avec <code>#[derive(OpenApi)]</code> :</p>
|
||||||
|
<div class="sourceCode" id="cb11"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb11-1"><a href="#cb11-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">utoipa::</span>OpenApi<span class="op">;</span></span>
|
||||||
|
<span id="cb11-2"><a href="#cb11-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-3"><a href="#cb11-3" aria-hidden="true" tabindex="-1"></a><span class="co">/// Documentation OpenAPI pour l'API XXX</span></span>
|
||||||
|
<span id="cb11-4"><a href="#cb11-4" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span>OpenApi<span class="at">)]</span></span>
|
||||||
|
<span id="cb11-5"><a href="#cb11-5" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>openapi<span class="at">(</span></span>
|
||||||
|
<span id="cb11-6"><a href="#cb11-6" aria-hidden="true" tabindex="-1"></a> info<span class="at">(</span></span>
|
||||||
|
<span id="cb11-7"><a href="#cb11-7" aria-hidden="true" tabindex="-1"></a> title <span class="op">=</span> <span class="st">"XXX API"</span><span class="op">,</span></span>
|
||||||
|
<span id="cb11-8"><a href="#cb11-8" aria-hidden="true" tabindex="-1"></a> version <span class="op">=</span> <span class="st">"1.0.0"</span><span class="op">,</span></span>
|
||||||
|
<span id="cb11-9"><a href="#cb11-9" aria-hidden="true" tabindex="-1"></a> description <span class="op">=</span> <span class="st">r#"</span></span>
|
||||||
|
<span id="cb11-10"><a href="#cb11-10" aria-hidden="true" tabindex="-1"></a><span class="st"># API REST pour XXX</span></span>
|
||||||
|
<span id="cb11-11"><a href="#cb11-11" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-12"><a href="#cb11-12" aria-hidden="true" tabindex="-1"></a><span class="st">Cette API permet de gérer les items XXX avec les fonctionnalités suivantes :</span></span>
|
||||||
|
<span id="cb11-13"><a href="#cb11-13" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-14"><a href="#cb11-14" aria-hidden="true" tabindex="-1"></a><span class="st">## Fonctionnalités</span></span>
|
||||||
|
<span id="cb11-15"><a href="#cb11-15" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-16"><a href="#cb11-16" aria-hidden="true" tabindex="-1"></a><span class="st">- **CRUD complet** : Création, lecture, mise à jour et suppression d'items</span></span>
|
||||||
|
<span id="cb11-17"><a href="#cb11-17" aria-hidden="true" tabindex="-1"></a><span class="st">- **Pagination** : Support de limit/offset pour les listes</span></span>
|
||||||
|
<span id="cb11-18"><a href="#cb11-18" aria-hidden="true" tabindex="-1"></a><span class="st">- **Filtrage** : Recherche par critères multiples</span></span>
|
||||||
|
<span id="cb11-19"><a href="#cb11-19" aria-hidden="true" tabindex="-1"></a><span class="st">- **Validation** : Vérification automatique des données</span></span>
|
||||||
|
<span id="cb11-20"><a href="#cb11-20" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-21"><a href="#cb11-21" aria-hidden="true" tabindex="-1"></a><span class="st">## Exemples d'utilisation</span></span>
|
||||||
|
<span id="cb11-22"><a href="#cb11-22" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb11-23"><a href="#cb11-23" aria-hidden="true" tabindex="-1"></a><span class="st">### Lister les items</span></span></pre></div>
|
||||||
|
<p>GET /api/xxx/items?limit=10&offset=0</p>
|
||||||
|
<pre><code>
|
||||||
|
### Créer un item</pre>
|
||||||
|
<p>POST /api/xxx/items Content-Type: application/json</p>
|
||||||
|
<p>{ “name”: “Mon Item”, “description”: “Description détaillée” }</p>
|
||||||
|
<pre><code>
|
||||||
|
### Récupérer un item</pre>
|
||||||
|
<p>GET /api/xxx/items/item-123</p>
|
||||||
|
<pre><code>
|
||||||
|
### Supprimer un item</pre>
|
||||||
|
<p>DELETE /api/xxx/items/item-123</p>
|
||||||
|
<pre><code> "#
|
||||||
|
),
|
||||||
|
paths(
|
||||||
|
list_items,
|
||||||
|
get_item,
|
||||||
|
create_item,
|
||||||
|
delete_item,
|
||||||
|
),
|
||||||
|
components(schemas(
|
||||||
|
ItemInfo,
|
||||||
|
ItemList,
|
||||||
|
CreateItemRequest,
|
||||||
|
ErrorResponse,
|
||||||
|
)),
|
||||||
|
tags(
|
||||||
|
(name = "items", description = "Opérations sur les items")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub struct ApiDoc;</pre>
|
||||||
|
<p><strong>Sections importantes</strong> : -
|
||||||
|
<strong><code>info</code></strong> : Titre, version et description
|
||||||
|
Markdown de l’API - <strong><code>paths</code></strong> : Liste des
|
||||||
|
fonctions handler annotées -
|
||||||
|
<strong><code>components(schemas(...))</code></strong> : Liste des
|
||||||
|
structures <code>ToSchema</code> - <strong><code>tags</code></strong> :
|
||||||
|
Organisation des endpoints en groupes</p>
|
||||||
|
<h3 id="enregistrer-lapi-avec-openapi">4. Enregistrer l’API avec
|
||||||
|
OpenAPI</h3>
|
||||||
|
<p>Dans l’implémentation du trait d’extension :</p>
|
||||||
|
<div class="sourceCode" id="cb16"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb16-1"><a href="#cb16-1" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
|
||||||
|
<span id="cb16-2"><a href="#cb16-2" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> XxxExt <span class="cf">for</span> <span class="pp">pmoserver::</span>Server <span class="op">{</span></span>
|
||||||
|
<span id="cb16-3"><a href="#cb16-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> init_xxx(<span class="op">&</span><span class="kw">mut</span> <span class="kw">self</span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>Arc<span class="op"><</span>Resource<span class="op">>></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb16-4"><a href="#cb16-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> resource <span class="op">=</span> <span class="pp">Arc::</span>new(<span class="pp">Resource::</span>new()<span class="op">?</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb16-5"><a href="#cb16-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> state <span class="op">=</span> XxxState <span class="op">{</span> resource<span class="op">:</span> resource<span class="op">.</span>clone() <span class="op">};</span></span>
|
||||||
|
<span id="cb16-6"><a href="#cb16-6" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb16-7"><a href="#cb16-7" aria-hidden="true" tabindex="-1"></a> <span class="co">// Créer le router avec les routes</span></span>
|
||||||
|
<span id="cb16-8"><a href="#cb16-8" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> router <span class="op">=</span> <span class="pp">Router::</span>new()</span>
|
||||||
|
<span id="cb16-9"><a href="#cb16-9" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>route(<span class="st">"/items"</span><span class="op">,</span> get(list_items)<span class="op">.</span>post(create_item))</span>
|
||||||
|
<span id="cb16-10"><a href="#cb16-10" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>route(<span class="st">"/items/{id}"</span><span class="op">,</span> get(get_item)<span class="op">.</span>delete(delete_item))</span>
|
||||||
|
<span id="cb16-11"><a href="#cb16-11" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>with_state(state)<span class="op">;</span></span>
|
||||||
|
<span id="cb16-12"><a href="#cb16-12" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb16-13"><a href="#cb16-13" aria-hidden="true" tabindex="-1"></a> <span class="co">// Enregistrer avec OpenAPI (génère aussi /swagger-ui/xxx)</span></span>
|
||||||
|
<span id="cb16-14"><a href="#cb16-14" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> openapi <span class="op">=</span> <span class="pp">ApiDoc::</span>openapi()<span class="op">;</span></span>
|
||||||
|
<span id="cb16-15"><a href="#cb16-15" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_openapi(router<span class="op">,</span> openapi<span class="op">,</span> <span class="st">"xxx"</span>)<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
|
||||||
|
<span id="cb16-16"><a href="#cb16-16" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb16-17"><a href="#cb16-17" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(resource)</span>
|
||||||
|
<span id="cb16-18"><a href="#cb16-18" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb16-19"><a href="#cb16-19" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<p><strong>Ce que fait <code>add_openapi</code></strong> : - Monte le
|
||||||
|
router sur <code>/api/{tag}/</code> - Génère la spec OpenAPI JSON sur
|
||||||
|
<code>/api/{tag}/openapi.json</code> - Crée une UI Swagger sur
|
||||||
|
<code>/swagger-ui/{tag}/</code></p>
|
||||||
|
<h3 id="exemple-complet-radio-paradise">5. Exemple complet : Radio
|
||||||
|
Paradise</h3>
|
||||||
|
<p><strong>Extrait de</strong>
|
||||||
|
<code>pmoparadise/src/pmoserver_ext.rs:93-315</code></p>
|
||||||
|
<div class="sourceCode" id="cb17"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb17-1"><a href="#cb17-1" aria-hidden="true" tabindex="-1"></a><span class="co">/// Information sur un morceau</span></span>
|
||||||
|
<span id="cb17-2"><a href="#cb17-2" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> ToSchema<span class="at">)]</span></span>
|
||||||
|
<span id="cb17-3"><a href="#cb17-3" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> SongInfo <span class="op">{</span></span>
|
||||||
|
<span id="cb17-4"><a href="#cb17-4" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Index dans le block</span></span>
|
||||||
|
<span id="cb17-5"><a href="#cb17-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> index<span class="op">:</span> <span class="dt">usize</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-6"><a href="#cb17-6" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Artiste</span></span>
|
||||||
|
<span id="cb17-7"><a href="#cb17-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> artist<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-8"><a href="#cb17-8" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Titre</span></span>
|
||||||
|
<span id="cb17-9"><a href="#cb17-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> title<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-10"><a href="#cb17-10" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Album</span></span>
|
||||||
|
<span id="cb17-11"><a href="#cb17-11" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> album<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-12"><a href="#cb17-12" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Année</span></span>
|
||||||
|
<span id="cb17-13"><a href="#cb17-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> year<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">u32</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb17-14"><a href="#cb17-14" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Temps écoulé depuis le début du block (ms)</span></span>
|
||||||
|
<span id="cb17-15"><a href="#cb17-15" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> elapsed_ms<span class="op">:</span> <span class="dt">u64</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-16"><a href="#cb17-16" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Durée du morceau (ms)</span></span>
|
||||||
|
<span id="cb17-17"><a href="#cb17-17" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> duration_ms<span class="op">:</span> <span class="dt">u64</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-18"><a href="#cb17-18" aria-hidden="true" tabindex="-1"></a> <span class="co">/// URL de la pochette</span></span>
|
||||||
|
<span id="cb17-19"><a href="#cb17-19" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> cover_url<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb17-20"><a href="#cb17-20" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb17-21"><a href="#cb17-21" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb17-22"><a href="#cb17-22" aria-hidden="true" tabindex="-1"></a><span class="co">/// Réponse pour l'URL de streaming</span></span>
|
||||||
|
<span id="cb17-23"><a href="#cb17-23" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> ToSchema<span class="at">)]</span></span>
|
||||||
|
<span id="cb17-24"><a href="#cb17-24" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> StreamUrlResponse <span class="op">{</span></span>
|
||||||
|
<span id="cb17-25"><a href="#cb17-25" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Event ID du block</span></span>
|
||||||
|
<span id="cb17-26"><a href="#cb17-26" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="dv">1234567</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb17-27"><a href="#cb17-27" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> event<span class="op">:</span> <span class="dt">u64</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-28"><a href="#cb17-28" aria-hidden="true" tabindex="-1"></a> <span class="co">/// URL de streaming FLAC</span></span>
|
||||||
|
<span id="cb17-29"><a href="#cb17-29" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="st">"https://apps.radioparadise.com/blocks/chan/0/4/1234567-1234580.flac"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb17-30"><a href="#cb17-30" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> stream_url<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-31"><a href="#cb17-31" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Durée totale (ms)</span></span>
|
||||||
|
<span id="cb17-32"><a href="#cb17-32" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="dv">900000</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb17-33"><a href="#cb17-33" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> length_ms<span class="op">:</span> <span class="dt">u64</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-34"><a href="#cb17-34" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb17-35"><a href="#cb17-35" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb17-36"><a href="#cb17-36" aria-hidden="true" tabindex="-1"></a><span class="co">/// GET /stream-url/{event_id} - Récupère l'URL de streaming</span></span>
|
||||||
|
<span id="cb17-37"><a href="#cb17-37" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span><span class="pp">utoipa::</span>path<span class="at">(</span></span>
|
||||||
|
<span id="cb17-38"><a href="#cb17-38" aria-hidden="true" tabindex="-1"></a> get<span class="op">,</span></span>
|
||||||
|
<span id="cb17-39"><a href="#cb17-39" aria-hidden="true" tabindex="-1"></a> path <span class="op">=</span> <span class="st">"/stream-url/{event_id}"</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-40"><a href="#cb17-40" aria-hidden="true" tabindex="-1"></a> params<span class="at">(</span></span>
|
||||||
|
<span id="cb17-41"><a href="#cb17-41" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span><span class="st">"event_id"</span> <span class="op">=</span> <span class="dt">u64</span><span class="op">,</span> <span class="dt">Path</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"Event ID du block"</span><span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-42"><a href="#cb17-42" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span><span class="st">"channel"</span> <span class="op">=</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">u8</span><span class="op">>,</span> Query<span class="op">,</span> description <span class="op">=</span> <span class="st">"Channel ID (0-3)"</span><span class="at">)</span></span>
|
||||||
|
<span id="cb17-43"><a href="#cb17-43" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-44"><a href="#cb17-44" aria-hidden="true" tabindex="-1"></a> responses<span class="at">(</span></span>
|
||||||
|
<span id="cb17-45"><a href="#cb17-45" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">200</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"URL de streaming"</span><span class="op">,</span> body <span class="op">=</span> StreamUrlResponse<span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-46"><a href="#cb17-46" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">500</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"Erreur serveur"</span><span class="at">)</span></span>
|
||||||
|
<span id="cb17-47"><a href="#cb17-47" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-48"><a href="#cb17-48" aria-hidden="true" tabindex="-1"></a> tag <span class="op">=</span> <span class="st">"Radio Paradise"</span></span>
|
||||||
|
<span id="cb17-49"><a href="#cb17-49" aria-hidden="true" tabindex="-1"></a><span class="at">)]</span></span>
|
||||||
|
<span id="cb17-50"><a href="#cb17-50" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> get_stream_url(</span>
|
||||||
|
<span id="cb17-51"><a href="#cb17-51" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op"><</span>RadioParadiseState<span class="op">>,</span></span>
|
||||||
|
<span id="cb17-52"><a href="#cb17-52" aria-hidden="true" tabindex="-1"></a> <span class="dt">Path</span>(event_id)<span class="op">:</span> <span class="dt">Path</span><span class="op"><</span><span class="dt">u64</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb17-53"><a href="#cb17-53" aria-hidden="true" tabindex="-1"></a> Query(params)<span class="op">:</span> Query<span class="op"><</span>ParadiseQuery<span class="op">>,</span></span>
|
||||||
|
<span id="cb17-54"><a href="#cb17-54" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Json<span class="op"><</span>StreamUrlResponse<span class="op">>,</span> StatusCode<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb17-55"><a href="#cb17-55" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> client <span class="op">=</span> state<span class="op">.</span>client_for_params(<span class="op">&</span>params)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb17-56"><a href="#cb17-56" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> block <span class="op">=</span> client<span class="op">.</span>get_block(<span class="cn">Some</span>(event_id))<span class="op">.</span><span class="kw">await</span><span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb17-57"><a href="#cb17-57" aria-hidden="true" tabindex="-1"></a> <span class="pp">tracing::error!</span>(<span class="st">"Failed to fetch block {}: {}"</span><span class="op">,</span> event_id<span class="op">,</span> e)<span class="op">;</span></span>
|
||||||
|
<span id="cb17-58"><a href="#cb17-58" aria-hidden="true" tabindex="-1"></a> <span class="pp">StatusCode::</span>INTERNAL_SERVER_ERROR</span>
|
||||||
|
<span id="cb17-59"><a href="#cb17-59" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)<span class="op">?;</span></span>
|
||||||
|
<span id="cb17-60"><a href="#cb17-60" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb17-61"><a href="#cb17-61" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(Json(StreamUrlResponse <span class="op">{</span></span>
|
||||||
|
<span id="cb17-62"><a href="#cb17-62" aria-hidden="true" tabindex="-1"></a> event<span class="op">:</span> block<span class="op">.</span>event<span class="op">,</span></span>
|
||||||
|
<span id="cb17-63"><a href="#cb17-63" aria-hidden="true" tabindex="-1"></a> stream_url<span class="op">:</span> block<span class="op">.</span>url<span class="op">,</span></span>
|
||||||
|
<span id="cb17-64"><a href="#cb17-64" aria-hidden="true" tabindex="-1"></a> length_ms<span class="op">:</span> block<span class="op">.</span>length<span class="op">,</span></span>
|
||||||
|
<span id="cb17-65"><a href="#cb17-65" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>))</span>
|
||||||
|
<span id="cb17-66"><a href="#cb17-66" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb17-67"><a href="#cb17-67" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb17-68"><a href="#cb17-68" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span>OpenApi<span class="at">)]</span></span>
|
||||||
|
<span id="cb17-69"><a href="#cb17-69" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>openapi<span class="at">(</span></span>
|
||||||
|
<span id="cb17-70"><a href="#cb17-70" aria-hidden="true" tabindex="-1"></a> info<span class="at">(</span></span>
|
||||||
|
<span id="cb17-71"><a href="#cb17-71" aria-hidden="true" tabindex="-1"></a> title <span class="op">=</span> <span class="st">"Radio Paradise API"</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-72"><a href="#cb17-72" aria-hidden="true" tabindex="-1"></a> version <span class="op">=</span> <span class="st">"1.0.0"</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-73"><a href="#cb17-73" aria-hidden="true" tabindex="-1"></a> description <span class="op">=</span> <span class="st">"API REST pour accéder aux métadonnées Radio Paradise"</span></span>
|
||||||
|
<span id="cb17-74"><a href="#cb17-74" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-75"><a href="#cb17-75" aria-hidden="true" tabindex="-1"></a> paths<span class="at">(</span></span>
|
||||||
|
<span id="cb17-76"><a href="#cb17-76" aria-hidden="true" tabindex="-1"></a> get_now_playing<span class="op">,</span></span>
|
||||||
|
<span id="cb17-77"><a href="#cb17-77" aria-hidden="true" tabindex="-1"></a> get_current_block<span class="op">,</span></span>
|
||||||
|
<span id="cb17-78"><a href="#cb17-78" aria-hidden="true" tabindex="-1"></a> get_stream_url<span class="op">,</span></span>
|
||||||
|
<span id="cb17-79"><a href="#cb17-79" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-80"><a href="#cb17-80" aria-hidden="true" tabindex="-1"></a> components<span class="at">(</span>schemas<span class="at">(</span></span>
|
||||||
|
<span id="cb17-81"><a href="#cb17-81" aria-hidden="true" tabindex="-1"></a> SongInfo<span class="op">,</span></span>
|
||||||
|
<span id="cb17-82"><a href="#cb17-82" aria-hidden="true" tabindex="-1"></a> StreamUrlResponse<span class="op">,</span></span>
|
||||||
|
<span id="cb17-83"><a href="#cb17-83" aria-hidden="true" tabindex="-1"></a> <span class="at">))</span><span class="op">,</span></span>
|
||||||
|
<span id="cb17-84"><a href="#cb17-84" aria-hidden="true" tabindex="-1"></a> tags<span class="at">(</span></span>
|
||||||
|
<span id="cb17-85"><a href="#cb17-85" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>name <span class="op">=</span> <span class="st">"Radio Paradise"</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"Endpoints Radio Paradise"</span><span class="at">)</span></span>
|
||||||
|
<span id="cb17-86"><a href="#cb17-86" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span></span>
|
||||||
|
<span id="cb17-87"><a href="#cb17-87" aria-hidden="true" tabindex="-1"></a><span class="at">)]</span></span>
|
||||||
|
<span id="cb17-88"><a href="#cb17-88" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> RadioParadiseApiDoc<span class="op">;</span></span></pre></div>
|
||||||
|
<h3 id="résultat-interface-swagger">Résultat : Interface Swagger</h3>
|
||||||
|
<p>Après avoir appelé <code>init_xxx()</code>, l’API est accessible
|
||||||
|
:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>API JSON</strong> :
|
||||||
|
<code>http://localhost:8080/api/xxx/</code></li>
|
||||||
|
<li><strong>Spec OpenAPI</strong> :
|
||||||
|
<code>http://localhost:8080/api/xxx/openapi.json</code></li>
|
||||||
|
<li><strong>Swagger UI</strong> :
|
||||||
|
<code>http://localhost:8080/swagger-ui/xxx/</code></li>
|
||||||
|
</ul>
|
||||||
|
<p>L’interface Swagger permet : - Parcourir tous les endpoints avec leur
|
||||||
|
documentation - Tester les requêtes directement depuis le navigateur -
|
||||||
|
Voir les schémas de données avec exemples - Consulter les codes de
|
||||||
|
réponse HTTP possibles</p>
|
||||||
|
<h2 id="patterns-courants">Patterns courants</h2>
|
||||||
|
<h3 id="pattern-1-extension-simple-avec-router">Pattern 1 : Extension
|
||||||
|
simple avec router</h3>
|
||||||
|
<p><strong>Exemple</strong> : <code>pmoparadise</code>
|
||||||
|
(pmoparadise/src/pmoserver_ext.rs:367-392)</p>
|
||||||
|
<div class="sourceCode" id="cb18"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb18-1"><a href="#cb18-1" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
|
||||||
|
<span id="cb18-2"><a href="#cb18-2" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> RadioParadiseExt <span class="cf">for</span> <span class="pp">pmoserver::</span>Server <span class="op">{</span></span>
|
||||||
|
<span id="cb18-3"><a href="#cb18-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> init_radioparadise(<span class="op">&</span><span class="kw">mut</span> <span class="kw">self</span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>State<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb18-4"><a href="#cb18-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> state <span class="op">=</span> <span class="pp">RadioParadiseState::</span>new()<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb18-5"><a href="#cb18-5" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb18-6"><a href="#cb18-6" aria-hidden="true" tabindex="-1"></a> <span class="co">// Créer le router API</span></span>
|
||||||
|
<span id="cb18-7"><a href="#cb18-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> api_router <span class="op">=</span> create_api_router(state<span class="op">.</span>clone())<span class="op">;</span></span>
|
||||||
|
<span id="cb18-8"><a href="#cb18-8" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb18-9"><a href="#cb18-9" aria-hidden="true" tabindex="-1"></a> <span class="co">// Enregistrer avec OpenAPI</span></span>
|
||||||
|
<span id="cb18-10"><a href="#cb18-10" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_openapi(api_router<span class="op">,</span> <span class="pp">ApiDoc::</span>openapi()<span class="op">,</span> <span class="st">"radioparadise"</span>)</span>
|
||||||
|
<span id="cb18-11"><a href="#cb18-11" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
|
||||||
|
<span id="cb18-12"><a href="#cb18-12" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb18-13"><a href="#cb18-13" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(state)</span>
|
||||||
|
<span id="cb18-14"><a href="#cb18-14" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb18-15"><a href="#cb18-15" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h3 id="pattern-2-extension-avec-cache-et-fichiers">Pattern 2 :
|
||||||
|
Extension avec cache et fichiers</h3>
|
||||||
|
<p><strong>Exemple</strong> : <code>pmoaudiocache</code>
|
||||||
|
(pmoaudiocache/src/lib.rs:225-260)</p>
|
||||||
|
<div class="sourceCode" id="cb19"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb19-1"><a href="#cb19-1" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
|
||||||
|
<span id="cb19-2"><a href="#cb19-2" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> AudioCacheExt <span class="cf">for</span> <span class="pp">pmoserver::</span>Server <span class="op">{</span></span>
|
||||||
|
<span id="cb19-3"><a href="#cb19-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> init_audio_cache(</span>
|
||||||
|
<span id="cb19-4"><a href="#cb19-4" aria-hidden="true" tabindex="-1"></a> <span class="op">&</span><span class="kw">mut</span> <span class="kw">self</span><span class="op">,</span></span>
|
||||||
|
<span id="cb19-5"><a href="#cb19-5" aria-hidden="true" tabindex="-1"></a> cache_dir<span class="op">:</span> <span class="op">&</span><span class="dt">str</span><span class="op">,</span></span>
|
||||||
|
<span id="cb19-6"><a href="#cb19-6" aria-hidden="true" tabindex="-1"></a> limit<span class="op">:</span> <span class="dt">usize</span><span class="op">,</span></span>
|
||||||
|
<span id="cb19-7"><a href="#cb19-7" aria-hidden="true" tabindex="-1"></a> ) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>Arc<span class="op"><</span>Cache<span class="op">>></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb19-8"><a href="#cb19-8" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> cache <span class="op">=</span> <span class="pp">Arc::</span>new(new_cache(cache_dir<span class="op">,</span> limit)<span class="op">?</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb19-9"><a href="#cb19-9" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb19-10"><a href="#cb19-10" aria-hidden="true" tabindex="-1"></a> <span class="co">// Router pour servir les fichiers FLAC</span></span>
|
||||||
|
<span id="cb19-11"><a href="#cb19-11" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> file_router <span class="op">=</span> create_file_router(cache<span class="op">.</span>clone()<span class="op">,</span> <span class="st">"audio/flac"</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb19-12"><a href="#cb19-12" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_router(<span class="st">"/"</span><span class="op">,</span> file_router)<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
|
||||||
|
<span id="cb19-13"><a href="#cb19-13" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb19-14"><a href="#cb19-14" aria-hidden="true" tabindex="-1"></a> <span class="co">// API REST</span></span>
|
||||||
|
<span id="cb19-15"><a href="#cb19-15" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> api_router <span class="op">=</span> <span class="pp">Router::</span>new()</span>
|
||||||
|
<span id="cb19-16"><a href="#cb19-16" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>route(<span class="st">"/"</span><span class="op">,</span> get(list)<span class="op">.</span>post(add))</span>
|
||||||
|
<span id="cb19-17"><a href="#cb19-17" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>route(<span class="st">"/{pk}"</span><span class="op">,</span> get(get_info)<span class="op">.</span>delete(delete))</span>
|
||||||
|
<span id="cb19-18"><a href="#cb19-18" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>with_state(cache<span class="op">.</span>clone())<span class="op">;</span></span>
|
||||||
|
<span id="cb19-19"><a href="#cb19-19" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb19-20"><a href="#cb19-20" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_openapi(api_router<span class="op">,</span> <span class="pp">ApiDoc::</span>openapi()<span class="op">,</span> <span class="st">"audio"</span>)<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
|
||||||
|
<span id="cb19-21"><a href="#cb19-21" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb19-22"><a href="#cb19-22" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(cache)</span>
|
||||||
|
<span id="cb19-23"><a href="#cb19-23" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb19-24"><a href="#cb19-24" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h3 id="pattern-3-extension-avec-routes-dynamiques">Pattern 3 :
|
||||||
|
Extension avec routes dynamiques</h3>
|
||||||
|
<p><strong>Exemple</strong> : <code>pmomediaserver</code>
|
||||||
|
(pmomediaserver/src/paradise_streaming.rs:70-148)</p>
|
||||||
|
<div class="sourceCode" id="cb20"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb20-1"><a href="#cb20-1" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
|
||||||
|
<span id="cb20-2"><a href="#cb20-2" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> ParadiseStreamingExt <span class="cf">for</span> <span class="pp">pmoserver::</span>Server <span class="op">{</span></span>
|
||||||
|
<span id="cb20-3"><a href="#cb20-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> init_paradise_streaming(<span class="op">&</span><span class="kw">mut</span> <span class="kw">self</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Arc<span class="op"><</span>Manager<span class="op">>></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb20-4"><a href="#cb20-4" aria-hidden="true" tabindex="-1"></a> <span class="co">// 1. Récupérer/créer les ressources partagées</span></span>
|
||||||
|
<span id="cb20-5"><a href="#cb20-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> audio_cache <span class="op">=</span> get_or_init_audio_cache(<span class="kw">self</span>)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb20-6"><a href="#cb20-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> manager <span class="op">=</span> <span class="pp">Arc::</span>new(<span class="pp">Manager::</span>new(audio_cache)<span class="op">.</span><span class="kw">await</span><span class="op">?</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb20-7"><a href="#cb20-7" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb20-8"><a href="#cb20-8" aria-hidden="true" tabindex="-1"></a> <span class="co">// 2. Créer l'état partagé</span></span>
|
||||||
|
<span id="cb20-9"><a href="#cb20-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> state <span class="op">=</span> <span class="pp">Arc::</span>new(StreamingState <span class="op">{</span> manager<span class="op">:</span> manager<span class="op">.</span>clone() <span class="op">}</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb20-10"><a href="#cb20-10" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb20-11"><a href="#cb20-11" aria-hidden="true" tabindex="-1"></a> <span class="co">// 3. Enregistrer les routes pour chaque canal</span></span>
|
||||||
|
<span id="cb20-12"><a href="#cb20-12" aria-hidden="true" tabindex="-1"></a> <span class="cf">for</span> descriptor <span class="kw">in</span> ALL_CHANNELS<span class="op">.</span>iter() <span class="op">{</span></span>
|
||||||
|
<span id="cb20-13"><a href="#cb20-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> slug <span class="op">=</span> descriptor<span class="op">.</span>slug<span class="op">;</span></span>
|
||||||
|
<span id="cb20-14"><a href="#cb20-14" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb20-15"><a href="#cb20-15" aria-hidden="true" tabindex="-1"></a> <span class="co">// Route streaming FLAC</span></span>
|
||||||
|
<span id="cb20-16"><a href="#cb20-16" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> path <span class="op">=</span> <span class="pp">format!</span>(<span class="st">"/stream/{}/flac"</span><span class="op">,</span> slug)<span class="op">;</span></span>
|
||||||
|
<span id="cb20-17"><a href="#cb20-17" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_handler_with_state(</span>
|
||||||
|
<span id="cb20-18"><a href="#cb20-18" aria-hidden="true" tabindex="-1"></a> <span class="op">&</span>path<span class="op">,</span></span>
|
||||||
|
<span id="cb20-19"><a href="#cb20-19" aria-hidden="true" tabindex="-1"></a> <span class="kw">move</span> <span class="op">|</span>State(s)<span class="op">:</span> State<span class="op"><</span>Arc<span class="op"><</span>StreamingState<span class="op">>>|</span> <span class="kw">async</span> <span class="kw">move</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb20-20"><a href="#cb20-20" aria-hidden="true" tabindex="-1"></a> stream_flac(s<span class="op">.</span>manager<span class="op">.</span>clone()<span class="op">,</span> descriptor<span class="op">.</span>id)<span class="op">.</span><span class="kw">await</span></span>
|
||||||
|
<span id="cb20-21"><a href="#cb20-21" aria-hidden="true" tabindex="-1"></a> <span class="op">},</span></span>
|
||||||
|
<span id="cb20-22"><a href="#cb20-22" aria-hidden="true" tabindex="-1"></a> state<span class="op">.</span>clone()<span class="op">,</span></span>
|
||||||
|
<span id="cb20-23"><a href="#cb20-23" aria-hidden="true" tabindex="-1"></a> )<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
|
||||||
|
<span id="cb20-24"><a href="#cb20-24" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb20-25"><a href="#cb20-25" aria-hidden="true" tabindex="-1"></a> <span class="co">// Route streaming OGG</span></span>
|
||||||
|
<span id="cb20-26"><a href="#cb20-26" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> path <span class="op">=</span> <span class="pp">format!</span>(<span class="st">"/stream/{}/ogg"</span><span class="op">,</span> slug)<span class="op">;</span></span>
|
||||||
|
<span id="cb20-27"><a href="#cb20-27" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_handler_with_state(</span>
|
||||||
|
<span id="cb20-28"><a href="#cb20-28" aria-hidden="true" tabindex="-1"></a> <span class="op">&</span>path<span class="op">,</span></span>
|
||||||
|
<span id="cb20-29"><a href="#cb20-29" aria-hidden="true" tabindex="-1"></a> <span class="kw">move</span> <span class="op">|</span>State(s)<span class="op">:</span> State<span class="op"><</span>Arc<span class="op"><</span>StreamingState<span class="op">>>|</span> <span class="kw">async</span> <span class="kw">move</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb20-30"><a href="#cb20-30" aria-hidden="true" tabindex="-1"></a> stream_ogg(s<span class="op">.</span>manager<span class="op">.</span>clone()<span class="op">,</span> descriptor<span class="op">.</span>id)<span class="op">.</span><span class="kw">await</span></span>
|
||||||
|
<span id="cb20-31"><a href="#cb20-31" aria-hidden="true" tabindex="-1"></a> <span class="op">},</span></span>
|
||||||
|
<span id="cb20-32"><a href="#cb20-32" aria-hidden="true" tabindex="-1"></a> state<span class="op">.</span>clone()<span class="op">,</span></span>
|
||||||
|
<span id="cb20-33"><a href="#cb20-33" aria-hidden="true" tabindex="-1"></a> )<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
|
||||||
|
<span id="cb20-34"><a href="#cb20-34" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb20-35"><a href="#cb20-35" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb20-36"><a href="#cb20-36" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(manager)</span>
|
||||||
|
<span id="cb20-37"><a href="#cb20-37" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb20-38"><a href="#cb20-38" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h2 id="gestion-des-opérations-longues">Gestion des opérations
|
||||||
|
longues</h2>
|
||||||
|
<h3 id="utiliser-spawn_blocking-pour-le-code-synchrone">Utiliser
|
||||||
|
<code>spawn_blocking</code> pour le code synchrone</h3>
|
||||||
|
<p>Pour éviter de bloquer le runtime Tokio avec du code synchrone :</p>
|
||||||
|
<div class="sourceCode" id="cb21"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb21-1"><a href="#cb21-1" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> list_renderers(</span>
|
||||||
|
<span id="cb21-2"><a href="#cb21-2" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op"><</span>ControlPointState<span class="op">></span></span>
|
||||||
|
<span id="cb21-3"><a href="#cb21-3" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> Json<span class="op"><</span><span class="dt">Vec</span><span class="op"><</span>Summary<span class="op">>></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb21-4"><a href="#cb21-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> control_point <span class="op">=</span> state<span class="op">.</span>control_point<span class="op">.</span>clone()<span class="op">;</span></span>
|
||||||
|
<span id="cb21-5"><a href="#cb21-5" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb21-6"><a href="#cb21-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> summaries <span class="op">=</span> <span class="pp">tokio::task::</span>spawn_blocking(<span class="kw">move</span> <span class="op">||</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb21-7"><a href="#cb21-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> renderers <span class="op">=</span> control_point<span class="op">.</span>list_music_renderers()<span class="op">;</span></span>
|
||||||
|
<span id="cb21-8"><a href="#cb21-8" aria-hidden="true" tabindex="-1"></a> renderers<span class="op">.</span>into_iter()</span>
|
||||||
|
<span id="cb21-9"><a href="#cb21-9" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map(<span class="op">|</span>r<span class="op">|</span> <span class="pp">Summary::</span>from(<span class="op">&</span>r))</span>
|
||||||
|
<span id="cb21-10"><a href="#cb21-10" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>collect()</span>
|
||||||
|
<span id="cb21-11"><a href="#cb21-11" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
|
||||||
|
<span id="cb21-12"><a href="#cb21-12" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span></span>
|
||||||
|
<span id="cb21-13"><a href="#cb21-13" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>unwrap_or_default()<span class="op">;</span></span>
|
||||||
|
<span id="cb21-14"><a href="#cb21-14" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb21-15"><a href="#cb21-15" aria-hidden="true" tabindex="-1"></a> Json(summaries)</span>
|
||||||
|
<span id="cb21-16"><a href="#cb21-16" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h3 id="ajouter-des-timeouts-pour-les-opérations-réseau">Ajouter des
|
||||||
|
timeouts pour les opérations réseau</h3>
|
||||||
|
<div class="sourceCode" id="cb22"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb22-1"><a href="#cb22-1" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> COMMAND_TIMEOUT<span class="op">:</span> Duration <span class="op">=</span> <span class="pp">Duration::</span>from_secs(<span class="dv">5</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb22-2"><a href="#cb22-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb22-3"><a href="#cb22-3" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> play_renderer(</span>
|
||||||
|
<span id="cb22-4"><a href="#cb22-4" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op"><</span>ControlPointState<span class="op">>,</span></span>
|
||||||
|
<span id="cb22-5"><a href="#cb22-5" aria-hidden="true" tabindex="-1"></a> <span class="dt">Path</span>(id)<span class="op">:</span> <span class="dt">Path</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb22-6"><a href="#cb22-6" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Json<span class="op"><</span>Response<span class="op">>,</span> (StatusCode<span class="op">,</span> Json<span class="op"><</span><span class="bu">Error</span><span class="op">></span>)<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb22-7"><a href="#cb22-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> renderer <span class="op">=</span> state<span class="op">.</span>get_renderer(<span class="op">&</span>id)</span>
|
||||||
|
<span id="cb22-8"><a href="#cb22-8" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>ok_or((<span class="pp">StatusCode::</span>NOT_FOUND<span class="op">,</span> Json(<span class="bu">Error</span><span class="pp">::</span>not_found())))<span class="op">?;</span></span>
|
||||||
|
<span id="cb22-9"><a href="#cb22-9" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb22-10"><a href="#cb22-10" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> play_task <span class="op">=</span> <span class="pp">tokio::task::</span>spawn_blocking(<span class="kw">move</span> <span class="op">||</span> renderer<span class="op">.</span>play())<span class="op">;</span></span>
|
||||||
|
<span id="cb22-11"><a href="#cb22-11" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb22-12"><a href="#cb22-12" aria-hidden="true" tabindex="-1"></a> <span class="pp">time::</span>timeout(COMMAND_TIMEOUT<span class="op">,</span> play_task)</span>
|
||||||
|
<span id="cb22-13"><a href="#cb22-13" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span></span>
|
||||||
|
<span id="cb22-14"><a href="#cb22-14" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>_<span class="op">|</span> (</span>
|
||||||
|
<span id="cb22-15"><a href="#cb22-15" aria-hidden="true" tabindex="-1"></a> <span class="pp">StatusCode::</span>GATEWAY_TIMEOUT<span class="op">,</span></span>
|
||||||
|
<span id="cb22-16"><a href="#cb22-16" aria-hidden="true" tabindex="-1"></a> Json(<span class="bu">Error</span><span class="pp">::</span>timeout())</span>
|
||||||
|
<span id="cb22-17"><a href="#cb22-17" aria-hidden="true" tabindex="-1"></a> ))<span class="op">?</span></span>
|
||||||
|
<span id="cb22-18"><a href="#cb22-18" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> (</span>
|
||||||
|
<span id="cb22-19"><a href="#cb22-19" aria-hidden="true" tabindex="-1"></a> <span class="pp">StatusCode::</span>INTERNAL_SERVER_ERROR<span class="op">,</span></span>
|
||||||
|
<span id="cb22-20"><a href="#cb22-20" aria-hidden="true" tabindex="-1"></a> Json(<span class="bu">Error</span><span class="pp">::</span>internal(e))</span>
|
||||||
|
<span id="cb22-21"><a href="#cb22-21" aria-hidden="true" tabindex="-1"></a> ))<span class="op">??;</span></span>
|
||||||
|
<span id="cb22-22"><a href="#cb22-22" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb22-23"><a href="#cb22-23" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(Json(<span class="pp">Response::</span>success()))</span>
|
||||||
|
<span id="cb22-24"><a href="#cb22-24" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h3 id="utiliser-spawn-pour-les-tâches-en-arrière-plan">Utiliser
|
||||||
|
<code>spawn</code> pour les tâches en arrière-plan</h3>
|
||||||
|
<p>Pour les opérations qui ne nécessitent pas d’attendre le résultat
|
||||||
|
:</p>
|
||||||
|
<div class="sourceCode" id="cb23"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb23-1"><a href="#cb23-1" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> trigger_action(</span>
|
||||||
|
<span id="cb23-2"><a href="#cb23-2" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op"><</span>XxxState<span class="op">>,</span></span>
|
||||||
|
<span id="cb23-3"><a href="#cb23-3" aria-hidden="true" tabindex="-1"></a> Json(req)<span class="op">:</span> Json<span class="op"><</span>Request<span class="op">>,</span></span>
|
||||||
|
<span id="cb23-4"><a href="#cb23-4" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> Json<span class="op"><</span>Response<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb23-5"><a href="#cb23-5" aria-hidden="true" tabindex="-1"></a> <span class="co">// Valider la requête</span></span>
|
||||||
|
<span id="cb23-6"><a href="#cb23-6" aria-hidden="true" tabindex="-1"></a> state<span class="op">.</span>validate(<span class="op">&</span>req)<span class="op">?;</span></span>
|
||||||
|
<span id="cb23-7"><a href="#cb23-7" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb23-8"><a href="#cb23-8" aria-hidden="true" tabindex="-1"></a> <span class="co">// Lancer l'action en arrière-plan</span></span>
|
||||||
|
<span id="cb23-9"><a href="#cb23-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> state_clone <span class="op">=</span> state<span class="op">.</span>clone()<span class="op">;</span></span>
|
||||||
|
<span id="cb23-10"><a href="#cb23-10" aria-hidden="true" tabindex="-1"></a> <span class="pp">tokio::task::</span>spawn(<span class="kw">async</span> <span class="kw">move</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb23-11"><a href="#cb23-11" aria-hidden="true" tabindex="-1"></a> <span class="cf">match</span> state_clone<span class="op">.</span>perform_action(req)<span class="op">.</span><span class="kw">await</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb23-12"><a href="#cb23-12" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(_) <span class="op">=></span> <span class="pp">debug!</span>(<span class="st">"Action completed"</span>)<span class="op">,</span></span>
|
||||||
|
<span id="cb23-13"><a href="#cb23-13" aria-hidden="true" tabindex="-1"></a> <span class="cn">Err</span>(e) <span class="op">=></span> <span class="pp">warn!</span>(<span class="st">"Action failed: {}"</span><span class="op">,</span> e)<span class="op">,</span></span>
|
||||||
|
<span id="cb23-14"><a href="#cb23-14" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb23-15"><a href="#cb23-15" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb23-16"><a href="#cb23-16" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb23-17"><a href="#cb23-17" aria-hidden="true" tabindex="-1"></a> <span class="co">// Retourner immédiatement</span></span>
|
||||||
|
<span id="cb23-18"><a href="#cb23-18" aria-hidden="true" tabindex="-1"></a> Json(<span class="pp">Response::</span>accepted())</span>
|
||||||
|
<span id="cb23-19"><a href="#cb23-19" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h2 id="checklist-dimplémentation">Checklist d’implémentation</h2>
|
||||||
|
<h3 id="configuration-de-base-1">Configuration de base</h3>
|
||||||
|
<ul class="task-list">
|
||||||
|
<li><label><input type="checkbox" />Créer le module
|
||||||
|
<code>pmoserver_ext.rs</code> avec
|
||||||
|
<code>#[cfg(feature = "pmoserver")]</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Ajouter la feature
|
||||||
|
<code>pmoserver</code> dans <code>Cargo.toml</code> avec dépendances
|
||||||
|
optionnelles</label></li>
|
||||||
|
<li><label><input type="checkbox" />Re-exporter le trait dans
|
||||||
|
<code>lib.rs</code></label></li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="définition-du-trait">Définition du trait</h3>
|
||||||
|
<ul class="task-list">
|
||||||
|
<li><label><input type="checkbox" />Définir le trait
|
||||||
|
<code>{Domaine}Ext</code> avec méthode <code>init_*</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Créer la structure
|
||||||
|
<code>{Domaine}State</code> avec
|
||||||
|
<code>#[derive(Clone)]</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Implémenter le trait pour
|
||||||
|
<code>pmoserver::Server</code></label></li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="documentation-openapi">Documentation OpenAPI</h3>
|
||||||
|
<ul class="task-list">
|
||||||
|
<li><label><input type="checkbox" />Ajouter <code>utoipa</code> dans les
|
||||||
|
dépendances</label></li>
|
||||||
|
<li><label><input type="checkbox" />Définir les schémas de
|
||||||
|
réponse/requête avec <code>#[derive(ToSchema)]</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Ajouter des exemples avec
|
||||||
|
<code>#[schema(example = "...")]</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Annoter chaque handler avec
|
||||||
|
<code>#[utoipa::path(...)]</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Créer la structure
|
||||||
|
<code>#[derive(OpenApi)]</code> avec documentation complète</label></li>
|
||||||
|
<li><label><input type="checkbox" />Lister tous les paths et schemas
|
||||||
|
dans <code>#[openapi(...)]</code></label></li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="handlers-et-routes">Handlers et routes</h3>
|
||||||
|
<ul class="task-list">
|
||||||
|
<li><label><input type="checkbox" />Créer les handlers avec les
|
||||||
|
extracteurs Axum appropriés</label></li>
|
||||||
|
<li><label><input type="checkbox" />Gérer les erreurs avec des codes
|
||||||
|
HTTP sémantiques</label></li>
|
||||||
|
<li><label><input type="checkbox" />Créer le router et l’enregistrer
|
||||||
|
avec <code>add_openapi()</code></label></li>
|
||||||
|
<li><label><input type="checkbox" />Ajouter des logs (debug, info, warn,
|
||||||
|
error)</label></li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="performance-et-robustesse">Performance et robustesse</h3>
|
||||||
|
<ul class="task-list">
|
||||||
|
<li><label><input type="checkbox" />Utiliser <code>spawn_blocking</code>
|
||||||
|
pour le code synchrone</label></li>
|
||||||
|
<li><label><input type="checkbox" />Ajouter des timeouts pour les
|
||||||
|
opérations réseau</label></li>
|
||||||
|
<li><label><input type="checkbox" />Utiliser <code>spawn</code> pour les
|
||||||
|
tâches en arrière-plan si nécessaire</label></li>
|
||||||
|
</ul>
|
||||||
|
<h2 id="exemple-complet-minimal">Exemple complet minimal</h2>
|
||||||
|
<div class="sourceCode" id="cb24"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb24-1"><a href="#cb24-1" aria-hidden="true" tabindex="-1"></a><span class="co">// pmoexample/src/pmoserver_ext.rs</span></span>
|
||||||
|
<span id="cb24-2"><a href="#cb24-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb24-3"><a href="#cb24-3" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">"pmoserver"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb24-4"><a href="#cb24-4" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">async_trait::</span>async_trait<span class="op">;</span></span>
|
||||||
|
<span id="cb24-5"><a href="#cb24-5" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">"pmoserver"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb24-6"><a href="#cb24-6" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">axum::</span><span class="op">{</span>Router<span class="op">,</span> <span class="pp">routing::</span>get<span class="op">,</span> Json<span class="op">,</span> <span class="pp">extract::</span>State<span class="op">};</span></span>
|
||||||
|
<span id="cb24-7"><a href="#cb24-7" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">"pmoserver"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb24-8"><a href="#cb24-8" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">std::sync::</span>Arc<span class="op">;</span></span>
|
||||||
|
<span id="cb24-9"><a href="#cb24-9" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">"pmoserver"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb24-10"><a href="#cb24-10" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="kw">crate</span><span class="pp">::</span>ExampleResource<span class="op">;</span></span>
|
||||||
|
<span id="cb24-11"><a href="#cb24-11" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb24-12"><a href="#cb24-12" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">"pmoserver"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb24-13"><a href="#cb24-13" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Clone</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb24-14"><a href="#cb24-14" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> ExampleState <span class="op">{</span></span>
|
||||||
|
<span id="cb24-15"><a href="#cb24-15" aria-hidden="true" tabindex="-1"></a> resource<span class="op">:</span> Arc<span class="op"><</span>ExampleResource<span class="op">>,</span></span>
|
||||||
|
<span id="cb24-16"><a href="#cb24-16" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb24-17"><a href="#cb24-17" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb24-18"><a href="#cb24-18" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">"pmoserver"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb24-19"><a href="#cb24-19" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
|
||||||
|
<span id="cb24-20"><a href="#cb24-20" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">trait</span> ExampleExt <span class="op">{</span></span>
|
||||||
|
<span id="cb24-21"><a href="#cb24-21" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> init_example(<span class="op">&</span><span class="kw">mut</span> <span class="kw">self</span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>Arc<span class="op"><</span>ExampleResource<span class="op">>>;</span></span>
|
||||||
|
<span id="cb24-22"><a href="#cb24-22" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb24-23"><a href="#cb24-23" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb24-24"><a href="#cb24-24" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">"pmoserver"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb24-25"><a href="#cb24-25" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
|
||||||
|
<span id="cb24-26"><a href="#cb24-26" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> ExampleExt <span class="cf">for</span> <span class="pp">pmoserver::</span>Server <span class="op">{</span></span>
|
||||||
|
<span id="cb24-27"><a href="#cb24-27" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> init_example(<span class="op">&</span><span class="kw">mut</span> <span class="kw">self</span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>Arc<span class="op"><</span>ExampleResource<span class="op">>></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb24-28"><a href="#cb24-28" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> resource <span class="op">=</span> <span class="pp">Arc::</span>new(<span class="pp">ExampleResource::</span>new())<span class="op">;</span></span>
|
||||||
|
<span id="cb24-29"><a href="#cb24-29" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> state <span class="op">=</span> ExampleState <span class="op">{</span> resource<span class="op">:</span> resource<span class="op">.</span>clone() <span class="op">};</span></span>
|
||||||
|
<span id="cb24-30"><a href="#cb24-30" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb24-31"><a href="#cb24-31" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> router <span class="op">=</span> <span class="pp">Router::</span>new()</span>
|
||||||
|
<span id="cb24-32"><a href="#cb24-32" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>route(<span class="st">"/items"</span><span class="op">,</span> get(list_items))</span>
|
||||||
|
<span id="cb24-33"><a href="#cb24-33" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>with_state(state)<span class="op">;</span></span>
|
||||||
|
<span id="cb24-34"><a href="#cb24-34" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb24-35"><a href="#cb24-35" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_router(<span class="st">"/api/example"</span><span class="op">,</span> router)<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
|
||||||
|
<span id="cb24-36"><a href="#cb24-36" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb24-37"><a href="#cb24-37" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(resource)</span>
|
||||||
|
<span id="cb24-38"><a href="#cb24-38" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb24-39"><a href="#cb24-39" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb24-40"><a href="#cb24-40" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb24-41"><a href="#cb24-41" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">"pmoserver"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb24-42"><a href="#cb24-42" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> list_items(State(state)<span class="op">:</span> State<span class="op"><</span>ExampleState<span class="op">></span>) <span class="op">-></span> Json<span class="op"><</span><span class="dt">Vec</span><span class="op"><</span><span class="dt">String</span><span class="op">>></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb24-43"><a href="#cb24-43" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> items <span class="op">=</span> state<span class="op">.</span>resource<span class="op">.</span>list()<span class="op">;</span></span>
|
||||||
|
<span id="cb24-44"><a href="#cb24-44" aria-hidden="true" tabindex="-1"></a> Json(items)</span>
|
||||||
|
<span id="cb24-45"><a href="#cb24-45" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h2 id="références">Références</h2>
|
||||||
|
<h3 id="exemples-dans-le-codebase">Exemples dans le codebase</h3>
|
||||||
|
<table>
|
||||||
|
<colgroup>
|
||||||
|
<col style="width: 28%" />
|
||||||
|
<col style="width: 36%" />
|
||||||
|
<col style="width: 36%" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Crate</th>
|
||||||
|
<th>Fichier</th>
|
||||||
|
<th>Pattern</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>pmoparadise</code></td>
|
||||||
|
<td><code>src/pmoserver_ext.rs:367-392</code></td>
|
||||||
|
<td>Extension simple avec OpenAPI</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>pmoaudiocache</code></td>
|
||||||
|
<td><code>src/lib.rs:225-260</code></td>
|
||||||
|
<td>Extension avec cache et fichiers</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>pmomediaserver</code></td>
|
||||||
|
<td><code>src/paradise_streaming.rs:70-148</code></td>
|
||||||
|
<td>Extension avec routes dynamiques</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>pmocontrol</code></td>
|
||||||
|
<td><code>src/pmoserver_ext.rs:68-92</code></td>
|
||||||
|
<td>Handlers avec <code>spawn_blocking</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>pmoapp</code></td>
|
||||||
|
<td><code>src/lib.rs:145-165</code></td>
|
||||||
|
<td>Extension SPA avec RustEmbed</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h3 id="dépendances-communes">Dépendances communes</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>axum</code> : Framework HTTP (Router, handlers,
|
||||||
|
extracteurs)</li>
|
||||||
|
<li><code>async-trait</code> : Support des traits async</li>
|
||||||
|
<li><code>tokio</code> : Runtime async (spawn, spawn_blocking,
|
||||||
|
timeout)</li>
|
||||||
|
<li><code>anyhow</code> : Gestion d’erreurs pour init</li>
|
||||||
|
<li><code>tracing</code> : Logging structuré</li>
|
||||||
|
<li><code>utoipa</code> : Documentation OpenAPI/Swagger</li>
|
||||||
|
<li><code>serde</code> : Sérialisation JSON</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1034
Blackboard_HTML/Done_Pinnable_cache_item.html
Normal file
1034
Blackboard_HTML/Done_Pinnable_cache_item.html
Normal file
File diff suppressed because it is too large
Load Diff
209
Blackboard_HTML/Done_WeabApp_debouncingSSE.html
Normal file
209
Blackboard_HTML/Done_WeabApp_debouncingSSE.html
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>WeabApp_debouncingSSE</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
||||||
|
<script type="module">
|
||||||
|
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||||
|
mermaid.initialize({startOnLoad: true, theme: "default"});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
pre.mermaid {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="markdown-body">
|
||||||
|
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
|
||||||
|
<h1 id="rapport-suppression-de-la-logique-de-débouncing-sse">Rapport :
|
||||||
|
Suppression de la logique de débouncing SSE</h1>
|
||||||
|
<p><strong>Date</strong>: 2026-01-12 <strong>Tâche</strong>:
|
||||||
|
WeabApp_debouncingSSE.md</p>
|
||||||
|
<h2 id="objectif">Objectif</h2>
|
||||||
|
<p>Supprimer la logique de débouncing inutile sur le canal SSE de
|
||||||
|
l’application web PMOControl, puisque le serveur contrôle déjà le flux
|
||||||
|
des événements.</p>
|
||||||
|
<h2 id="analyse-préalable">Analyse préalable</h2>
|
||||||
|
<p>J’ai identifié trois endroits avec des mécanismes de temporisation
|
||||||
|
dans l’application web :</p>
|
||||||
|
<h3 id="mediabrowser.vue---débouncing-sse-à-supprimer">1.
|
||||||
|
MediaBrowser.vue - Débouncing SSE (À SUPPRIMER ✓)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Débouncing</strong>: 200ms après invalidation du cache</li>
|
||||||
|
<li><strong>Cooldown</strong>: 2 secondes entre les rechargements</li>
|
||||||
|
<li><strong>Justification originale</strong>: “dédupliquer les
|
||||||
|
événements SSE dans le même batch (polling 500ms)”</li>
|
||||||
|
<li><strong>Problème</strong>: Cette logique est redondante puisque le
|
||||||
|
serveur contrôle déjà le flux SSE</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="userenderers.ts---smart-fetching-à-conserver">2. useRenderers.ts
|
||||||
|
- Smart fetching (À CONSERVER ✓)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Mécanisme</strong>: Comparaison des timestamps
|
||||||
|
<code>lastEventAt</code> vs <code>lastSnapshotAt</code></li>
|
||||||
|
<li><strong>But</strong>: Éviter de refetch un snapshot déjà à jour</li>
|
||||||
|
<li><strong>Justification</strong>: Ce n’est PAS du débouncing, c’est
|
||||||
|
une optimisation intelligente qui évite des appels API inutiles</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="volumecontrol.vue---ui-debouncing-à-conserver">3.
|
||||||
|
VolumeControl.vue - UI debouncing (À CONSERVER ✓)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Débouncing</strong>: 300ms sur les changements de
|
||||||
|
volume</li>
|
||||||
|
<li><strong>But</strong>: Réduire les appels API pendant que
|
||||||
|
l’utilisateur fait glisser le curseur</li>
|
||||||
|
<li><strong>Justification</strong>: Débouncing légitime pour l’interface
|
||||||
|
utilisateur</li>
|
||||||
|
</ul>
|
||||||
|
<h2 id="modifications-effectuées">Modifications effectuées</h2>
|
||||||
|
<h3
|
||||||
|
id="fichier-modifié-pmoappwebappsrccomponentspmocontrolmediabrowser.vue">Fichier
|
||||||
|
modifié:
|
||||||
|
<code>pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue</code></h3>
|
||||||
|
<h4 id="suppression-des-variables-de-débouncing-ligne-27">1. Suppression
|
||||||
|
des variables de débouncing (ligne ~27)</h4>
|
||||||
|
<p><strong>Avant</strong>:</p>
|
||||||
|
<div class="sourceCode" id="cb1"><pre
|
||||||
|
class="sourceCode typescript"><code class="sourceCode typescript"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="co">// Flags pour gérer le rechargement automatique avec debounce et cooldown</span></span>
|
||||||
|
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> isRefreshing <span class="op">=</span> <span class="fu">ref</span>(<span class="kw">false</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> refreshTimeoutId <span class="op">=</span> <span class="fu">ref</span><span class="op"><</span><span class="dt">number</span> <span class="op">|</span> <span class="dt">null</span><span class="op">></span>(<span class="kw">null</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb1-4"><a href="#cb1-4" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> lastRefreshTime <span class="op">=</span> <span class="fu">ref</span><span class="op"><</span><span class="dt">number</span><span class="op">></span>(<span class="dv">0</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb1-5"><a href="#cb1-5" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> REFRESH_COOLDOWN_MS <span class="op">=</span> <span class="dv">2000</span><span class="op">;</span> <span class="co">// Ne pas recharger plus d'une fois toutes les 2 secondes</span></span></pre></div>
|
||||||
|
<p><strong>Après</strong>:</p>
|
||||||
|
<div class="sourceCode" id="cb2"><pre
|
||||||
|
class="sourceCode typescript"><code class="sourceCode typescript"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a><span class="co">// Flag pour gérer le rechargement automatique</span></span>
|
||||||
|
<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> isRefreshing <span class="op">=</span> <span class="fu">ref</span>(<span class="kw">false</span>)<span class="op">;</span></span></pre></div>
|
||||||
|
<h4 id="simplification-du-watcher-de-cache-ligne-53">2. Simplification
|
||||||
|
du watcher de cache (ligne ~53)</h4>
|
||||||
|
<p><strong>Avant</strong>:</p>
|
||||||
|
<div class="sourceCode" id="cb3"><pre
|
||||||
|
class="sourceCode typescript"><code class="sourceCode typescript"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="co">// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)</span></span>
|
||||||
|
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a><span class="co">// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,</span></span>
|
||||||
|
<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a><span class="co">// ce qui déclenche un événement ContainersUpdated qui invalide le cache</span></span>
|
||||||
|
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a><span class="co">// Utilise un debounce de 3 secondes pour regrouper les multiples invalidations</span></span>
|
||||||
|
<span id="cb3-5"><a href="#cb3-5" aria-hidden="true" tabindex="-1"></a><span class="co">// et un cooldown de 5 secondes pour éviter les rechargements successifs</span></span>
|
||||||
|
<span id="cb3-6"><a href="#cb3-6" aria-hidden="true" tabindex="-1"></a><span class="fu">watch</span>(</span>
|
||||||
|
<span id="cb3-7"><a href="#cb3-7" aria-hidden="true" tabindex="-1"></a> () <span class="kw">=></span> browseData<span class="op">.</span><span class="at">value</span><span class="op">,</span></span>
|
||||||
|
<span id="cb3-8"><a href="#cb3-8" aria-hidden="true" tabindex="-1"></a> (data) <span class="kw">=></span> {</span>
|
||||||
|
<span id="cb3-9"><a href="#cb3-9" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (<span class="op">!</span>data <span class="op">&&</span> props<span class="op">.</span><span class="at">containerId</span> <span class="op">&&</span> <span class="op">!</span>loading<span class="op">.</span><span class="at">value</span>) {</span>
|
||||||
|
<span id="cb3-10"><a href="#cb3-10" aria-hidden="true" tabindex="-1"></a> <span class="co">// Vérifier le cooldown: ignorer si on a rechargé il y a moins de 5 secondes</span></span>
|
||||||
|
<span id="cb3-11"><a href="#cb3-11" aria-hidden="true" tabindex="-1"></a> <span class="kw">const</span> timeSinceLastRefresh <span class="op">=</span> <span class="bu">Date</span><span class="op">.</span><span class="fu">now</span>() <span class="op">-</span> lastRefreshTime<span class="op">.</span><span class="at">value</span><span class="op">;</span></span>
|
||||||
|
<span id="cb3-12"><a href="#cb3-12" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (timeSinceLastRefresh <span class="op"><</span> REFRESH_COOLDOWN_MS) {</span>
|
||||||
|
<span id="cb3-13"><a href="#cb3-13" aria-hidden="true" tabindex="-1"></a> <span class="bu">console</span><span class="op">.</span><span class="fu">log</span>(</span>
|
||||||
|
<span id="cb3-14"><a href="#cb3-14" aria-hidden="true" tabindex="-1"></a> <span class="vs">`[MediaBrowser] Cache invalidé mais cooldown actif (</span><span class="sc">${</span><span class="bu">Math</span><span class="op">.</span><span class="fu">round</span>((REFRESH_COOLDOWN_MS <span class="op">-</span> timeSinceLastRefresh) <span class="op">/</span> <span class="dv">1000</span>)<span class="sc">}</span><span class="vs">s restantes), rechargement ignoré`</span><span class="op">,</span></span>
|
||||||
|
<span id="cb3-15"><a href="#cb3-15" aria-hidden="true" tabindex="-1"></a> )<span class="op">;</span></span>
|
||||||
|
<span id="cb3-16"><a href="#cb3-16" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span><span class="op">;</span></span>
|
||||||
|
<span id="cb3-17"><a href="#cb3-17" aria-hidden="true" tabindex="-1"></a> }</span>
|
||||||
|
<span id="cb3-18"><a href="#cb3-18" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb3-19"><a href="#cb3-19" aria-hidden="true" tabindex="-1"></a> <span class="co">// Annuler tout timeout en cours</span></span>
|
||||||
|
<span id="cb3-20"><a href="#cb3-20" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (refreshTimeoutId<span class="op">.</span><span class="at">value</span> <span class="op">!==</span> <span class="kw">null</span>) {</span>
|
||||||
|
<span id="cb3-21"><a href="#cb3-21" aria-hidden="true" tabindex="-1"></a> <span class="pp">clearTimeout</span>(refreshTimeoutId<span class="op">.</span><span class="at">value</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb3-22"><a href="#cb3-22" aria-hidden="true" tabindex="-1"></a> }</span>
|
||||||
|
<span id="cb3-23"><a href="#cb3-23" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb3-24"><a href="#cb3-24" aria-hidden="true" tabindex="-1"></a> <span class="co">// Planifier le rechargement après 200ms</span></span>
|
||||||
|
<span id="cb3-25"><a href="#cb3-25" aria-hidden="true" tabindex="-1"></a> refreshTimeoutId<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="bu">window</span><span class="op">.</span><span class="fu">setTimeout</span>(<span class="kw">async</span> () <span class="kw">=></span> {</span>
|
||||||
|
<span id="cb3-26"><a href="#cb3-26" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (<span class="op">!</span>isRefreshing<span class="op">.</span><span class="at">value</span>) {</span>
|
||||||
|
<span id="cb3-27"><a href="#cb3-27" aria-hidden="true" tabindex="-1"></a> <span class="bu">console</span><span class="op">.</span><span class="fu">log</span>(</span>
|
||||||
|
<span id="cb3-28"><a href="#cb3-28" aria-hidden="true" tabindex="-1"></a> <span class="vs">`[MediaBrowser] Cache invalidé pour </span><span class="sc">${</span>props<span class="op">.</span><span class="at">serverId</span><span class="sc">}</span><span class="vs">/</span><span class="sc">${</span>props<span class="op">.</span><span class="at">containerId</span><span class="sc">}</span><span class="vs">, rechargement après debounce...`</span><span class="op">,</span></span>
|
||||||
|
<span id="cb3-29"><a href="#cb3-29" aria-hidden="true" tabindex="-1"></a> )<span class="op">;</span></span>
|
||||||
|
<span id="cb3-30"><a href="#cb3-30" aria-hidden="true" tabindex="-1"></a> isRefreshing<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">true</span><span class="op">;</span></span>
|
||||||
|
<span id="cb3-31"><a href="#cb3-31" aria-hidden="true" tabindex="-1"></a> <span class="cf">await</span> <span class="fu">browseContainer</span>(</span>
|
||||||
|
<span id="cb3-32"><a href="#cb3-32" aria-hidden="true" tabindex="-1"></a> props<span class="op">.</span><span class="at">serverId</span><span class="op">,</span></span>
|
||||||
|
<span id="cb3-33"><a href="#cb3-33" aria-hidden="true" tabindex="-1"></a> props<span class="op">.</span><span class="at">containerId</span><span class="op">,</span></span>
|
||||||
|
<span id="cb3-34"><a href="#cb3-34" aria-hidden="true" tabindex="-1"></a> <span class="kw">false</span><span class="op">,</span></span>
|
||||||
|
<span id="cb3-35"><a href="#cb3-35" aria-hidden="true" tabindex="-1"></a> )<span class="op">;</span></span>
|
||||||
|
<span id="cb3-36"><a href="#cb3-36" aria-hidden="true" tabindex="-1"></a> lastRefreshTime<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="bu">Date</span><span class="op">.</span><span class="fu">now</span>()<span class="op">;</span></span>
|
||||||
|
<span id="cb3-37"><a href="#cb3-37" aria-hidden="true" tabindex="-1"></a> isRefreshing<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">false</span><span class="op">;</span></span>
|
||||||
|
<span id="cb3-38"><a href="#cb3-38" aria-hidden="true" tabindex="-1"></a> refreshTimeoutId<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">null</span><span class="op">;</span></span>
|
||||||
|
<span id="cb3-39"><a href="#cb3-39" aria-hidden="true" tabindex="-1"></a> }</span>
|
||||||
|
<span id="cb3-40"><a href="#cb3-40" aria-hidden="true" tabindex="-1"></a> }<span class="op">,</span> <span class="dv">200</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb3-41"><a href="#cb3-41" aria-hidden="true" tabindex="-1"></a> }</span>
|
||||||
|
<span id="cb3-42"><a href="#cb3-42" aria-hidden="true" tabindex="-1"></a> }<span class="op">,</span></span>
|
||||||
|
<span id="cb3-43"><a href="#cb3-43" aria-hidden="true" tabindex="-1"></a>)<span class="op">;</span></span></pre></div>
|
||||||
|
<p><strong>Après</strong>:</p>
|
||||||
|
<div class="sourceCode" id="cb4"><pre
|
||||||
|
class="sourceCode typescript"><code class="sourceCode typescript"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="co">// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)</span></span>
|
||||||
|
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a><span class="co">// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,</span></span>
|
||||||
|
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a><span class="co">// ce qui déclenche un événement ContainersUpdated qui invalide le cache</span></span>
|
||||||
|
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a><span class="co">// Le serveur contrôle déjà le flux SSE, pas besoin de debouncing côté client</span></span>
|
||||||
|
<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a><span class="fu">watch</span>(</span>
|
||||||
|
<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a> () <span class="kw">=></span> browseData<span class="op">.</span><span class="at">value</span><span class="op">,</span></span>
|
||||||
|
<span id="cb4-7"><a href="#cb4-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> (data) <span class="kw">=></span> {</span>
|
||||||
|
<span id="cb4-8"><a href="#cb4-8" aria-hidden="true" tabindex="-1"></a> <span class="co">// Si browseData devient undefined alors que containerId est présent,</span></span>
|
||||||
|
<span id="cb4-9"><a href="#cb4-9" aria-hidden="true" tabindex="-1"></a> <span class="co">// et qu'on n'est pas déjà en train de charger, recharger immédiatement</span></span>
|
||||||
|
<span id="cb4-10"><a href="#cb4-10" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (<span class="op">!</span>data <span class="op">&&</span> props<span class="op">.</span><span class="at">containerId</span> <span class="op">&&</span> <span class="op">!</span>loading<span class="op">.</span><span class="at">value</span> <span class="op">&&</span> <span class="op">!</span>isRefreshing<span class="op">.</span><span class="at">value</span>) {</span>
|
||||||
|
<span id="cb4-11"><a href="#cb4-11" aria-hidden="true" tabindex="-1"></a> <span class="bu">console</span><span class="op">.</span><span class="fu">log</span>(</span>
|
||||||
|
<span id="cb4-12"><a href="#cb4-12" aria-hidden="true" tabindex="-1"></a> <span class="vs">`[MediaBrowser] Cache invalidé pour </span><span class="sc">${</span>props<span class="op">.</span><span class="at">serverId</span><span class="sc">}</span><span class="vs">/</span><span class="sc">${</span>props<span class="op">.</span><span class="at">containerId</span><span class="sc">}</span><span class="vs">, rechargement...`</span><span class="op">,</span></span>
|
||||||
|
<span id="cb4-13"><a href="#cb4-13" aria-hidden="true" tabindex="-1"></a> )<span class="op">;</span></span>
|
||||||
|
<span id="cb4-14"><a href="#cb4-14" aria-hidden="true" tabindex="-1"></a> isRefreshing<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">true</span><span class="op">;</span></span>
|
||||||
|
<span id="cb4-15"><a href="#cb4-15" aria-hidden="true" tabindex="-1"></a> <span class="cf">await</span> <span class="fu">browseContainer</span>(props<span class="op">.</span><span class="at">serverId</span><span class="op">,</span> props<span class="op">.</span><span class="at">containerId</span><span class="op">,</span> <span class="kw">false</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb4-16"><a href="#cb4-16" aria-hidden="true" tabindex="-1"></a> isRefreshing<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">false</span><span class="op">;</span></span>
|
||||||
|
<span id="cb4-17"><a href="#cb4-17" aria-hidden="true" tabindex="-1"></a> }</span>
|
||||||
|
<span id="cb4-18"><a href="#cb4-18" aria-hidden="true" tabindex="-1"></a> }<span class="op">,</span></span>
|
||||||
|
<span id="cb4-19"><a href="#cb4-19" aria-hidden="true" tabindex="-1"></a>)<span class="op">;</span></span></pre></div>
|
||||||
|
<h2 id="résultats">Résultats</h2>
|
||||||
|
<h3 id="changements-de-comportement">Changements de comportement</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Avant</strong>: Délai de 200ms + cooldown de 2s entre les
|
||||||
|
rechargements de cache</li>
|
||||||
|
<li><strong>Après</strong>: Rechargement immédiat dès l’invalidation du
|
||||||
|
cache</li>
|
||||||
|
<li><strong>Impact</strong>: Réactivité améliorée de l’interface, les
|
||||||
|
mises à jour apparaissent immédiatement</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="réduction-de-complexité">Réduction de complexité</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>3 variables supprimées</strong>:
|
||||||
|
<code>refreshTimeoutId</code>, <code>lastRefreshTime</code>,
|
||||||
|
<code>REFRESH_COOLDOWN_MS</code></li>
|
||||||
|
<li><strong>Logique simplifiée</strong>: De ~40 lignes à ~10 lignes dans
|
||||||
|
le watcher</li>
|
||||||
|
<li><strong>Code plus lisible</strong>: Intention claire sans mécanismes
|
||||||
|
de temporisation complexes</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="tests">Tests</h3>
|
||||||
|
<ul>
|
||||||
|
<li>✓ Le projet compile sans erreurs TypeScript</li>
|
||||||
|
<li>✓ Le flag <code>isRefreshing</code> empêche toujours les
|
||||||
|
rechargements concurrents</li>
|
||||||
|
<li>✓ Les autres composants (useRenderers.ts, VolumeControl.vue)
|
||||||
|
conservent leurs optimisations légitimes</li>
|
||||||
|
</ul>
|
||||||
|
<h2 id="conclusion">Conclusion</h2>
|
||||||
|
<p>La suppression du débouncing et du cooldown dans MediaBrowser.vue
|
||||||
|
simplifie le code tout en améliorant la réactivité de l’interface.
|
||||||
|
Puisque le serveur contrôle déjà le flux SSE, ces mécanismes côté client
|
||||||
|
étaient redondants et ajoutaient une latence artificielle.</p>
|
||||||
|
<p>Le code est maintenant plus simple, plus réactif, et fait confiance
|
||||||
|
au serveur pour contrôler la fréquence des événements SSE.</p>
|
||||||
|
<h2 id="fichiers-modifiés">Fichiers modifiés</h2>
|
||||||
|
<ul>
|
||||||
|
<li><code>pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue</code></li>
|
||||||
|
</ul>
|
||||||
|
<h2 id="lignes-de-code">Lignes de code</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Supprimées</strong>: ~35 lignes (logique de
|
||||||
|
débouncing/cooldown)</li>
|
||||||
|
<li><strong>Ajoutées</strong>: ~5 lignes (logique simplifiée)</li>
|
||||||
|
<li><strong>Net</strong>: -30 lignes</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
518
Blackboard_HTML/Report_Pinnable_cache_item.html
Normal file
518
Blackboard_HTML/Report_Pinnable_cache_item.html
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Pinnable_cache_item</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
||||||
|
<script type="module">
|
||||||
|
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||||
|
mermaid.initialize({startOnLoad: true, theme: "default"});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
pre.mermaid {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="markdown-body">
|
||||||
|
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
|
||||||
|
<h1
|
||||||
|
id="rapport-implémentation-des-items-épinglables-dans-pmocache">Rapport
|
||||||
|
: Implémentation des items épinglables dans PMOcache</h1>
|
||||||
|
<h2 id="résumé">Résumé</h2>
|
||||||
|
<p>Implémentation réussie de la fonctionnalité d’items épinglables dans
|
||||||
|
la crate PMOcache, permettant de protéger certains items de l’éviction
|
||||||
|
automatique par la politique LRU. Cette fonctionnalité inclut également
|
||||||
|
un système de TTL (Time To Live) avec une règle métier empêchant qu’un
|
||||||
|
item soit à la fois épinglé et avec un TTL.</p>
|
||||||
|
<h2 id="modifications-apportées">Modifications apportées</h2>
|
||||||
|
<h3 id="structure-de-la-base-de-données-pmocachesrcdb.rs">1. Structure
|
||||||
|
de la base de données (<code>pmocache/src/db.rs</code>)</h3>
|
||||||
|
<h4 id="modification-du-schéma-de-la-table-asset">Modification du schéma
|
||||||
|
de la table <code>asset</code></h4>
|
||||||
|
<p>Ajout de deux nouvelles colonnes :</p>
|
||||||
|
<div class="sourceCode" id="cb1"><pre
|
||||||
|
class="sourceCode sql"><code class="sourceCode sql"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="kw">CREATE</span> <span class="kw">TABLE</span> <span class="cf">IF</span> <span class="kw">NOT</span> <span class="kw">EXISTS</span> asset (</span>
|
||||||
|
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a> pk TEXT <span class="kw">PRIMARY</span> <span class="kw">KEY</span>,</span>
|
||||||
|
<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a> collection TEXT,</span>
|
||||||
|
<span id="cb1-4"><a href="#cb1-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">id</span> TEXT,</span>
|
||||||
|
<span id="cb1-5"><a href="#cb1-5" aria-hidden="true" tabindex="-1"></a> hits <span class="dt">INTEGER</span> <span class="kw">DEFAULT</span> <span class="dv">0</span>,</span>
|
||||||
|
<span id="cb1-6"><a href="#cb1-6" aria-hidden="true" tabindex="-1"></a> last_used TEXT,</span>
|
||||||
|
<span id="cb1-7"><a href="#cb1-7" aria-hidden="true" tabindex="-1"></a> lazy_pk TEXT,</span>
|
||||||
|
<span id="cb1-8"><a href="#cb1-8" aria-hidden="true" tabindex="-1"></a> pinned <span class="dt">INTEGER</span> <span class="kw">DEFAULT</span> <span class="dv">0</span> <span class="kw">CHECK</span> (pinned <span class="kw">IN</span> (<span class="dv">0</span>, <span class="dv">1</span>)),</span>
|
||||||
|
<span id="cb1-9"><a href="#cb1-9" aria-hidden="true" tabindex="-1"></a> ttl_expires_at TEXT</span>
|
||||||
|
<span id="cb1-10"><a href="#cb1-10" aria-hidden="true" tabindex="-1"></a>)</span></pre></div>
|
||||||
|
<ul>
|
||||||
|
<li><strong><code>pinned</code></strong> : Booléen (0 ou 1) indiquant si
|
||||||
|
l’item est épinglé</li>
|
||||||
|
<li><strong><code>ttl_expires_at</code></strong> : Date/heure
|
||||||
|
d’expiration au format RFC3339 (optionnel)</li>
|
||||||
|
</ul>
|
||||||
|
<h4 id="mise-à-jour-de-la-structure-cacheentry">Mise à jour de la
|
||||||
|
structure <code>CacheEntry</code></h4>
|
||||||
|
<p>Ajout des champs correspondants :</p>
|
||||||
|
<div class="sourceCode" id="cb2"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> CacheEntry <span class="op">{</span></span>
|
||||||
|
<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a> <span class="co">// ... champs existants ...</span></span>
|
||||||
|
<span id="cb2-3"><a href="#cb2-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> pinned<span class="op">:</span> <span class="dt">bool</span><span class="op">,</span></span>
|
||||||
|
<span id="cb2-4"><a href="#cb2-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> ttl_expires_at<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb2-5"><a href="#cb2-5" aria-hidden="true" tabindex="-1"></a> <span class="co">// ...</span></span>
|
||||||
|
<span id="cb2-6"><a href="#cb2-6" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h4 id="nouvelles-méthodes-dans-db">Nouvelles méthodes dans
|
||||||
|
<code>DB</code></h4>
|
||||||
|
<h5 id="gestion-du-comptage">Gestion du comptage</h5>
|
||||||
|
<ul>
|
||||||
|
<li><strong><code>count_unpinned()</code></strong> : Compte uniquement
|
||||||
|
les items non épinglés
|
||||||
|
<ul>
|
||||||
|
<li>Les items épinglés ne comptent pas dans la limite du cache</li>
|
||||||
|
</ul></li>
|
||||||
|
</ul>
|
||||||
|
<h5 id="gestion-du-pinning">Gestion du pinning</h5>
|
||||||
|
<ul>
|
||||||
|
<li><p><strong><code>pin(pk: &str)</code></strong> : Épingle un
|
||||||
|
item</p>
|
||||||
|
<ul>
|
||||||
|
<li>Vérifie que l’item n’a pas de TTL défini (règle métier)</li>
|
||||||
|
<li>Retourne une erreur si le TTL est déjà défini</li>
|
||||||
|
</ul></li>
|
||||||
|
<li><p><strong><code>unpin(pk: &str)</code></strong> : Désépingle un
|
||||||
|
item</p></li>
|
||||||
|
<li><p><strong><code>is_pinned(pk: &str)</code></strong> : Vérifie
|
||||||
|
si un item est épinglé</p></li>
|
||||||
|
</ul>
|
||||||
|
<h5 id="gestion-du-ttl">Gestion du TTL</h5>
|
||||||
|
<ul>
|
||||||
|
<li><p><strong><code>set_ttl(pk: &str, expires_at: &str)</code></strong>
|
||||||
|
: Définit le TTL d’un item</p>
|
||||||
|
<ul>
|
||||||
|
<li>Vérifie que l’item n’est pas épinglé (règle métier)</li>
|
||||||
|
<li>Retourne une erreur si l’item est épinglé</li>
|
||||||
|
</ul></li>
|
||||||
|
<li><p><strong><code>clear_ttl(pk: &str)</code></strong> : Supprime
|
||||||
|
le TTL d’un item</p></li>
|
||||||
|
<li><p><strong><code>get_expired()</code></strong> : Récupère tous les
|
||||||
|
items dont le TTL est dépassé</p></li>
|
||||||
|
</ul>
|
||||||
|
<h5 id="modification-de-get_oldest">Modification de
|
||||||
|
<code>get_oldest()</code></h5>
|
||||||
|
<p>La requête SQL exclut maintenant les items épinglés :</p>
|
||||||
|
<div class="sourceCode" id="cb3"><pre
|
||||||
|
class="sourceCode sql"><code class="sourceCode sql"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="kw">SELECT</span> <span class="op">..</span>. <span class="kw">FROM</span> asset</span>
|
||||||
|
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a><span class="kw">WHERE</span> pinned <span class="op">=</span> <span class="dv">0</span></span>
|
||||||
|
<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a><span class="kw">ORDER</span> <span class="kw">BY</span> last_used <span class="kw">ASC</span>, hits <span class="kw">ASC</span></span>
|
||||||
|
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a><span class="kw">LIMIT</span> ?<span class="dv">1</span></span></pre></div>
|
||||||
|
<h3 id="logique-du-cache-pmocachesrccache.rs">2. Logique du cache
|
||||||
|
(<code>pmocache/src/cache.rs</code>)</h3>
|
||||||
|
<h4 id="méthodes-publiques-ajoutées">Méthodes publiques ajoutées</h4>
|
||||||
|
<div class="sourceCode" id="cb4"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">async</span> <span class="kw">fn</span> pin(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> pk<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">></span></span>
|
||||||
|
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">async</span> <span class="kw">fn</span> unpin(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> pk<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">></span></span>
|
||||||
|
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">async</span> <span class="kw">fn</span> is_pinned(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> pk<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">bool</span><span class="op">></span></span>
|
||||||
|
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">async</span> <span class="kw">fn</span> set_ttl(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> pk<span class="op">:</span> <span class="op">&</span><span class="dt">str</span><span class="op">,</span> expires_at<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">></span></span>
|
||||||
|
<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">async</span> <span class="kw">fn</span> clear_ttl(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> pk<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">></span></span></pre></div>
|
||||||
|
<h4 id="modification-de-enforce_limit">Modification de
|
||||||
|
<code>enforce_limit()</code></h4>
|
||||||
|
<p>La politique d’éviction a été améliorée :</p>
|
||||||
|
<ol type="1">
|
||||||
|
<li><strong>Suppression prioritaire des items expirés</strong> : Les
|
||||||
|
items dont le TTL est dépassé sont supprimés en premier</li>
|
||||||
|
<li><strong>Comptage des items non épinglés</strong> : Utilise
|
||||||
|
<code>count_unpinned()</code> au lieu de <code>count()</code></li>
|
||||||
|
<li><strong>Protection des items épinglés</strong> : Ils ne peuvent pas
|
||||||
|
être évincés par LRU</li>
|
||||||
|
<li><strong>Logging amélioré</strong> : Messages distincts pour les
|
||||||
|
items expirés et l’éviction LRU</li>
|
||||||
|
</ol>
|
||||||
|
<h3 id="tests-pmocacheteststest_pinnable.rs">3. Tests
|
||||||
|
(<code>pmocache/tests/test_pinnable.rs</code>)</h3>
|
||||||
|
<p>Création d’une suite complète de tests (9 tests, tous passants) :</p>
|
||||||
|
<ol type="1">
|
||||||
|
<li><strong><code>test_pin_unpin</code></strong> : Vérifie l’épinglage
|
||||||
|
et le désépinglage basiques</li>
|
||||||
|
<li><strong><code>test_pinned_excluded_from_lru</code></strong> :
|
||||||
|
Vérifie que les items épinglés ne sont pas évincés</li>
|
||||||
|
<li><strong><code>test_pinned_count_separately</code></strong> : Vérifie
|
||||||
|
le comptage séparé des items épinglés</li>
|
||||||
|
<li><strong><code>test_cannot_pin_with_ttl</code></strong> : Vérifie la
|
||||||
|
règle métier TTL → pas de pinning</li>
|
||||||
|
<li><strong><code>test_cannot_set_ttl_when_pinned</code></strong> :
|
||||||
|
Vérifie la règle métier pinned → pas de TTL</li>
|
||||||
|
<li><strong><code>test_ttl_expiration</code></strong> : Vérifie la
|
||||||
|
suppression automatique des items expirés</li>
|
||||||
|
<li><strong><code>test_clear_ttl</code></strong> : Vérifie la
|
||||||
|
suppression du TTL</li>
|
||||||
|
<li><strong><code>test_get_expired</code></strong> : Vérifie la
|
||||||
|
récupération des items expirés</li>
|
||||||
|
<li><strong><code>test_cache_entry_fields</code></strong> : Vérifie les
|
||||||
|
valeurs des champs dans <code>CacheEntry</code></li>
|
||||||
|
</ol>
|
||||||
|
<h2 id="règles-métier-implémentées">Règles métier implémentées</h2>
|
||||||
|
<h3 id="incompatibilité-ttl-pinned">Incompatibilité TTL ↔︎ Pinned</h3>
|
||||||
|
<p>Un item ne peut pas être à la fois épinglé ET avoir un TTL :</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Si TTL défini</strong> : <code>pin()</code> retourne une
|
||||||
|
erreur</li>
|
||||||
|
<li><strong>Si épinglé</strong> : <code>set_ttl()</code> retourne une
|
||||||
|
erreur</li>
|
||||||
|
</ul>
|
||||||
|
<p>Cette règle garantit une sémantique claire : -
|
||||||
|
<strong>Épinglé</strong> = permanent, protégé de l’éviction -
|
||||||
|
<strong>TTL</strong> = temporaire, sera supprimé à expiration</p>
|
||||||
|
<h3 id="comptage-des-items">Comptage des items</h3>
|
||||||
|
<p>Les items épinglés sont <strong>exclus</strong> du comptage de la
|
||||||
|
limite du cache :</p>
|
||||||
|
<ul>
|
||||||
|
<li>Un cache de limite 100 peut contenir 100 items non épinglés + N
|
||||||
|
items épinglés</li>
|
||||||
|
<li>Seuls les items non épinglés sont pris en compte pour l’éviction
|
||||||
|
LRU</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="ordre-de-suppression-lors-de-enforce_limit">Ordre de suppression
|
||||||
|
lors de <code>enforce_limit()</code></h3>
|
||||||
|
<ol type="1">
|
||||||
|
<li><strong>Items expirés (TTL dépassé)</strong> : supprimés en
|
||||||
|
priorité</li>
|
||||||
|
<li><strong>Items LRU</strong> : si la limite est toujours dépassée,
|
||||||
|
suppression des plus vieux items <strong>non épinglés</strong></li>
|
||||||
|
</ol>
|
||||||
|
<h2 id="compatibilité">Compatibilité</h2>
|
||||||
|
<h3 id="migration-de-base-de-données">Migration de base de données</h3>
|
||||||
|
<p><strong>Aucune migration nécessaire</strong> : Les colonnes
|
||||||
|
<code>pinned</code> et <code>ttl_expires_at</code> ont des valeurs par
|
||||||
|
défaut : - <code>pinned = 0</code> (non épinglé) -
|
||||||
|
<code>ttl_expires_at = NULL</code> (pas de TTL)</p>
|
||||||
|
<p>Les bases existantes seront automatiquement mises à jour au prochain
|
||||||
|
démarrage via le <code>CREATE TABLE IF NOT EXISTS</code> avec les
|
||||||
|
nouvelles colonnes.</p>
|
||||||
|
<h3 id="rétrocompatibilité-du-code">Rétrocompatibilité du code</h3>
|
||||||
|
<p>Toutes les méthodes existantes continuent de fonctionner sans
|
||||||
|
modification : - Les items existants ne sont pas épinglés par défaut -
|
||||||
|
Le comportement LRU standard reste identique pour les items non
|
||||||
|
épinglés</p>
|
||||||
|
<h2 id="exemples-dutilisation">Exemples d’utilisation</h2>
|
||||||
|
<h3 id="utilisation-programmatique-rust">Utilisation programmatique
|
||||||
|
(Rust)</h3>
|
||||||
|
<div class="sourceCode" id="cb5"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmocache::</span><span class="op">{</span>Cache<span class="op">,</span> CacheConfig<span class="op">};</span></span>
|
||||||
|
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">chrono::</span><span class="op">{</span>Duration<span class="op">,</span> Utc<span class="op">};</span></span>
|
||||||
|
<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb5-4"><a href="#cb5-4" aria-hidden="true" tabindex="-1"></a><span class="co">// Créer un cache</span></span>
|
||||||
|
<span id="cb5-5"><a href="#cb5-5" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> cache <span class="op">=</span> <span class="pp">Cache::</span><span class="op"><</span>MyConfig<span class="op">></span><span class="pp">::</span>new(<span class="st">"./cache"</span><span class="op">,</span> <span class="dv">100</span>)<span class="op">.</span>unwrap()<span class="op">;</span></span>
|
||||||
|
<span id="cb5-6"><a href="#cb5-6" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb5-7"><a href="#cb5-7" aria-hidden="true" tabindex="-1"></a><span class="co">// Ajouter un fichier</span></span>
|
||||||
|
<span id="cb5-8"><a href="#cb5-8" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> pk <span class="op">=</span> cache<span class="op">.</span>add_from_url(<span class="st">"https://example.com/file.dat"</span><span class="op">,</span> <span class="cn">None</span>)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb5-9"><a href="#cb5-9" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb5-10"><a href="#cb5-10" aria-hidden="true" tabindex="-1"></a><span class="co">// Épingler pour protéger de l'éviction</span></span>
|
||||||
|
<span id="cb5-11"><a href="#cb5-11" aria-hidden="true" tabindex="-1"></a>cache<span class="op">.</span>pin(<span class="op">&</span>pk)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb5-12"><a href="#cb5-12" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb5-13"><a href="#cb5-13" aria-hidden="true" tabindex="-1"></a><span class="co">// Ou définir un TTL de 24 heures</span></span>
|
||||||
|
<span id="cb5-14"><a href="#cb5-14" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> expires_at <span class="op">=</span> (<span class="pp">Utc::</span>now() <span class="op">+</span> <span class="pp">Duration::</span>hours(<span class="dv">24</span>))<span class="op">.</span>to_rfc3339()<span class="op">;</span></span>
|
||||||
|
<span id="cb5-15"><a href="#cb5-15" aria-hidden="true" tabindex="-1"></a>cache<span class="op">.</span>set_ttl(<span class="op">&</span>pk2<span class="op">,</span> <span class="op">&</span>expires_at)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
|
||||||
|
<span id="cb5-16"><a href="#cb5-16" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb5-17"><a href="#cb5-17" aria-hidden="true" tabindex="-1"></a><span class="co">// Vérifier le statut</span></span>
|
||||||
|
<span id="cb5-18"><a href="#cb5-18" aria-hidden="true" tabindex="-1"></a><span class="cf">if</span> cache<span class="op">.</span>is_pinned(<span class="op">&</span>pk)<span class="op">.</span><span class="kw">await</span><span class="op">?</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb5-19"><a href="#cb5-19" aria-hidden="true" tabindex="-1"></a> <span class="pp">println!</span>(<span class="st">"Fichier protégé"</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb5-20"><a href="#cb5-20" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h3 id="utilisation-via-lapi-rest">Utilisation via l’API REST</h3>
|
||||||
|
<h4 id="récupérer-le-statut-de-pinning">Récupérer le statut de
|
||||||
|
pinning</h4>
|
||||||
|
<div class="sourceCode" id="cb6"><pre
|
||||||
|
class="sourceCode bash"><code class="sourceCode bash"><span id="cb6-1"><a href="#cb6-1" aria-hidden="true" tabindex="-1"></a><span class="ex">GET</span> /api/cache/{pk}/pin</span>
|
||||||
|
<span id="cb6-2"><a href="#cb6-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb6-3"><a href="#cb6-3" aria-hidden="true" tabindex="-1"></a><span class="ex">Response</span> 200 OK:</span>
|
||||||
|
<span id="cb6-4"><a href="#cb6-4" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
|
||||||
|
<span id="cb6-5"><a href="#cb6-5" aria-hidden="true" tabindex="-1"></a> <span class="st">"pk"</span><span class="ex">:</span> <span class="st">"1a2b3c4d5e6f7a8b"</span>,</span>
|
||||||
|
<span id="cb6-6"><a href="#cb6-6" aria-hidden="true" tabindex="-1"></a> <span class="st">"pinned"</span><span class="ex">:</span> false,</span>
|
||||||
|
<span id="cb6-7"><a href="#cb6-7" aria-hidden="true" tabindex="-1"></a> <span class="st">"ttl_expires_at"</span><span class="ex">:</span> null</span>
|
||||||
|
<span id="cb6-8"><a href="#cb6-8" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span></pre></div>
|
||||||
|
<h4 id="épingler-un-item">Épingler un item</h4>
|
||||||
|
<div class="sourceCode" id="cb7"><pre
|
||||||
|
class="sourceCode bash"><code class="sourceCode bash"><span id="cb7-1"><a href="#cb7-1" aria-hidden="true" tabindex="-1"></a><span class="ex">POST</span> /api/cache/{pk}/pin</span>
|
||||||
|
<span id="cb7-2"><a href="#cb7-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb7-3"><a href="#cb7-3" aria-hidden="true" tabindex="-1"></a><span class="ex">Response</span> 200 OK:</span>
|
||||||
|
<span id="cb7-4"><a href="#cb7-4" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
|
||||||
|
<span id="cb7-5"><a href="#cb7-5" aria-hidden="true" tabindex="-1"></a> <span class="st">"pk"</span><span class="ex">:</span> <span class="st">"1a2b3c4d5e6f7a8b"</span>,</span>
|
||||||
|
<span id="cb7-6"><a href="#cb7-6" aria-hidden="true" tabindex="-1"></a> <span class="st">"message"</span><span class="ex">:</span> <span class="st">"Item '1a2b3c4d5e6f7a8b' pinned successfully"</span></span>
|
||||||
|
<span id="cb7-7"><a href="#cb7-7" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span>
|
||||||
|
<span id="cb7-8"><a href="#cb7-8" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb7-9"><a href="#cb7-9" aria-hidden="true" tabindex="-1"></a><span class="ex">Response</span> 409 CONFLICT <span class="er">(</span><span class="ex">si</span> TTL défini<span class="kw">)</span><span class="bu">:</span></span>
|
||||||
|
<span id="cb7-10"><a href="#cb7-10" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
|
||||||
|
<span id="cb7-11"><a href="#cb7-11" aria-hidden="true" tabindex="-1"></a> <span class="st">"error"</span><span class="ex">:</span> <span class="st">"CONFLICT"</span>,</span>
|
||||||
|
<span id="cb7-12"><a href="#cb7-12" aria-hidden="true" tabindex="-1"></a> <span class="st">"message"</span><span class="ex">:</span> <span class="st">"Cannot pin an item with TTL set. Clear TTL first."</span></span>
|
||||||
|
<span id="cb7-13"><a href="#cb7-13" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span></pre></div>
|
||||||
|
<h4 id="désépingler-un-item">Désépingler un item</h4>
|
||||||
|
<div class="sourceCode" id="cb8"><pre
|
||||||
|
class="sourceCode bash"><code class="sourceCode bash"><span id="cb8-1"><a href="#cb8-1" aria-hidden="true" tabindex="-1"></a><span class="ex">DELETE</span> /api/cache/{pk}/pin</span>
|
||||||
|
<span id="cb8-2"><a href="#cb8-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-3"><a href="#cb8-3" aria-hidden="true" tabindex="-1"></a><span class="ex">Response</span> 200 OK:</span>
|
||||||
|
<span id="cb8-4"><a href="#cb8-4" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
|
||||||
|
<span id="cb8-5"><a href="#cb8-5" aria-hidden="true" tabindex="-1"></a> <span class="st">"pk"</span><span class="ex">:</span> <span class="st">"1a2b3c4d5e6f7a8b"</span>,</span>
|
||||||
|
<span id="cb8-6"><a href="#cb8-6" aria-hidden="true" tabindex="-1"></a> <span class="st">"message"</span><span class="ex">:</span> <span class="st">"Item '1a2b3c4d5e6f7a8b' unpinned successfully"</span></span>
|
||||||
|
<span id="cb8-7"><a href="#cb8-7" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span></pre></div>
|
||||||
|
<h4 id="définir-un-ttl">Définir un TTL</h4>
|
||||||
|
<div class="sourceCode" id="cb9"><pre
|
||||||
|
class="sourceCode bash"><code class="sourceCode bash"><span id="cb9-1"><a href="#cb9-1" aria-hidden="true" tabindex="-1"></a><span class="ex">POST</span> /api/cache/{pk}/ttl</span>
|
||||||
|
<span id="cb9-2"><a href="#cb9-2" aria-hidden="true" tabindex="-1"></a><span class="ex">Content-Type:</span> application/json</span>
|
||||||
|
<span id="cb9-3"><a href="#cb9-3" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb9-4"><a href="#cb9-4" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
|
||||||
|
<span id="cb9-5"><a href="#cb9-5" aria-hidden="true" tabindex="-1"></a> <span class="st">"expires_at"</span><span class="ex">:</span> <span class="st">"2025-01-20T10:30:00Z"</span></span>
|
||||||
|
<span id="cb9-6"><a href="#cb9-6" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span>
|
||||||
|
<span id="cb9-7"><a href="#cb9-7" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb9-8"><a href="#cb9-8" aria-hidden="true" tabindex="-1"></a><span class="ex">Response</span> 200 OK:</span>
|
||||||
|
<span id="cb9-9"><a href="#cb9-9" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
|
||||||
|
<span id="cb9-10"><a href="#cb9-10" aria-hidden="true" tabindex="-1"></a> <span class="st">"pk"</span><span class="ex">:</span> <span class="st">"1a2b3c4d5e6f7a8b"</span>,</span>
|
||||||
|
<span id="cb9-11"><a href="#cb9-11" aria-hidden="true" tabindex="-1"></a> <span class="st">"message"</span><span class="ex">:</span> <span class="st">"TTL set successfully for item '1a2b3c4d5e6f7a8b'"</span></span>
|
||||||
|
<span id="cb9-12"><a href="#cb9-12" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span>
|
||||||
|
<span id="cb9-13"><a href="#cb9-13" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb9-14"><a href="#cb9-14" aria-hidden="true" tabindex="-1"></a><span class="ex">Response</span> 409 CONFLICT <span class="er">(</span><span class="ex">si</span> épinglé<span class="kw">)</span><span class="bu">:</span></span>
|
||||||
|
<span id="cb9-15"><a href="#cb9-15" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
|
||||||
|
<span id="cb9-16"><a href="#cb9-16" aria-hidden="true" tabindex="-1"></a> <span class="st">"error"</span><span class="ex">:</span> <span class="st">"CONFLICT"</span>,</span>
|
||||||
|
<span id="cb9-17"><a href="#cb9-17" aria-hidden="true" tabindex="-1"></a> <span class="st">"message"</span><span class="ex">:</span> <span class="st">"Cannot set TTL on a pinned item. Unpin first."</span></span>
|
||||||
|
<span id="cb9-18"><a href="#cb9-18" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span>
|
||||||
|
<span id="cb9-19"><a href="#cb9-19" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb9-20"><a href="#cb9-20" aria-hidden="true" tabindex="-1"></a><span class="ex">Response</span> 400 BAD REQUEST <span class="er">(</span><span class="ex">format</span> invalide<span class="kw">)</span><span class="bu">:</span></span>
|
||||||
|
<span id="cb9-21"><a href="#cb9-21" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
|
||||||
|
<span id="cb9-22"><a href="#cb9-22" aria-hidden="true" tabindex="-1"></a> <span class="st">"error"</span><span class="ex">:</span> <span class="st">"INVALID_DATE"</span>,</span>
|
||||||
|
<span id="cb9-23"><a href="#cb9-23" aria-hidden="true" tabindex="-1"></a> <span class="st">"message"</span><span class="ex">:</span> <span class="st">"Invalid RFC3339 date format"</span></span>
|
||||||
|
<span id="cb9-24"><a href="#cb9-24" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span></pre></div>
|
||||||
|
<h4 id="supprimer-un-ttl">Supprimer un TTL</h4>
|
||||||
|
<div class="sourceCode" id="cb10"><pre
|
||||||
|
class="sourceCode bash"><code class="sourceCode bash"><span id="cb10-1"><a href="#cb10-1" aria-hidden="true" tabindex="-1"></a><span class="ex">DELETE</span> /api/cache/{pk}/ttl</span>
|
||||||
|
<span id="cb10-2"><a href="#cb10-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb10-3"><a href="#cb10-3" aria-hidden="true" tabindex="-1"></a><span class="ex">Response</span> 200 OK:</span>
|
||||||
|
<span id="cb10-4"><a href="#cb10-4" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
|
||||||
|
<span id="cb10-5"><a href="#cb10-5" aria-hidden="true" tabindex="-1"></a> <span class="st">"pk"</span><span class="ex">:</span> <span class="st">"1a2b3c4d5e6f7a8b"</span>,</span>
|
||||||
|
<span id="cb10-6"><a href="#cb10-6" aria-hidden="true" tabindex="-1"></a> <span class="st">"message"</span><span class="ex">:</span> <span class="st">"TTL cleared successfully for item '1a2b3c4d5e6f7a8b'"</span></span>
|
||||||
|
<span id="cb10-7"><a href="#cb10-7" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span></pre></div>
|
||||||
|
<h2 id="fichiers-modifiés">Fichiers modifiés</h2>
|
||||||
|
<h3 id="phase-1-implémentation-de-base">Phase 1 : Implémentation de
|
||||||
|
base</h3>
|
||||||
|
<ol type="1">
|
||||||
|
<li><strong><code>pmocache/src/db.rs</code></strong> :
|
||||||
|
<ul>
|
||||||
|
<li>Modification du schéma SQL</li>
|
||||||
|
<li>Ajout de champs dans <code>CacheEntry</code></li>
|
||||||
|
<li>Ajout de 8 nouvelles méthodes</li>
|
||||||
|
<li>Modification de <code>get_oldest()</code>, <code>get()</code>,
|
||||||
|
<code>get_from_id()</code>, <code>get_all()</code>,
|
||||||
|
<code>get_by_collection()</code></li>
|
||||||
|
</ul></li>
|
||||||
|
<li><strong><code>pmocache/src/cache.rs</code></strong> :
|
||||||
|
<ul>
|
||||||
|
<li>Ajout de 5 méthodes publiques</li>
|
||||||
|
<li>Modification de <code>enforce_limit()</code></li>
|
||||||
|
</ul></li>
|
||||||
|
<li><strong><code>pmocache/tests/test_pinnable.rs</code></strong> :
|
||||||
|
<ul>
|
||||||
|
<li>Nouveau fichier de tests (9 tests)</li>
|
||||||
|
</ul></li>
|
||||||
|
</ol>
|
||||||
|
<h3 id="phase-2-enrichissement-de-lapi-rest">Phase 2 : Enrichissement de
|
||||||
|
l’API REST</h3>
|
||||||
|
<ol start="4" type="1">
|
||||||
|
<li><strong><code>pmocache/src/api.rs</code></strong> :
|
||||||
|
<ul>
|
||||||
|
<li>Ajout de 3 nouvelles structures de données :
|
||||||
|
<code>SetTtlRequest</code>, <code>PinResponse</code>,
|
||||||
|
<code>PinStatus</code></li>
|
||||||
|
<li>Ajout de 5 nouveaux handlers d’API :
|
||||||
|
<ul>
|
||||||
|
<li><code>get_pin_status()</code> : Récupération du statut de
|
||||||
|
pinning</li>
|
||||||
|
<li><code>pin_item()</code> : Épinglage d’un item</li>
|
||||||
|
<li><code>unpin_item()</code> : Désépinglage d’un item</li>
|
||||||
|
<li><code>set_item_ttl()</code> : Définition du TTL</li>
|
||||||
|
<li><code>clear_item_ttl()</code> : Suppression du TTL</li>
|
||||||
|
</ul></li>
|
||||||
|
</ul></li>
|
||||||
|
<li><strong><code>pmocache/src/pmoserver_ext.rs</code></strong> :
|
||||||
|
<ul>
|
||||||
|
<li>Ajout de 4 nouvelles routes dans <code>create_api_router()</code> :
|
||||||
|
<ul>
|
||||||
|
<li><code>GET /{pk}/pin</code> : Statut de pinning</li>
|
||||||
|
<li><code>POST /{pk}/pin</code> : Épingler</li>
|
||||||
|
<li><code>DELETE /{pk}/pin</code> : Désépingler</li>
|
||||||
|
<li><code>POST /{pk}/ttl</code> : Définir TTL</li>
|
||||||
|
<li><code>DELETE /{pk}/ttl</code> : Supprimer TTL</li>
|
||||||
|
</ul></li>
|
||||||
|
</ul></li>
|
||||||
|
<li><strong><code>pmocache/src/openapi.rs</code></strong> :
|
||||||
|
<ul>
|
||||||
|
<li>Mise à jour de la macro <code>create_cache_openapi!</code> pour
|
||||||
|
inclure :
|
||||||
|
<ul>
|
||||||
|
<li>Les 5 nouveaux endpoints dans la documentation</li>
|
||||||
|
<li>Les 3 nouvelles structures dans les schémas OpenAPI</li>
|
||||||
|
</ul></li>
|
||||||
|
</ul></li>
|
||||||
|
<li><strong><code>pmocache/src/lib.rs</code></strong> :
|
||||||
|
<ul>
|
||||||
|
<li>Export des nouvelles structures publiques pour l’API</li>
|
||||||
|
</ul></li>
|
||||||
|
</ol>
|
||||||
|
<h2 id="api-rest-et-documentation-openapi">API REST et Documentation
|
||||||
|
OpenAPI</h2>
|
||||||
|
<h3 id="routes-disponibles">Routes disponibles</h3>
|
||||||
|
<p>Toutes les routes sont préfixées par <code>/api/{cache_name}/</code>
|
||||||
|
(ex: <code>/api/covers/</code>, <code>/api/audio/</code>).</p>
|
||||||
|
<table>
|
||||||
|
<colgroup>
|
||||||
|
<col style="width: 31%" />
|
||||||
|
<col style="width: 24%" />
|
||||||
|
<col style="width: 44%" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Méthode</th>
|
||||||
|
<th>Route</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>GET</code></td>
|
||||||
|
<td><code>/{pk}/pin</code></td>
|
||||||
|
<td>Récupère le statut de pinning d’un item</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>POST</code></td>
|
||||||
|
<td><code>/{pk}/pin</code></td>
|
||||||
|
<td>Épingle un item (le protège de l’éviction LRU)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>DELETE</code></td>
|
||||||
|
<td><code>/{pk}/pin</code></td>
|
||||||
|
<td>Désépingle un item</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>POST</code></td>
|
||||||
|
<td><code>/{pk}/ttl</code></td>
|
||||||
|
<td>Définit le TTL d’un item (expiration automatique)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>DELETE</code></td>
|
||||||
|
<td><code>/{pk}/ttl</code></td>
|
||||||
|
<td>Supprime le TTL d’un item</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h3 id="codes-de-statut-http">Codes de statut HTTP</h3>
|
||||||
|
<table>
|
||||||
|
<colgroup>
|
||||||
|
<col style="width: 18%" />
|
||||||
|
<col style="width: 42%" />
|
||||||
|
<col style="width: 39%" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Code</th>
|
||||||
|
<th>Signification</th>
|
||||||
|
<th>Cas d’usage</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>200 OK</code></td>
|
||||||
|
<td>Opération réussie</td>
|
||||||
|
<td>Tous les cas de succès</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>400 BAD REQUEST</code></td>
|
||||||
|
<td>Requête invalide</td>
|
||||||
|
<td>Format de date TTL invalide</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>404 NOT FOUND</code></td>
|
||||||
|
<td>Item non trouvé</td>
|
||||||
|
<td>PK inexistant dans le cache</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>409 CONFLICT</code></td>
|
||||||
|
<td>Conflit de règle métier</td>
|
||||||
|
<td>Tentative de pin avec TTL ou vice-versa</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>500 INTERNAL SERVER ERROR</code></td>
|
||||||
|
<td>Erreur serveur</td>
|
||||||
|
<td>Erreur de base de données</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h3 id="documentation-openapiswagger">Documentation OpenAPI/Swagger</h3>
|
||||||
|
<p>La documentation OpenAPI est automatiquement générée et inclut :</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Schémas de données</strong> :
|
||||||
|
<ul>
|
||||||
|
<li><code>PinStatus</code> : Statut de pinning (pinned,
|
||||||
|
ttl_expires_at)</li>
|
||||||
|
<li><code>PinResponse</code> : Réponse d’opération de pinning</li>
|
||||||
|
<li><code>SetTtlRequest</code> : Requête de définition de TTL</li>
|
||||||
|
<li><code>CacheEntry</code> : Mis à jour avec les champs
|
||||||
|
<code>pinned</code> et <code>ttl_expires_at</code></li>
|
||||||
|
</ul></li>
|
||||||
|
<li><strong>Endpoints documentés</strong> :
|
||||||
|
<ul>
|
||||||
|
<li>Description détaillée de chaque route</li>
|
||||||
|
<li>Exemples de requêtes et réponses</li>
|
||||||
|
<li>Codes d’erreur possibles</li>
|
||||||
|
</ul></li>
|
||||||
|
<li><strong>Interface Swagger UI</strong> :
|
||||||
|
<ul>
|
||||||
|
<li>Accessible à <code>/swagger-ui/{cache_name}</code></li>
|
||||||
|
<li>Permet de tester l’API directement depuis le navigateur</li>
|
||||||
|
</ul></li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="gestion-des-erreurs">Gestion des erreurs</h3>
|
||||||
|
<p>L’API suit une structure d’erreur cohérente :</p>
|
||||||
|
<div class="sourceCode" id="cb11"><pre
|
||||||
|
class="sourceCode json"><code class="sourceCode json"><span id="cb11-1"><a href="#cb11-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span></span>
|
||||||
|
<span id="cb11-2"><a href="#cb11-2" aria-hidden="true" tabindex="-1"></a> <span class="dt">"error"</span><span class="fu">:</span> <span class="st">"CODE_ERREUR"</span><span class="fu">,</span></span>
|
||||||
|
<span id="cb11-3"><a href="#cb11-3" aria-hidden="true" tabindex="-1"></a> <span class="dt">"message"</span><span class="fu">:</span> <span class="st">"Description lisible de l'erreur"</span></span>
|
||||||
|
<span id="cb11-4"><a href="#cb11-4" aria-hidden="true" tabindex="-1"></a><span class="fu">}</span></span></pre></div>
|
||||||
|
<p>Les règles métier sont appliquées strictement : - <strong>409
|
||||||
|
CONFLICT</strong> si tentative de pin avec TTL défini - <strong>409
|
||||||
|
CONFLICT</strong> si tentative de set TTL sur item épinglé - Messages
|
||||||
|
d’erreur explicites guidant l’utilisateur</p>
|
||||||
|
<h2 id="tests">Tests</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Suite de tests dédiée</strong> : 9 tests, tous passants</li>
|
||||||
|
<li><strong>Tests existants</strong> : Tous les tests de
|
||||||
|
<code>test_cache.rs</code> passent toujours</li>
|
||||||
|
<li><strong>Couverture</strong> : Toutes les nouvelles fonctionnalités
|
||||||
|
sont testées</li>
|
||||||
|
<li><strong>Compilation</strong> : Aucune erreur, tous les modules
|
||||||
|
compilent correctement</li>
|
||||||
|
</ul>
|
||||||
|
<h2 id="résultat">Résultat</h2>
|
||||||
|
<p>✅ <strong>Implémentation complète et fonctionnelle</strong> des
|
||||||
|
items épinglables avec TTL<br />
|
||||||
|
✅ <strong>Règle métier</strong> TTL ↔︎ Pinned correctement
|
||||||
|
implémentée<br />
|
||||||
|
✅ <strong>Tests exhaustifs</strong> validant tous les cas d’usage<br />
|
||||||
|
✅ <strong>Compatibilité</strong> avec les bases de données
|
||||||
|
existantes<br />
|
||||||
|
✅ <strong>Pas de régression</strong> sur les tests existants<br />
|
||||||
|
✅ <strong>API REST complète</strong> avec 5 nouveaux endpoints<br />
|
||||||
|
✅ <strong>Documentation OpenAPI</strong> automatiquement générée<br />
|
||||||
|
✅ <strong>Gestion d’erreurs cohérente</strong> avec codes HTTP
|
||||||
|
appropriés</p>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
209
Blackboard_HTML/Report_WeabApp_debouncingSSE.html
Normal file
209
Blackboard_HTML/Report_WeabApp_debouncingSSE.html
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>WeabApp_debouncingSSE</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
||||||
|
<script type="module">
|
||||||
|
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||||
|
mermaid.initialize({startOnLoad: true, theme: "default"});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
pre.mermaid {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="markdown-body">
|
||||||
|
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
|
||||||
|
<h1 id="rapport-suppression-de-la-logique-de-débouncing-sse">Rapport :
|
||||||
|
Suppression de la logique de débouncing SSE</h1>
|
||||||
|
<p><strong>Date</strong>: 2026-01-12 <strong>Tâche</strong>:
|
||||||
|
WeabApp_debouncingSSE.md</p>
|
||||||
|
<h2 id="objectif">Objectif</h2>
|
||||||
|
<p>Supprimer la logique de débouncing inutile sur le canal SSE de
|
||||||
|
l’application web PMOControl, puisque le serveur contrôle déjà le flux
|
||||||
|
des événements.</p>
|
||||||
|
<h2 id="analyse-préalable">Analyse préalable</h2>
|
||||||
|
<p>J’ai identifié trois endroits avec des mécanismes de temporisation
|
||||||
|
dans l’application web :</p>
|
||||||
|
<h3 id="mediabrowser.vue---débouncing-sse-à-supprimer">1.
|
||||||
|
MediaBrowser.vue - Débouncing SSE (À SUPPRIMER ✓)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Débouncing</strong>: 200ms après invalidation du cache</li>
|
||||||
|
<li><strong>Cooldown</strong>: 2 secondes entre les rechargements</li>
|
||||||
|
<li><strong>Justification originale</strong>: “dédupliquer les
|
||||||
|
événements SSE dans le même batch (polling 500ms)”</li>
|
||||||
|
<li><strong>Problème</strong>: Cette logique est redondante puisque le
|
||||||
|
serveur contrôle déjà le flux SSE</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="userenderers.ts---smart-fetching-à-conserver">2. useRenderers.ts
|
||||||
|
- Smart fetching (À CONSERVER ✓)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Mécanisme</strong>: Comparaison des timestamps
|
||||||
|
<code>lastEventAt</code> vs <code>lastSnapshotAt</code></li>
|
||||||
|
<li><strong>But</strong>: Éviter de refetch un snapshot déjà à jour</li>
|
||||||
|
<li><strong>Justification</strong>: Ce n’est PAS du débouncing, c’est
|
||||||
|
une optimisation intelligente qui évite des appels API inutiles</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="volumecontrol.vue---ui-debouncing-à-conserver">3.
|
||||||
|
VolumeControl.vue - UI debouncing (À CONSERVER ✓)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Débouncing</strong>: 300ms sur les changements de
|
||||||
|
volume</li>
|
||||||
|
<li><strong>But</strong>: Réduire les appels API pendant que
|
||||||
|
l’utilisateur fait glisser le curseur</li>
|
||||||
|
<li><strong>Justification</strong>: Débouncing légitime pour l’interface
|
||||||
|
utilisateur</li>
|
||||||
|
</ul>
|
||||||
|
<h2 id="modifications-effectuées">Modifications effectuées</h2>
|
||||||
|
<h3
|
||||||
|
id="fichier-modifié-pmoappwebappsrccomponentspmocontrolmediabrowser.vue">Fichier
|
||||||
|
modifié:
|
||||||
|
<code>pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue</code></h3>
|
||||||
|
<h4 id="suppression-des-variables-de-débouncing-ligne-27">1. Suppression
|
||||||
|
des variables de débouncing (ligne ~27)</h4>
|
||||||
|
<p><strong>Avant</strong>:</p>
|
||||||
|
<div class="sourceCode" id="cb1"><pre
|
||||||
|
class="sourceCode typescript"><code class="sourceCode typescript"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="co">// Flags pour gérer le rechargement automatique avec debounce et cooldown</span></span>
|
||||||
|
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> isRefreshing <span class="op">=</span> <span class="fu">ref</span>(<span class="kw">false</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> refreshTimeoutId <span class="op">=</span> <span class="fu">ref</span><span class="op"><</span><span class="dt">number</span> <span class="op">|</span> <span class="dt">null</span><span class="op">></span>(<span class="kw">null</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb1-4"><a href="#cb1-4" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> lastRefreshTime <span class="op">=</span> <span class="fu">ref</span><span class="op"><</span><span class="dt">number</span><span class="op">></span>(<span class="dv">0</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb1-5"><a href="#cb1-5" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> REFRESH_COOLDOWN_MS <span class="op">=</span> <span class="dv">2000</span><span class="op">;</span> <span class="co">// Ne pas recharger plus d'une fois toutes les 2 secondes</span></span></pre></div>
|
||||||
|
<p><strong>Après</strong>:</p>
|
||||||
|
<div class="sourceCode" id="cb2"><pre
|
||||||
|
class="sourceCode typescript"><code class="sourceCode typescript"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a><span class="co">// Flag pour gérer le rechargement automatique</span></span>
|
||||||
|
<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> isRefreshing <span class="op">=</span> <span class="fu">ref</span>(<span class="kw">false</span>)<span class="op">;</span></span></pre></div>
|
||||||
|
<h4 id="simplification-du-watcher-de-cache-ligne-53">2. Simplification
|
||||||
|
du watcher de cache (ligne ~53)</h4>
|
||||||
|
<p><strong>Avant</strong>:</p>
|
||||||
|
<div class="sourceCode" id="cb3"><pre
|
||||||
|
class="sourceCode typescript"><code class="sourceCode typescript"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="co">// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)</span></span>
|
||||||
|
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a><span class="co">// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,</span></span>
|
||||||
|
<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a><span class="co">// ce qui déclenche un événement ContainersUpdated qui invalide le cache</span></span>
|
||||||
|
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a><span class="co">// Utilise un debounce de 3 secondes pour regrouper les multiples invalidations</span></span>
|
||||||
|
<span id="cb3-5"><a href="#cb3-5" aria-hidden="true" tabindex="-1"></a><span class="co">// et un cooldown de 5 secondes pour éviter les rechargements successifs</span></span>
|
||||||
|
<span id="cb3-6"><a href="#cb3-6" aria-hidden="true" tabindex="-1"></a><span class="fu">watch</span>(</span>
|
||||||
|
<span id="cb3-7"><a href="#cb3-7" aria-hidden="true" tabindex="-1"></a> () <span class="kw">=></span> browseData<span class="op">.</span><span class="at">value</span><span class="op">,</span></span>
|
||||||
|
<span id="cb3-8"><a href="#cb3-8" aria-hidden="true" tabindex="-1"></a> (data) <span class="kw">=></span> {</span>
|
||||||
|
<span id="cb3-9"><a href="#cb3-9" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (<span class="op">!</span>data <span class="op">&&</span> props<span class="op">.</span><span class="at">containerId</span> <span class="op">&&</span> <span class="op">!</span>loading<span class="op">.</span><span class="at">value</span>) {</span>
|
||||||
|
<span id="cb3-10"><a href="#cb3-10" aria-hidden="true" tabindex="-1"></a> <span class="co">// Vérifier le cooldown: ignorer si on a rechargé il y a moins de 5 secondes</span></span>
|
||||||
|
<span id="cb3-11"><a href="#cb3-11" aria-hidden="true" tabindex="-1"></a> <span class="kw">const</span> timeSinceLastRefresh <span class="op">=</span> <span class="bu">Date</span><span class="op">.</span><span class="fu">now</span>() <span class="op">-</span> lastRefreshTime<span class="op">.</span><span class="at">value</span><span class="op">;</span></span>
|
||||||
|
<span id="cb3-12"><a href="#cb3-12" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (timeSinceLastRefresh <span class="op"><</span> REFRESH_COOLDOWN_MS) {</span>
|
||||||
|
<span id="cb3-13"><a href="#cb3-13" aria-hidden="true" tabindex="-1"></a> <span class="bu">console</span><span class="op">.</span><span class="fu">log</span>(</span>
|
||||||
|
<span id="cb3-14"><a href="#cb3-14" aria-hidden="true" tabindex="-1"></a> <span class="vs">`[MediaBrowser] Cache invalidé mais cooldown actif (</span><span class="sc">${</span><span class="bu">Math</span><span class="op">.</span><span class="fu">round</span>((REFRESH_COOLDOWN_MS <span class="op">-</span> timeSinceLastRefresh) <span class="op">/</span> <span class="dv">1000</span>)<span class="sc">}</span><span class="vs">s restantes), rechargement ignoré`</span><span class="op">,</span></span>
|
||||||
|
<span id="cb3-15"><a href="#cb3-15" aria-hidden="true" tabindex="-1"></a> )<span class="op">;</span></span>
|
||||||
|
<span id="cb3-16"><a href="#cb3-16" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span><span class="op">;</span></span>
|
||||||
|
<span id="cb3-17"><a href="#cb3-17" aria-hidden="true" tabindex="-1"></a> }</span>
|
||||||
|
<span id="cb3-18"><a href="#cb3-18" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb3-19"><a href="#cb3-19" aria-hidden="true" tabindex="-1"></a> <span class="co">// Annuler tout timeout en cours</span></span>
|
||||||
|
<span id="cb3-20"><a href="#cb3-20" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (refreshTimeoutId<span class="op">.</span><span class="at">value</span> <span class="op">!==</span> <span class="kw">null</span>) {</span>
|
||||||
|
<span id="cb3-21"><a href="#cb3-21" aria-hidden="true" tabindex="-1"></a> <span class="pp">clearTimeout</span>(refreshTimeoutId<span class="op">.</span><span class="at">value</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb3-22"><a href="#cb3-22" aria-hidden="true" tabindex="-1"></a> }</span>
|
||||||
|
<span id="cb3-23"><a href="#cb3-23" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb3-24"><a href="#cb3-24" aria-hidden="true" tabindex="-1"></a> <span class="co">// Planifier le rechargement après 200ms</span></span>
|
||||||
|
<span id="cb3-25"><a href="#cb3-25" aria-hidden="true" tabindex="-1"></a> refreshTimeoutId<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="bu">window</span><span class="op">.</span><span class="fu">setTimeout</span>(<span class="kw">async</span> () <span class="kw">=></span> {</span>
|
||||||
|
<span id="cb3-26"><a href="#cb3-26" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (<span class="op">!</span>isRefreshing<span class="op">.</span><span class="at">value</span>) {</span>
|
||||||
|
<span id="cb3-27"><a href="#cb3-27" aria-hidden="true" tabindex="-1"></a> <span class="bu">console</span><span class="op">.</span><span class="fu">log</span>(</span>
|
||||||
|
<span id="cb3-28"><a href="#cb3-28" aria-hidden="true" tabindex="-1"></a> <span class="vs">`[MediaBrowser] Cache invalidé pour </span><span class="sc">${</span>props<span class="op">.</span><span class="at">serverId</span><span class="sc">}</span><span class="vs">/</span><span class="sc">${</span>props<span class="op">.</span><span class="at">containerId</span><span class="sc">}</span><span class="vs">, rechargement après debounce...`</span><span class="op">,</span></span>
|
||||||
|
<span id="cb3-29"><a href="#cb3-29" aria-hidden="true" tabindex="-1"></a> )<span class="op">;</span></span>
|
||||||
|
<span id="cb3-30"><a href="#cb3-30" aria-hidden="true" tabindex="-1"></a> isRefreshing<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">true</span><span class="op">;</span></span>
|
||||||
|
<span id="cb3-31"><a href="#cb3-31" aria-hidden="true" tabindex="-1"></a> <span class="cf">await</span> <span class="fu">browseContainer</span>(</span>
|
||||||
|
<span id="cb3-32"><a href="#cb3-32" aria-hidden="true" tabindex="-1"></a> props<span class="op">.</span><span class="at">serverId</span><span class="op">,</span></span>
|
||||||
|
<span id="cb3-33"><a href="#cb3-33" aria-hidden="true" tabindex="-1"></a> props<span class="op">.</span><span class="at">containerId</span><span class="op">,</span></span>
|
||||||
|
<span id="cb3-34"><a href="#cb3-34" aria-hidden="true" tabindex="-1"></a> <span class="kw">false</span><span class="op">,</span></span>
|
||||||
|
<span id="cb3-35"><a href="#cb3-35" aria-hidden="true" tabindex="-1"></a> )<span class="op">;</span></span>
|
||||||
|
<span id="cb3-36"><a href="#cb3-36" aria-hidden="true" tabindex="-1"></a> lastRefreshTime<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="bu">Date</span><span class="op">.</span><span class="fu">now</span>()<span class="op">;</span></span>
|
||||||
|
<span id="cb3-37"><a href="#cb3-37" aria-hidden="true" tabindex="-1"></a> isRefreshing<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">false</span><span class="op">;</span></span>
|
||||||
|
<span id="cb3-38"><a href="#cb3-38" aria-hidden="true" tabindex="-1"></a> refreshTimeoutId<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">null</span><span class="op">;</span></span>
|
||||||
|
<span id="cb3-39"><a href="#cb3-39" aria-hidden="true" tabindex="-1"></a> }</span>
|
||||||
|
<span id="cb3-40"><a href="#cb3-40" aria-hidden="true" tabindex="-1"></a> }<span class="op">,</span> <span class="dv">200</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb3-41"><a href="#cb3-41" aria-hidden="true" tabindex="-1"></a> }</span>
|
||||||
|
<span id="cb3-42"><a href="#cb3-42" aria-hidden="true" tabindex="-1"></a> }<span class="op">,</span></span>
|
||||||
|
<span id="cb3-43"><a href="#cb3-43" aria-hidden="true" tabindex="-1"></a>)<span class="op">;</span></span></pre></div>
|
||||||
|
<p><strong>Après</strong>:</p>
|
||||||
|
<div class="sourceCode" id="cb4"><pre
|
||||||
|
class="sourceCode typescript"><code class="sourceCode typescript"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="co">// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)</span></span>
|
||||||
|
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a><span class="co">// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,</span></span>
|
||||||
|
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a><span class="co">// ce qui déclenche un événement ContainersUpdated qui invalide le cache</span></span>
|
||||||
|
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a><span class="co">// Le serveur contrôle déjà le flux SSE, pas besoin de debouncing côté client</span></span>
|
||||||
|
<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a><span class="fu">watch</span>(</span>
|
||||||
|
<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a> () <span class="kw">=></span> browseData<span class="op">.</span><span class="at">value</span><span class="op">,</span></span>
|
||||||
|
<span id="cb4-7"><a href="#cb4-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> (data) <span class="kw">=></span> {</span>
|
||||||
|
<span id="cb4-8"><a href="#cb4-8" aria-hidden="true" tabindex="-1"></a> <span class="co">// Si browseData devient undefined alors que containerId est présent,</span></span>
|
||||||
|
<span id="cb4-9"><a href="#cb4-9" aria-hidden="true" tabindex="-1"></a> <span class="co">// et qu'on n'est pas déjà en train de charger, recharger immédiatement</span></span>
|
||||||
|
<span id="cb4-10"><a href="#cb4-10" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (<span class="op">!</span>data <span class="op">&&</span> props<span class="op">.</span><span class="at">containerId</span> <span class="op">&&</span> <span class="op">!</span>loading<span class="op">.</span><span class="at">value</span> <span class="op">&&</span> <span class="op">!</span>isRefreshing<span class="op">.</span><span class="at">value</span>) {</span>
|
||||||
|
<span id="cb4-11"><a href="#cb4-11" aria-hidden="true" tabindex="-1"></a> <span class="bu">console</span><span class="op">.</span><span class="fu">log</span>(</span>
|
||||||
|
<span id="cb4-12"><a href="#cb4-12" aria-hidden="true" tabindex="-1"></a> <span class="vs">`[MediaBrowser] Cache invalidé pour </span><span class="sc">${</span>props<span class="op">.</span><span class="at">serverId</span><span class="sc">}</span><span class="vs">/</span><span class="sc">${</span>props<span class="op">.</span><span class="at">containerId</span><span class="sc">}</span><span class="vs">, rechargement...`</span><span class="op">,</span></span>
|
||||||
|
<span id="cb4-13"><a href="#cb4-13" aria-hidden="true" tabindex="-1"></a> )<span class="op">;</span></span>
|
||||||
|
<span id="cb4-14"><a href="#cb4-14" aria-hidden="true" tabindex="-1"></a> isRefreshing<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">true</span><span class="op">;</span></span>
|
||||||
|
<span id="cb4-15"><a href="#cb4-15" aria-hidden="true" tabindex="-1"></a> <span class="cf">await</span> <span class="fu">browseContainer</span>(props<span class="op">.</span><span class="at">serverId</span><span class="op">,</span> props<span class="op">.</span><span class="at">containerId</span><span class="op">,</span> <span class="kw">false</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb4-16"><a href="#cb4-16" aria-hidden="true" tabindex="-1"></a> isRefreshing<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">false</span><span class="op">;</span></span>
|
||||||
|
<span id="cb4-17"><a href="#cb4-17" aria-hidden="true" tabindex="-1"></a> }</span>
|
||||||
|
<span id="cb4-18"><a href="#cb4-18" aria-hidden="true" tabindex="-1"></a> }<span class="op">,</span></span>
|
||||||
|
<span id="cb4-19"><a href="#cb4-19" aria-hidden="true" tabindex="-1"></a>)<span class="op">;</span></span></pre></div>
|
||||||
|
<h2 id="résultats">Résultats</h2>
|
||||||
|
<h3 id="changements-de-comportement">Changements de comportement</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Avant</strong>: Délai de 200ms + cooldown de 2s entre les
|
||||||
|
rechargements de cache</li>
|
||||||
|
<li><strong>Après</strong>: Rechargement immédiat dès l’invalidation du
|
||||||
|
cache</li>
|
||||||
|
<li><strong>Impact</strong>: Réactivité améliorée de l’interface, les
|
||||||
|
mises à jour apparaissent immédiatement</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="réduction-de-complexité">Réduction de complexité</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>3 variables supprimées</strong>:
|
||||||
|
<code>refreshTimeoutId</code>, <code>lastRefreshTime</code>,
|
||||||
|
<code>REFRESH_COOLDOWN_MS</code></li>
|
||||||
|
<li><strong>Logique simplifiée</strong>: De ~40 lignes à ~10 lignes dans
|
||||||
|
le watcher</li>
|
||||||
|
<li><strong>Code plus lisible</strong>: Intention claire sans mécanismes
|
||||||
|
de temporisation complexes</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="tests">Tests</h3>
|
||||||
|
<ul>
|
||||||
|
<li>✓ Le projet compile sans erreurs TypeScript</li>
|
||||||
|
<li>✓ Le flag <code>isRefreshing</code> empêche toujours les
|
||||||
|
rechargements concurrents</li>
|
||||||
|
<li>✓ Les autres composants (useRenderers.ts, VolumeControl.vue)
|
||||||
|
conservent leurs optimisations légitimes</li>
|
||||||
|
</ul>
|
||||||
|
<h2 id="conclusion">Conclusion</h2>
|
||||||
|
<p>La suppression du débouncing et du cooldown dans MediaBrowser.vue
|
||||||
|
simplifie le code tout en améliorant la réactivité de l’interface.
|
||||||
|
Puisque le serveur contrôle déjà le flux SSE, ces mécanismes côté client
|
||||||
|
étaient redondants et ajoutaient une latence artificielle.</p>
|
||||||
|
<p>Le code est maintenant plus simple, plus réactif, et fait confiance
|
||||||
|
au serveur pour contrôler la fréquence des événements SSE.</p>
|
||||||
|
<h2 id="fichiers-modifiés">Fichiers modifiés</h2>
|
||||||
|
<ul>
|
||||||
|
<li><code>pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue</code></li>
|
||||||
|
</ul>
|
||||||
|
<h2 id="lignes-de-code">Lignes de code</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Supprimées</strong>: ~35 lignes (logique de
|
||||||
|
débouncing/cooldown)</li>
|
||||||
|
<li><strong>Ajoutées</strong>: ~5 lignes (logique simplifiée)</li>
|
||||||
|
<li><strong>Net</strong>: -30 lignes</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
152
Blackboard_HTML/Report_config_ext.html
Normal file
152
Blackboard_HTML/Report_config_ext.html
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>config_ext</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
||||||
|
<script type="module">
|
||||||
|
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||||
|
mermaid.initialize({startOnLoad: true, theme: "default"});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
pre.mermaid {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="markdown-body">
|
||||||
|
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
|
||||||
|
<h1 id="rapport-documentation-du-pattern-dextension-pmoconfig">Rapport :
|
||||||
|
Documentation du pattern d’extension pmoconfig</h1>
|
||||||
|
<h2 id="objectif-de-la-tâche">Objectif de la tâche</h2>
|
||||||
|
<p>Créer une fiche descriptive documentant le pattern d’implémentation
|
||||||
|
des traits d’extension de <code>pmoconfig::Config</code> en analysant
|
||||||
|
les implémentations existantes dans les différents crates du projet.</p>
|
||||||
|
<h2 id="travail-réalisé">Travail réalisé</h2>
|
||||||
|
<h3 id="analyse-des-fichiers-source">1. Analyse des fichiers source</h3>
|
||||||
|
<p>Les fichiers suivants ont été analysés :</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>pmocovers/src/config_ext.rs</code> - Pattern cache avec
|
||||||
|
conversion WebP</li>
|
||||||
|
<li><code>pmoaudiocache/src/config_ext.rs</code> - Pattern cache avec
|
||||||
|
conversion FLAC</li>
|
||||||
|
<li><code>pmoqobuz/src/config_ext.rs</code> - Pattern authentification
|
||||||
|
et rate limiting</li>
|
||||||
|
<li><code>pmocache/src/config_ext.rs</code> - Trait générique de cache
|
||||||
|
et macro</li>
|
||||||
|
<li><code>pmoconfig/PASSWORD_ENCRYPTION.md</code> - Documentation du
|
||||||
|
chiffrement</li>
|
||||||
|
<li><code>pmoupnp/src/config_ext.rs</code> - Pattern configuration
|
||||||
|
UPnP</li>
|
||||||
|
<li><code>pmoparadise/src/config_ext.rs</code> - Pattern configuration
|
||||||
|
minimale</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="patterns-identifiés">2. Patterns identifiés</h3>
|
||||||
|
<h4 id="pattern-de-base">Pattern de base</h4>
|
||||||
|
<p>Tous les traits d’extension suivent la même structure : - Trait
|
||||||
|
public avec méthodes getter/setter - Implémentation pour
|
||||||
|
<code>pmoconfig::Config</code> - Utilisation de
|
||||||
|
<code>get_value</code>/<code>set_value</code> génériques - Constantes
|
||||||
|
pour valeurs par défaut</p>
|
||||||
|
<h4 id="patterns-spécialisés">Patterns spécialisés</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Cache</strong> : Utilisation de <code>CacheConfigExt</code>
|
||||||
|
et factory methods</li>
|
||||||
|
<li><strong>Authentification</strong> : Getters combinés, helpers de
|
||||||
|
validation, déchiffrement automatique</li>
|
||||||
|
<li><strong>Rate limiting</strong> : Configuration des limites avec
|
||||||
|
valeurs par défaut</li>
|
||||||
|
<li><strong>Configuration minimale</strong> : Auto-persistence des
|
||||||
|
valeurs par défaut</li>
|
||||||
|
<li><strong>UPnP</strong> : Configuration des identifiants devices</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="structure-de-la-documentation">3. Structure de la
|
||||||
|
documentation</h3>
|
||||||
|
<p>La documentation créée couvre :</p>
|
||||||
|
<ol type="1">
|
||||||
|
<li><strong>Vue d’ensemble</strong> : Objectif et principe du
|
||||||
|
pattern</li>
|
||||||
|
<li><strong>Architecture</strong> : Structure et flux de données</li>
|
||||||
|
<li><strong>Implémentation</strong> : Guide détaillé avec patterns de
|
||||||
|
code</li>
|
||||||
|
<li><strong>Patterns spécialisés</strong> : Exemples pour chaque cas
|
||||||
|
d’usage</li>
|
||||||
|
<li><strong>Bonnes pratiques</strong> : Nommage, erreurs,
|
||||||
|
documentation</li>
|
||||||
|
<li><strong>Exemples complets</strong> : 3 implémentations complètes
|
||||||
|
commentées</li>
|
||||||
|
<li><strong>Checklist</strong> : Liste de vérification pour nouveaux
|
||||||
|
traits</li>
|
||||||
|
<li><strong>Philosophie</strong> : Principes directeurs et
|
||||||
|
avantages</li>
|
||||||
|
</ol>
|
||||||
|
<h3 id="contenu-clé">4. Contenu clé</h3>
|
||||||
|
<h4 id="patterns-de-getters">Patterns de getters</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Getter simple avec valeur par défaut</li>
|
||||||
|
<li>Getter avec auto-persistence</li>
|
||||||
|
<li>Getter optionnel</li>
|
||||||
|
<li>Getter avec déchiffrement</li>
|
||||||
|
<li>Getter avec parsing et fallback</li>
|
||||||
|
</ul>
|
||||||
|
<h4 id="patterns-de-setters">Patterns de setters</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Setter simple</li>
|
||||||
|
<li>Setter avec transformation</li>
|
||||||
|
<li>Setter multiple (transaction)</li>
|
||||||
|
<li>Setter de nettoyage</li>
|
||||||
|
</ul>
|
||||||
|
<h4 id="helpers">Helpers</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Factory methods</li>
|
||||||
|
<li>Getters combinés</li>
|
||||||
|
<li>Helpers de validation</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="hiérarchie-de-configuration-yaml">5. Hiérarchie de configuration
|
||||||
|
YAML</h3>
|
||||||
|
<p>Documentation des chemins standards : - <code>host.*</code> :
|
||||||
|
Configuration hôte/système - <code>accounts.*</code> : Comptes et
|
||||||
|
services - <code>sources.*</code> : Sources de médias</p>
|
||||||
|
<h2 id="résultat">Résultat</h2>
|
||||||
|
<p>Le document <code>Blackboard/Architecture/pmoconfig_ext.md</code> a
|
||||||
|
été créé avec : - 800+ lignes de documentation complète - 3 exemples
|
||||||
|
d’implémentation complète - Patterns pour tous les cas d’usage
|
||||||
|
identifiés - Bonnes pratiques et anti-patterns - Checklist
|
||||||
|
d’implémentation</p>
|
||||||
|
<h2 id="fichiers-créés-ou-modifiés">Fichiers créés ou modifiés</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Créé</strong> :
|
||||||
|
<code>Blackboard/Architecture/pmoconfig_ext.md</code> - Documentation
|
||||||
|
complète du pattern</li>
|
||||||
|
<li><strong>Créé</strong> : <code>Blackboard/Report/config_ext.md</code>
|
||||||
|
- Ce rapport</li>
|
||||||
|
</ul>
|
||||||
|
<h2 id="conformité-avec-rules.md">Conformité avec Rules.md</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Documentation placée dans <code>Blackboard/Architecture/</code>
|
||||||
|
comme demandé</li>
|
||||||
|
<li>Rapport créé dans <code>Blackboard/Report/</code> avec le même nom
|
||||||
|
de fichier</li>
|
||||||
|
<li>Analyse focalisée sur l’objectif principal</li>
|
||||||
|
<li>Documentation prête pour classification (Done/ToDiscuss) par
|
||||||
|
l’humain</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
238
Blackboard_HTML/Report_music_source.html
Normal file
238
Blackboard_HTML/Report_music_source.html
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>music_source</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
||||||
|
<script type="module">
|
||||||
|
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||||
|
mermaid.initialize({startOnLoad: true, theme: "default"});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
pre.mermaid {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="markdown-body">
|
||||||
|
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
|
||||||
|
<h1
|
||||||
|
id="rapport-documentation-dimplémentation-dune-nouvelle-musicsource">Rapport
|
||||||
|
: Documentation d’implémentation d’une nouvelle MusicSource</h1>
|
||||||
|
<h2 id="objectif">Objectif</h2>
|
||||||
|
<p>Créer une documentation complète et pratique pour guider
|
||||||
|
l’implémentation d’une nouvelle source musicale dans l’écosystème
|
||||||
|
PMOMusic.</p>
|
||||||
|
<h2 id="travail-réalisé">Travail réalisé</h2>
|
||||||
|
<h3 id="analyse-des-sources-existantes">1. Analyse des sources
|
||||||
|
existantes</h3>
|
||||||
|
<p>J’ai analysé deux implémentations de référence :</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>pmoparadise/src/source.rs</strong> : Source dynamique avec
|
||||||
|
FIFO (radio streaming)</li>
|
||||||
|
<li><strong>pmoqobuz/src/source.rs</strong> : Source catalogue avec
|
||||||
|
playlists lazy</li>
|
||||||
|
</ul>
|
||||||
|
<p>Ainsi que la documentation du trait :</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>pmosource/README.md</strong> : Vue d’ensemble du trait
|
||||||
|
MusicSource</li>
|
||||||
|
<li><strong>pmosource/ARCHITECTURE.md</strong> : Architecture et design
|
||||||
|
decisions</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="identification-des-patterns-principaux">2. Identification des
|
||||||
|
patterns principaux</h3>
|
||||||
|
<p>Deux patterns majeurs ont été identifiés :</p>
|
||||||
|
<h4 id="pattern-1-source-dynamique-fifo-radio-paradise">Pattern 1 :
|
||||||
|
Source dynamique FIFO (Radio Paradise)</h4>
|
||||||
|
<p><strong>Caractéristiques :</strong> - Flux continu de tracks avec
|
||||||
|
capacité limitée - Suppression automatique des plus anciens - Callbacks
|
||||||
|
sur playlists pour détecter les changements - Notification du
|
||||||
|
ContentDirectory via notifier injecté - Adaptation des IDs playlist →
|
||||||
|
schema source</p>
|
||||||
|
<p><strong>Éléments clés :</strong></p>
|
||||||
|
<div class="sourceCode" id="cb1"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a>update_counter<span class="op">:</span> Arc<span class="op"><</span>RwLock<span class="op"><</span><span class="dt">u32</span><span class="op">>></span></span>
|
||||||
|
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a>last_change<span class="op">:</span> Arc<span class="op"><</span>RwLock<span class="op"><</span>SystemTime<span class="op">>></span></span>
|
||||||
|
<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a>callback_tokens<span class="op">:</span> Arc<span class="op"><</span>Mutex<span class="op"><</span><span class="dt">Vec</span><span class="op"><</span><span class="dt">u64</span><span class="op">>>></span></span>
|
||||||
|
<span id="cb1-4"><a href="#cb1-4" aria-hidden="true" tabindex="-1"></a>container_notifier<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span>Arc<span class="op"><</span><span class="kw">dyn</span> <span class="bu">Fn</span>(<span class="op">&</span>[<span class="dt">String</span>]) <span class="op">+</span> <span class="bu">Send</span> <span class="op">+</span> <span class="bu">Sync</span><span class="op">>></span></span></pre></div>
|
||||||
|
<h4 id="pattern-2-source-catalogue-lazy-qobuz">Pattern 2 : Source
|
||||||
|
catalogue lazy (Qobuz)</h4>
|
||||||
|
<p><strong>Caractéristiques :</strong> - Catalogue vaste avec navigation
|
||||||
|
hiérarchique - Cache lazy pour audio, eager pour covers - Playlists
|
||||||
|
créées à la demande avec TTL - LazyProvider pour télécharger l’audio à
|
||||||
|
la lecture - Métadonnées riches stockées dans le cache</p>
|
||||||
|
<p><strong>Éléments clés :</strong></p>
|
||||||
|
<div class="sourceCode" id="cb2"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a>SourceCacheManager centralisé</span>
|
||||||
|
<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a>QobuzLazyProvider implémentant LazyProvider</span>
|
||||||
|
<span id="cb2-3"><a href="#cb2-3" aria-hidden="true" tabindex="-1"></a>Playlists avec rôle Album et TTL de <span class="dv">7</span> jours</span>
|
||||||
|
<span id="cb2-4"><a href="#cb2-4" aria-hidden="true" tabindex="-1"></a>Adaptation IDs avec metadata source_track_id</span></pre></div>
|
||||||
|
<h3 id="structure-du-document-créé">3. Structure du document créé</h3>
|
||||||
|
<p>Le document <code>Blackboard/Architecture/music_source.md</code>
|
||||||
|
contient :</p>
|
||||||
|
<h4 id="table-des-matières">Table des matières</h4>
|
||||||
|
<ol type="1">
|
||||||
|
<li>Vue d’ensemble</li>
|
||||||
|
<li>Structure d’une MusicSource</li>
|
||||||
|
<li>Implémentation du trait MusicSource</li>
|
||||||
|
<li>Patterns d’implémentation</li>
|
||||||
|
<li>Intégration avec l’écosystème PMOMusic</li>
|
||||||
|
<li>Checklist de mise en œuvre</li>
|
||||||
|
<li>Exemples de référence</li>
|
||||||
|
</ol>
|
||||||
|
<h4 id="sections-détaillées">Sections détaillées</h4>
|
||||||
|
<p><strong>Section 1 : Vue d’ensemble</strong> - Définition d’une
|
||||||
|
MusicSource - Types de sources (dynamique vs statique) - Capacités du
|
||||||
|
trait</p>
|
||||||
|
<p><strong>Section 2 : Structure</strong> - Organisation du code -
|
||||||
|
Dépendances recommandées - Features Cargo</p>
|
||||||
|
<p><strong>Section 3 : Implémentation du trait</strong> - Informations
|
||||||
|
de base (name, id, default_image) - Navigation ContentDirectory
|
||||||
|
(root_container, browse, resolve_uri) - Support FIFO (append_track,
|
||||||
|
remove_oldest, update_id) - Support statique (get_items, search)</p>
|
||||||
|
<p><strong>Section 4 : Patterns</strong> - Pattern 1 : Source dynamique
|
||||||
|
avec FIFO (code complet) - Pattern 2 : Source catalogue avec playlists
|
||||||
|
lazy (code complet) - Pattern 3 : Adaptation des IDs entre playlist et
|
||||||
|
source</p>
|
||||||
|
<p><strong>Section 5 : Intégration écosystème</strong> - pmoplaylist :
|
||||||
|
création et gestion de playlists - pmoaudiocache/pmocovers via
|
||||||
|
SourceCacheManager - pmodidl : conversion vers DIDL-Lite - LazyProvider
|
||||||
|
personnalisé</p>
|
||||||
|
<p><strong>Section 6 : Checklist</strong> - Phase 1 : Structure de base
|
||||||
|
- Phase 2 : Navigation ContentDirectory - Phase 3 : Résolution d’URI -
|
||||||
|
Phase 4 : Support FIFO (si dynamique) - Phase 5 : Support statique (si
|
||||||
|
catalogue) - Phase 6 : Intégration avancée - Phase 7 : Tests et
|
||||||
|
validation</p>
|
||||||
|
<p><strong>Section 7 : Exemples de référence</strong> - Radio Paradise
|
||||||
|
(source dynamique FIFO) - Qobuz (source catalogue lazy) - Schemas
|
||||||
|
d’Object ID détaillés</p>
|
||||||
|
<h3 id="points-techniques-importants-documentés">4. Points techniques
|
||||||
|
importants documentés</h3>
|
||||||
|
<h4 id="schema-dobject-id">Schema d’Object ID</h4>
|
||||||
|
<p>Format recommandé hiérarchique :</p>
|
||||||
|
<pre><code><source-id>
|
||||||
|
<source-id>:albums
|
||||||
|
<source-id>:album:<album_id>
|
||||||
|
<source-id>:track:<track_id>
|
||||||
|
<source-id>:playlist:<playlist_id></pre>
|
||||||
|
<p>Exemples concrets de Radio Paradise et Qobuz fournis.</p>
|
||||||
|
<h4 id="adaptation-des-ids">Adaptation des IDs</h4>
|
||||||
|
<p>Code complet pour adapter les items de playlist au schema de la
|
||||||
|
source : - Extraction du cache_pk depuis l’URL - Récupération du
|
||||||
|
source_track_id depuis metadata - Reconstruction de l’ID correct -
|
||||||
|
Normalisation des URLs (relatives → absolues) - Ajout de champs requis
|
||||||
|
(genre)</p>
|
||||||
|
<h4 id="cache-lazy-vs-eager">Cache lazy vs eager</h4>
|
||||||
|
<p>Stratégie claire : - <strong>Covers</strong> : Cache eager (petit, UI
|
||||||
|
en a besoin immédiatement) - <strong>Audio</strong> : Cache lazy (grand,
|
||||||
|
téléchargé à la demande)</p>
|
||||||
|
<h4 id="thread-safety">Thread Safety</h4>
|
||||||
|
<p>Règles explicites : - <code>Arc<RwLock<>></code> pour
|
||||||
|
état mutable partagé - <code>tokio::sync::RwLock</code> pour async -
|
||||||
|
Éviter <code>Rc<></code>, <code>RefCell</code> (non thread-safe) -
|
||||||
|
Implémenter <code>Clone</code> via <code>Arc<></code></p>
|
||||||
|
<h4 id="compatibilité-upnp">Compatibilité UPnP</h4>
|
||||||
|
<p>Points de vigilance : - Genre obligatoire pour certains clients
|
||||||
|
(gupnp-av-cp) - URLs absolues uniquement - Protocol Info correct pour
|
||||||
|
FLAC - Duration au format <code>H:MM:SS</code> - childCount optionnel
|
||||||
|
mais recommandé</p>
|
||||||
|
<h3 id="code-dexemple-complet">5. Code d’exemple complet</h3>
|
||||||
|
<p>Le document contient des exemples de code complets et fonctionnels
|
||||||
|
pour :</p>
|
||||||
|
<ol type="1">
|
||||||
|
<li><strong>Structure de base</strong> : définition de la struct et
|
||||||
|
implémentation basique</li>
|
||||||
|
<li><strong>Navigation</strong> : root_container et browse avec pattern
|
||||||
|
matching</li>
|
||||||
|
<li><strong>Résolution URI</strong> : avec fallback cache →
|
||||||
|
original</li>
|
||||||
|
<li><strong>FIFO</strong> : append_track, remove_oldest, callbacks</li>
|
||||||
|
<li><strong>Adaptation IDs</strong> : fonction complète
|
||||||
|
d’adaptation</li>
|
||||||
|
<li><strong>LazyProvider</strong> : implémentation personnalisée</li>
|
||||||
|
<li><strong>Conversion DIDL</strong> : traits ToDIDLContainer et
|
||||||
|
ToDIDLItem</li>
|
||||||
|
</ol>
|
||||||
|
<h2 id="couverture-des-besoins">Couverture des besoins</h2>
|
||||||
|
<h3 id="sources-couvertes">Sources couvertes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>✅ Radio Paradise : source dynamique FIFO</li>
|
||||||
|
<li>✅ Qobuz : source catalogue lazy</li>
|
||||||
|
<li>✅ Patterns génériques applicables à d’autres sources</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="cas-dusage-couverts">Cas d’usage couverts</h3>
|
||||||
|
<ul>
|
||||||
|
<li>✅ Source radio/streaming live</li>
|
||||||
|
<li>✅ Source catalogue de streaming (Spotify, Deezer, etc.)</li>
|
||||||
|
<li>✅ Source bibliothèque locale</li>
|
||||||
|
<li>✅ Source playlists fixes</li>
|
||||||
|
<li>✅ Source avec authentification (via client)</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="intégrations-couvertes">Intégrations couvertes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>✅ pmoplaylist (FIFO et persistant)</li>
|
||||||
|
<li>✅ pmoaudiocache (cache audio)</li>
|
||||||
|
<li>✅ pmocovers (cache covers)</li>
|
||||||
|
<li>✅ SourceCacheManager (centralisé)</li>
|
||||||
|
<li>✅ LazyProvider (téléchargement lazy)</li>
|
||||||
|
<li>✅ pmodidl (DIDL-Lite)</li>
|
||||||
|
</ul>
|
||||||
|
<h2 id="limitations-et-améliorations-futures">Limitations et
|
||||||
|
améliorations futures</h2>
|
||||||
|
<h3 id="limitations-actuelles">Limitations actuelles</h3>
|
||||||
|
<ol type="1">
|
||||||
|
<li><strong>Search</strong> : Pas d’exemple détaillé de search
|
||||||
|
(optionnel dans le trait)</li>
|
||||||
|
<li><strong>Authentification</strong> : Mentionné mais pas d’exemple
|
||||||
|
complet</li>
|
||||||
|
<li><strong>Multi-format</strong> : Pas d’exemple de source supportant
|
||||||
|
plusieurs formats</li>
|
||||||
|
<li><strong>Offline</strong> : Pas de pattern pour source
|
||||||
|
offline/synchronisation</li>
|
||||||
|
</ol>
|
||||||
|
<h3 id="améliorations-possibles">Améliorations possibles</h3>
|
||||||
|
<ol type="1">
|
||||||
|
<li>Ajouter un exemple complet de search avec filtres</li>
|
||||||
|
<li>Documenter l’intégration avec un système d’auth OAuth</li>
|
||||||
|
<li>Ajouter un pattern pour sources multi-formats (FLAC/MP3/AAC)</li>
|
||||||
|
<li>Documenter la gestion offline avec synchronisation</li>
|
||||||
|
</ol>
|
||||||
|
<h2 id="fichiers-créés">Fichiers créés</h2>
|
||||||
|
<ul>
|
||||||
|
<li><code>Blackboard/Architecture/music_source.md</code> : Documentation
|
||||||
|
complète (15 sections, ~800 lignes)</li>
|
||||||
|
</ul>
|
||||||
|
<h2 id="conclusion">Conclusion</h2>
|
||||||
|
<p>Le document créé fournit un guide complet et pratique pour
|
||||||
|
implémenter une nouvelle MusicSource. Il combine :</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Théorie</strong> : Architecture, design patterns,
|
||||||
|
principes</li>
|
||||||
|
<li><strong>Pratique</strong> : Code complet, exemples réels,
|
||||||
|
checklist</li>
|
||||||
|
<li><strong>Référence</strong> : Schemas d’Object ID, intégrations,
|
||||||
|
compatibilité</li>
|
||||||
|
</ul>
|
||||||
|
<p>Un développeur peut suivre ce guide étape par étape pour créer une
|
||||||
|
nouvelle source musicale compatible avec l’écosystème PMOMusic, en
|
||||||
|
s’inspirant des patterns éprouvés de Radio Paradise et Qobuz.</p>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
120
Blackboard_HTML/Report_pmoserver_ext.html
Normal file
120
Blackboard_HTML/Report_pmoserver_ext.html
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>pmoserver_ext</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
||||||
|
<script type="module">
|
||||||
|
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||||
|
mermaid.initialize({startOnLoad: true, theme: "default"});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
pre.mermaid {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="markdown-body">
|
||||||
|
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
|
||||||
|
<h1 id="rapport-documentation-du-pattern-pmoserver_ext">Rapport :
|
||||||
|
Documentation du pattern pmoserver_ext</h1>
|
||||||
|
<h2 id="contexte">Contexte</h2>
|
||||||
|
<p>Documentation du pattern d’extension du PMOServer à travers plusieurs
|
||||||
|
itérations basées sur les retours utilisateur.</p>
|
||||||
|
<h2 id="travail-réalisé">Travail réalisé</h2>
|
||||||
|
<h3 id="analyse-des-fichiers-sources">Analyse des fichiers sources</h3>
|
||||||
|
<p>Les fichiers suivants ont été analysés pour extraire le pattern :</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>pmoapp/src/lib.rs</code> : Pattern SPA avec RustEmbed</li>
|
||||||
|
<li><code>pmocontrol/src/pmoserver_ext.rs</code> : API REST avec Control
|
||||||
|
Point (1506+ lignes)</li>
|
||||||
|
<li><code>pmoparadise/src/pmoserver_ext.rs</code> : API REST simple avec
|
||||||
|
client externe</li>
|
||||||
|
<li><code>pmoaudiocache/src/lib.rs</code> : Extension avec cache et
|
||||||
|
fichiers</li>
|
||||||
|
<li><code>pmomediaserver/src/paradise_streaming.rs</code> : Extension
|
||||||
|
complexe avec streaming</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="round-1-document-initial">Round 1 : Document initial</h3>
|
||||||
|
<p>Premier jet documentant exhaustivement tous les aspects des
|
||||||
|
extensions (~850 lignes).</p>
|
||||||
|
<h3 id="round-2-recentrage-sur-le-pattern">Round 2 : Recentrage sur le
|
||||||
|
pattern</h3>
|
||||||
|
<p><strong>Annotation</strong> : “se recentrer sur le sujet
|
||||||
|
principal”</p>
|
||||||
|
<p><strong>Actions</strong> : - Réduction de ~850 à ~400 lignes -
|
||||||
|
Suppression des digressions (OpenAPI détaillé, handlers spécifiques) -
|
||||||
|
Focus sur l’anatomie du pattern en 5 étapes - Ajout d’une checklist et
|
||||||
|
d’un exemple minimal</p>
|
||||||
|
<p><strong>Résultat</strong> : Document focalisé sur l’implémentation du
|
||||||
|
pattern uniquement.</p>
|
||||||
|
<h3 id="round-3-réintégration-openapi">Round 3 : Réintégration
|
||||||
|
OpenAPI</h3>
|
||||||
|
<p><strong>Annotation</strong> : “Je trouve que le fait de devoir
|
||||||
|
déclarer et documenter les URL dans OpenAPI / utopia était quelque chose
|
||||||
|
d’important. Remets le.”</p>
|
||||||
|
<p><strong>Actions</strong> : - Ajout d’une section complète
|
||||||
|
“Documentation OpenAPI avec utoipa” (~260 lignes) - 5 sous-sections
|
||||||
|
détaillées : 1. Configuration de base (dépendances Cargo) 2. Définition
|
||||||
|
des schémas avec <code>#[derive(ToSchema)]</code> 3. Annotation des
|
||||||
|
handlers avec <code>#[utoipa::path]</code> 4. Création de la structure
|
||||||
|
<code>#[derive(OpenApi)]</code> 5. Exemple complet extrait de Radio
|
||||||
|
Paradise - Mise à jour de la checklist avec section “Documentation
|
||||||
|
OpenAPI” - Ajout des dépendances <code>utoipa</code> et
|
||||||
|
<code>serde</code> dans la section références</p>
|
||||||
|
<p><strong>Positionnement</strong> : Section insérée après “Méthodes
|
||||||
|
disponibles du serveur” et avant “Patterns courants”, car elle fait
|
||||||
|
partie intégrante de l’implémentation.</p>
|
||||||
|
<h2 id="structure-finale-du-document">Structure finale du document</h2>
|
||||||
|
<ol type="1">
|
||||||
|
<li><strong>Vue d’ensemble</strong> : Principe du pattern</li>
|
||||||
|
<li><strong>Anatomie d’une extension</strong> : 5 étapes détaillées</li>
|
||||||
|
<li><strong>Méthodes disponibles du serveur</strong> : API de
|
||||||
|
<code>pmoserver::Server</code></li>
|
||||||
|
<li><strong>Documentation OpenAPI avec utoipa</strong> : Guide complet
|
||||||
|
en 5 étapes ⭐ <em>Ajouté au Round 3</em></li>
|
||||||
|
<li><strong>Patterns courants</strong> : 3 exemples concrets</li>
|
||||||
|
<li><strong>Gestion des opérations longues</strong> : spawn_blocking,
|
||||||
|
timeouts, background tasks</li>
|
||||||
|
<li><strong>Checklist d’implémentation</strong> : Organisée par
|
||||||
|
catégories</li>
|
||||||
|
<li><strong>Exemple complet minimal</strong> : Code fonctionnel</li>
|
||||||
|
<li><strong>Références</strong> : Fichiers sources et dépendances</li>
|
||||||
|
</ol>
|
||||||
|
<h2 id="résultat-final">Résultat final</h2>
|
||||||
|
<p>Le document est maintenant :</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Complet</strong> : Couvre tous les aspects essentiels
|
||||||
|
incluant OpenAPI</li>
|
||||||
|
<li><strong>Structuré</strong> : Progression logique de la configuration
|
||||||
|
à l’implémentation</li>
|
||||||
|
<li><strong>Pratique</strong> : Exemples de code concrets extraits du
|
||||||
|
codebase</li>
|
||||||
|
<li><strong>Actionnable</strong> : Checklist détaillée en 4
|
||||||
|
catégories</li>
|
||||||
|
</ul>
|
||||||
|
<p>Taille finale : ~660 lignes (avec section OpenAPI complète)</p>
|
||||||
|
<h2 id="fichiers-modifiés">Fichiers modifiés</h2>
|
||||||
|
<ul>
|
||||||
|
<li><code>Blackboard/Architecture/pmoserver_ext.md</code> : Document
|
||||||
|
complet avec OpenAPI (660 lignes)</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
54
Blackboard_HTML/ToDiscuss_Pinnable_cache_item.html
Normal file
54
Blackboard_HTML/ToDiscuss_Pinnable_cache_item.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Pinnable_cache_item</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
||||||
|
<script type="module">
|
||||||
|
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||||
|
mermaid.initialize({startOnLoad: true, theme: "default"});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
pre.mermaid {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="markdown-body">
|
||||||
|
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
|
||||||
|
<p><strong>Il faut suivre les instructions générales placées dans le
|
||||||
|
fichier : Blackboard/Rules.md</strong></p>
|
||||||
|
<p>La crâte PMOcache, implémente un system de cache qui pourrait être
|
||||||
|
étendu pour permettre une utilisation plus large. L’idée est de modifier
|
||||||
|
les règles de déletion des items. Actuellement le cache a une capacité
|
||||||
|
maximale. Et les items ont des TTL, qui peuvent être non définies.
|
||||||
|
Lorsque le cash est plein, les plus vieux items en termes d’utilisation
|
||||||
|
ou ceux qui ont dépassé leur TTL peuvent être détruits. Je propose de
|
||||||
|
rajouter une fonctionnalité qui permet d’épingler certains items pour
|
||||||
|
les rendre non destructibles. Ils pourraient aussi sortir du comptage
|
||||||
|
général des items pour savoir si le cache est plein.</p>
|
||||||
|
<p>Il faudra modifier la structure de la base de données. Ajouter une
|
||||||
|
colonne indiquant cette propriété. Mettre une règle métier en disant
|
||||||
|
qu’on ne peut pas être à la fois épinglés et avec un TTL.</p>
|
||||||
|
<p>On se moque de maintenir la compatibilité avec la base de données
|
||||||
|
actuelle, il n’y a pas à prévoir de phase de transition. Nous sommes en
|
||||||
|
période de développement.</p>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
59
Blackboard_HTML/ToDiscuss_pmoserver_ext.html
Normal file
59
Blackboard_HTML/ToDiscuss_pmoserver_ext.html
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>pmoserver_ext</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
||||||
|
<script type="module">
|
||||||
|
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||||
|
mermaid.initialize({startOnLoad: true, theme: "default"});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
pre.mermaid {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="markdown-body">
|
||||||
|
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
|
||||||
|
<p><strong>Il faut suivre les instructions générales placées dans le
|
||||||
|
fichier : Blackboard/Rules.md</strong></p>
|
||||||
|
<p>Partir des fichiers suivants:</p>
|
||||||
|
<ul>
|
||||||
|
<li>pmoapp/src/lib.rs</li>
|
||||||
|
<li>pmocontrol/src/pmoserver_ext.rs</li>
|
||||||
|
<li>pmoparadise/src/pmoserver_ext.rs</li>
|
||||||
|
<li>pmoaudiocache/src/lib.rs</li>
|
||||||
|
<li>pmomediaserver/src/paradise_streaming.rs</li>
|
||||||
|
</ul>
|
||||||
|
<p>réalise une fiche descriptive sur le pattern à réaliser pour
|
||||||
|
implémenter un trait d’extension du PMO serveur.</p>
|
||||||
|
<p>Le résultat sera une documentation d’implémentation qui sera placé
|
||||||
|
dans le fichier:
|
||||||
|
<code>Blackboard/Architecture/pmoserver_ext.md</code></p>
|
||||||
|
<h2 id="round-2">Round 2</h2>
|
||||||
|
<p>J’ai regardé ton document généré et je trouve que tu t’élargis du
|
||||||
|
sujet central documenter lecture d’une extension PMOserver. Peux-tu te
|
||||||
|
recentrer sur le sujet principal.</p>
|
||||||
|
<h2 id="round-3">Round 3</h2>
|
||||||
|
<p>Je trouve que le fait de devoir déclarer et documenter les URL dans
|
||||||
|
OpenAPI / utopia était quelque chose d’important. Remets le.</p>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
563
Blackboard_HTML/ToThinkAbout_MusicBoxSource.html
Normal file
563
Blackboard_HTML/ToThinkAbout_MusicBoxSource.html
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>MusicBoxSource</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
||||||
|
<script type="module">
|
||||||
|
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||||
|
mermaid.initialize({startOnLoad: true, theme: "default"});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
pre.mermaid {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="markdown-body">
|
||||||
|
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
|
||||||
|
<p><strong>Il faut suivre les instructions générales placées dans le
|
||||||
|
fichier : Blackboard/Rules.md</strong></p>
|
||||||
|
<h1 id="musicboxsource-bibliothèque-musicale-universelle">MusicBoxSource
|
||||||
|
: Bibliothèque musicale universelle</h1>
|
||||||
|
<p>Créer une <strong>“boîte à musique”</strong> personnelle : un
|
||||||
|
catalogue unifié de morceaux provenant de n’importe quelle source
|
||||||
|
(Qobuz, URLs, fichiers locaux, Radio Paradise, etc.), avec taxonomie de
|
||||||
|
tags et playlists intelligentes.</p>
|
||||||
|
<hr />
|
||||||
|
<h2 id="vision">🎯 Vision</h2>
|
||||||
|
<h3 id="concept">Concept</h3>
|
||||||
|
<p><strong>MusicBoxSource</strong> est une bibliothèque musicale
|
||||||
|
curatoriale qui permet de : - <strong>Collecter</strong> : Ajouter des
|
||||||
|
morceaux depuis n’importe quelle source PMOMusic ou URL -
|
||||||
|
<strong>Organiser</strong> : Classifier avec une taxonomie de tags
|
||||||
|
extensible - <strong>Requêter</strong> : Créer des playlists statiques
|
||||||
|
et smart playlists (requêtes dynamiques) - <strong>Exposer</strong> :
|
||||||
|
Servir via UPnP/DIDL-Lite avec navigation multi-axes</p>
|
||||||
|
<h3 id="différence-avec-pmoplaylist">Différence avec
|
||||||
|
<code>pmoplaylist</code></h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong><code>pmoplaylist</code></strong> : Playlists FIFO
|
||||||
|
<strong>éphémères</strong> pour sources live (Radio Paradise)</li>
|
||||||
|
<li><strong><code>pmomusicbox</code></strong> : Bibliothèque
|
||||||
|
<strong>persistante</strong> cross-sources avec métadonnées
|
||||||
|
enrichies</li>
|
||||||
|
</ul>
|
||||||
|
<hr />
|
||||||
|
<h2 id="architecture-globale">🏛️ Architecture globale</h2>
|
||||||
|
<pre class="mermaid">flowchart TB
|
||||||
|
subgraph Sources[Sources PMOMusic]
|
||||||
|
QOBUZ[pmoqobuz]
|
||||||
|
PARADISE[pmoparadise]
|
||||||
|
LOCAL[pmolocal - à créer]
|
||||||
|
URL[URLs directes]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Import[Import Layer]
|
||||||
|
IMPORTER[MusicBox Importer]
|
||||||
|
JSPF[pmojspf - Parser playlists]
|
||||||
|
META[pmometadata - Extraction]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Core[pmomusicbox Core]
|
||||||
|
DB[(SQLite Database)]
|
||||||
|
TAXONOMY[Taxonomie Tags]
|
||||||
|
QUERY[Smart Query Engine]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Cache[Cache Layer]
|
||||||
|
AUDIO[pmoaudiocache]
|
||||||
|
COVERS[pmocovers]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Export[Export UPnP]
|
||||||
|
SOURCE[MusicSource Trait]
|
||||||
|
DIDL[DIDL-Lite Generator]
|
||||||
|
BROWSE[Multi-Axis Browser]
|
||||||
|
end
|
||||||
|
|
||||||
|
Sources --> IMPORTER
|
||||||
|
URL --> IMPORTER
|
||||||
|
JSPF --> IMPORTER
|
||||||
|
META --> IMPORTER
|
||||||
|
|
||||||
|
IMPORTER --> DB
|
||||||
|
DB --> TAXONOMY
|
||||||
|
DB --> QUERY
|
||||||
|
|
||||||
|
DB <--> AUDIO
|
||||||
|
DB <--> COVERS
|
||||||
|
|
||||||
|
DB --> SOURCE
|
||||||
|
TAXONOMY --> BROWSE
|
||||||
|
QUERY --> BROWSE
|
||||||
|
SOURCE --> DIDL
|
||||||
|
BROWSE --> DIDL</pre>
|
||||||
|
<hr />
|
||||||
|
<h2 id="modèle-de-données-sqlite">🗄️ Modèle de données (SQLite)</h2>
|
||||||
|
<h3 id="tables-principales">Tables principales</h3>
|
||||||
|
<pre class="mermaid">erDiagram
|
||||||
|
TAG_CATEGORIES ||--o{ TAGS : contient
|
||||||
|
TAG_CATEGORIES ||--o{ TAG_CATEGORIES : parent
|
||||||
|
TAGS ||--o{ ITEM_TAGS : associe
|
||||||
|
MUSIC_ITEMS ||--o{ ITEM_TAGS : a
|
||||||
|
MUSIC_ITEMS ||--o{ PLAYLIST_ITEMS : dans
|
||||||
|
PLAYLISTS ||--o{ PLAYLIST_ITEMS : contient
|
||||||
|
|
||||||
|
TAG_CATEGORIES {
|
||||||
|
text id PK "Ex: mood, genre"
|
||||||
|
text name "Nom affiché"
|
||||||
|
text parent_id FK "Hiérarchie"
|
||||||
|
text color "Hex color"
|
||||||
|
text icon "Emoji/icon"
|
||||||
|
int display_order
|
||||||
|
}
|
||||||
|
|
||||||
|
TAGS {
|
||||||
|
text id PK "Ex: mood:energetic"
|
||||||
|
text category_id FK
|
||||||
|
text name "energetic, chill"
|
||||||
|
text description
|
||||||
|
text color "Override"
|
||||||
|
}
|
||||||
|
|
||||||
|
MUSIC_ITEMS {
|
||||||
|
text id PK "UUID"
|
||||||
|
text source_type "qobuz, url, local"
|
||||||
|
text source_id "ID source"
|
||||||
|
text original_uri "URI source"
|
||||||
|
text cache_audio_pk FK "pmoaudiocache"
|
||||||
|
text cache_cover_pk FK "pmocovers"
|
||||||
|
text title
|
||||||
|
text artist
|
||||||
|
text album
|
||||||
|
int year
|
||||||
|
int rating "1-5 étoiles"
|
||||||
|
int play_count
|
||||||
|
}
|
||||||
|
|
||||||
|
ITEM_TAGS {
|
||||||
|
text item_id PK,FK
|
||||||
|
text tag_id PK,FK
|
||||||
|
int added_at
|
||||||
|
text source "user, auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
PLAYLISTS {
|
||||||
|
text id PK
|
||||||
|
text name
|
||||||
|
bool is_smart
|
||||||
|
text smart_query "JSON"
|
||||||
|
}
|
||||||
|
|
||||||
|
PLAYLIST_ITEMS {
|
||||||
|
text playlist_id PK,FK
|
||||||
|
text item_id FK
|
||||||
|
int position PK
|
||||||
|
}</pre>
|
||||||
|
<h3 id="tables-dassociation">Tables d’association</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong><code>item_tags</code></strong> : Liens items ↔︎ tags
|
||||||
|
(N:M)</li>
|
||||||
|
<li><strong><code>playlist_items</code></strong> : Items dans playlists
|
||||||
|
statiques (position, ordre)</li>
|
||||||
|
<li><strong><code>tag_synonyms</code></strong> : Synonymes pour
|
||||||
|
recherche (ex: “jazz” → “swing”)</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="index-recherche">Index & Recherche</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Indexes B-tree</strong> : artist, album, genre, year,
|
||||||
|
rating, play_count</li>
|
||||||
|
<li><strong>FTS5 (Full-Text Search)</strong> : title, artist, album,
|
||||||
|
comment</li>
|
||||||
|
<li><strong>Triggers</strong> : Maintien des tables FTS en sync avec
|
||||||
|
<code>music_items</code></li>
|
||||||
|
</ul>
|
||||||
|
<hr />
|
||||||
|
<h2 id="taxonomie-par-défaut">🎨 Taxonomie par défaut</h2>
|
||||||
|
<p>Catégories préchargées à l’initialisation :</p>
|
||||||
|
<table>
|
||||||
|
<colgroup>
|
||||||
|
<col style="width: 14%" />
|
||||||
|
<col style="width: 37%" />
|
||||||
|
<col style="width: 48%" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Catégorie</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Exemples de tags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Mood</strong></td>
|
||||||
|
<td>État d’esprit, émotion</td>
|
||||||
|
<td>energetic, chill, melancholic, happy</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Genre</strong></td>
|
||||||
|
<td>Style musical</td>
|
||||||
|
<td>rock, jazz, classical, electronic, metal</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Era</strong></td>
|
||||||
|
<td>Période, décennie</td>
|
||||||
|
<td>60s, 70s, 80s, 90s, contemporary</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Occasion</strong></td>
|
||||||
|
<td>Contexte d’écoute</td>
|
||||||
|
<td>workout, focus, party, driving, sleep</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Tempo</strong></td>
|
||||||
|
<td>Vitesse</td>
|
||||||
|
<td>slow, medium, fast</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Instrument</strong></td>
|
||||||
|
<td>Instrument dominant</td>
|
||||||
|
<td>piano, guitar, vocal, synthesizer</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Quality</strong></td>
|
||||||
|
<td>Qualité audio</td>
|
||||||
|
<td>lossless, high-res, remastered, live</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Origin</strong></td>
|
||||||
|
<td>Origine géographique</td>
|
||||||
|
<td>usa, uk, france, japan, latin, africa</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p><strong>Extensibilité</strong> : L’utilisateur peut créer ses propres
|
||||||
|
catégories et tags.</p>
|
||||||
|
<hr />
|
||||||
|
<h2 id="crates-architecture">📦 Crates architecture</h2>
|
||||||
|
<h3 id="pmojspf---parser-de-playlists-utilitaire">1.
|
||||||
|
<strong><code>pmojspf</code></strong> - Parser de playlists
|
||||||
|
(utilitaire)</h3>
|
||||||
|
<p><strong>But</strong> : Parser/écrire différents formats de playlists
|
||||||
|
vers/depuis un format pivot JSPF (JSON).</p>
|
||||||
|
<pre><code>pmojspf/
|
||||||
|
├── model.rs # Structures JSPF (Playlist, Track, Meta)
|
||||||
|
├── reader/
|
||||||
|
│ ├── jspf.rs # JSON natif
|
||||||
|
│ ├── xspf.rs # XML (via quick-xml ou crate xspf)
|
||||||
|
│ ├── m3u.rs # M3U/M3U8 (parsing ligne par ligne)
|
||||||
|
│ └── pls.rs # PLS (format INI-like)
|
||||||
|
└── writer.rs # Export JSPF</pre>
|
||||||
|
<p><strong>Dépendances</strong> : <code>serde</code>,
|
||||||
|
<code>serde_json</code>, <code>quick-xml</code> (ou <code>xspf</code>
|
||||||
|
crate)</p>
|
||||||
|
<p><strong>Usage</strong> : Réutilisé par <code>pmomusicbox</code> pour
|
||||||
|
import/export</p>
|
||||||
|
<hr />
|
||||||
|
<h3 id="pmomusicbox---bibliothèque-musicale-core">2.
|
||||||
|
<strong><code>pmomusicbox</code></strong> - Bibliothèque musicale
|
||||||
|
core</h3>
|
||||||
|
<p><strong>Responsabilités</strong> : - Gestion base SQLite (CRUD items,
|
||||||
|
tags, playlists) - Import depuis sources PMO (Qobuz, Paradise, Local,
|
||||||
|
URLs) - Smart playlists (query builder + exécution SQL) - Implémentation
|
||||||
|
<code>MusicSource</code> trait (exposition UPnP) - Intégration caches
|
||||||
|
audio/covers</p>
|
||||||
|
<pre><code>pmomusicbox/
|
||||||
|
├── db/
|
||||||
|
│ ├── schema.rs # DDL SQLite + migrations
|
||||||
|
│ ├── items.rs # CRUD music_items
|
||||||
|
│ ├── tags.rs # CRUD tags + taxonomie
|
||||||
|
│ ├── playlists.rs # CRUD playlists statiques
|
||||||
|
│ ├── smart.rs # Smart playlists
|
||||||
|
│ └── search.rs # Full-text search (FTS5)
|
||||||
|
│
|
||||||
|
├── import/
|
||||||
|
│ ├── url.rs # Import URL directe
|
||||||
|
│ ├── source.rs # Import depuis MusicSource
|
||||||
|
│ ├── local.rs # Import fichiers locaux (via pmometadata)
|
||||||
|
│ └── playlist.rs # Import JSPF/M3U8 (via pmojspf)
|
||||||
|
│
|
||||||
|
├── export/
|
||||||
|
│ └── playlist.rs # Export playlists (JSPF, M3U8)
|
||||||
|
│
|
||||||
|
├── query/
|
||||||
|
│ ├── builder.rs # SmartPlaylistQuery (DSL)
|
||||||
|
│ └── executor.rs # Génération + exécution SQL
|
||||||
|
│
|
||||||
|
├── didl/
|
||||||
|
│ └── generator.rs # Conversion items → DIDL-Lite
|
||||||
|
│
|
||||||
|
├── source.rs # Impl MusicSource trait
|
||||||
|
├── taxonomy.rs # Taxonomie par défaut + CRUD
|
||||||
|
└── config_ext.rs # Extension pmoconfig</pre>
|
||||||
|
<p><strong>Dépendances</strong> : - <code>pmosource</code>,
|
||||||
|
<code>pmoaudiocache</code>, <code>pmocovers</code>,
|
||||||
|
<code>pmodidl</code>, <code>pmometadata</code> - <code>pmojspf</code>
|
||||||
|
(import/export playlists) - <code>rusqlite</code> (features:
|
||||||
|
<code>bundled</code>, <code>serde_json</code>) - <code>uuid</code>,
|
||||||
|
<code>serde</code>, <code>tokio</code>, <code>async-trait</code></p>
|
||||||
|
<hr />
|
||||||
|
<h3 id="pmolocal---source-fichiers-locaux-à-créer">3.
|
||||||
|
<strong><code>pmolocal</code></strong> - Source fichiers locaux (à
|
||||||
|
créer)</h3>
|
||||||
|
<p><strong>But</strong> : Scanner des répertoires locaux et exposer les
|
||||||
|
fichiers audio via <code>MusicSource</code>.</p>
|
||||||
|
<pre><code>pmolocal/
|
||||||
|
├── scanner.rs # Scan récursif de répertoires
|
||||||
|
├── watcher.rs # Hot reload (notify)
|
||||||
|
├── source.rs # Impl MusicSource
|
||||||
|
└── config_ext.rs # Extension pmoconfig</pre>
|
||||||
|
<p><strong>Workflow</strong> : 1. <code>pmolocal</code> scanne
|
||||||
|
<code>/home/user/Music</code> 2. <code>pmomusicbox</code> importe les
|
||||||
|
items découverts 3. Tags automatiques basés sur métadonnées (genre,
|
||||||
|
année)</p>
|
||||||
|
<hr />
|
||||||
|
<h2 id="flux-dimport">🔄 Flux d’import</h2>
|
||||||
|
<h3 id="import-depuis-une-source-pmo-ex-qobuz">Import depuis une source
|
||||||
|
PMO (ex: Qobuz)</h3>
|
||||||
|
<pre class="mermaid">sequenceDiagram
|
||||||
|
participant QS as Qobuz Source
|
||||||
|
participant MB as MusicBox Importer
|
||||||
|
participant DB as SQLite DB
|
||||||
|
participant AC as pmoaudiocache
|
||||||
|
participant CC as pmocovers
|
||||||
|
|
||||||
|
QS->>MB: get_item(object_id)
|
||||||
|
MB->>QS: resolve_uri(object_id)
|
||||||
|
|
||||||
|
Note over MB: 1. Extraire métadonnées DIDL-Lite<br/>2. Générer UUID
|
||||||
|
|
||||||
|
MB->>DB: INSERT INTO music_items
|
||||||
|
|
||||||
|
opt Auto-cache activé
|
||||||
|
MB->>AC: Cache audio
|
||||||
|
MB->>CC: Cache cover
|
||||||
|
AC-->>DB: Retourner cache_audio_pk
|
||||||
|
CC-->>DB: Retourner cache_cover_pk
|
||||||
|
end
|
||||||
|
|
||||||
|
MB-->>QS: item_id (UUID)</pre>
|
||||||
|
<h3 id="import-url-directe">Import URL directe</h3>
|
||||||
|
<pre class="mermaid">flowchart LR
|
||||||
|
URL[URL simple] --> META["pmometadata<br/>Extraction"]
|
||||||
|
META --> UUID[Générer UUID]
|
||||||
|
UUID --> DB[("music_items")]
|
||||||
|
DB --> CACHE{"Auto-cache?"}
|
||||||
|
CACHE -->|Oui| AC[pmoaudiocache]
|
||||||
|
CACHE -->|Non| END[Fin]
|
||||||
|
AC --> END</pre>
|
||||||
|
<h3 id="import-playlist-jspfm3u8">Import playlist JSPF/M3U8</h3>
|
||||||
|
<pre class="mermaid">flowchart LR
|
||||||
|
FILE[Fichier playlist] --> JSPF["pmojspf<br/>Parser"]
|
||||||
|
JSPF --> STRUCT[Structure JSPF]
|
||||||
|
STRUCT --> LOOP{"Pour chaque track"}
|
||||||
|
LOOP --> IMPORT[Import comme URL]
|
||||||
|
IMPORT --> DB[("music_items")]
|
||||||
|
DB --> PLAYLIST[Créer playlist statique]
|
||||||
|
PLAYLIST --> LINK[Lier tracks à playlist]</pre>
|
||||||
|
<hr />
|
||||||
|
<h2 id="smart-playlists-query-dsl">🔍 Smart Playlists (Query DSL)</h2>
|
||||||
|
<h3 id="concept-1">Concept</h3>
|
||||||
|
<p>Les smart playlists sont des <strong>requêtes sauvegardées</strong>
|
||||||
|
qui génèrent dynamiquement une liste de tracks.</p>
|
||||||
|
<h3 id="structure-de-requête-json">Structure de requête (JSON)</h3>
|
||||||
|
<div class="sourceCode" id="cb9"><pre
|
||||||
|
class="sourceCode json"><code class="sourceCode json"><span id="cb9-1"><a href="#cb9-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span></span>
|
||||||
|
<span id="cb9-2"><a href="#cb9-2" aria-hidden="true" tabindex="-1"></a> <span class="dt">"include_all_tags"</span><span class="fu">:</span> <span class="ot">[</span><span class="st">"mood:energetic"</span><span class="ot">,</span> <span class="st">"genre:rock"</span><span class="ot">]</span><span class="fu">,</span></span>
|
||||||
|
<span id="cb9-3"><a href="#cb9-3" aria-hidden="true" tabindex="-1"></a> <span class="dt">"exclude_tags"</span><span class="fu">:</span> <span class="ot">[</span><span class="st">"mood:melancholic"</span><span class="ot">]</span><span class="fu">,</span></span>
|
||||||
|
<span id="cb9-4"><a href="#cb9-4" aria-hidden="true" tabindex="-1"></a> <span class="dt">"year_min"</span><span class="fu">:</span> <span class="dv">1980</span><span class="fu">,</span></span>
|
||||||
|
<span id="cb9-5"><a href="#cb9-5" aria-hidden="true" tabindex="-1"></a> <span class="dt">"year_max"</span><span class="fu">:</span> <span class="dv">1989</span><span class="fu">,</span></span>
|
||||||
|
<span id="cb9-6"><a href="#cb9-6" aria-hidden="true" tabindex="-1"></a> <span class="dt">"min_rating"</span><span class="fu">:</span> <span class="dv">4</span><span class="fu">,</span></span>
|
||||||
|
<span id="cb9-7"><a href="#cb9-7" aria-hidden="true" tabindex="-1"></a> <span class="dt">"lossless_only"</span><span class="fu">:</span> <span class="kw">true</span><span class="fu">,</span></span>
|
||||||
|
<span id="cb9-8"><a href="#cb9-8" aria-hidden="true" tabindex="-1"></a> <span class="dt">"order_by"</span><span class="fu">:</span> <span class="st">"play_count"</span><span class="fu">,</span></span>
|
||||||
|
<span id="cb9-9"><a href="#cb9-9" aria-hidden="true" tabindex="-1"></a> <span class="dt">"order"</span><span class="fu">:</span> <span class="st">"desc"</span><span class="fu">,</span></span>
|
||||||
|
<span id="cb9-10"><a href="#cb9-10" aria-hidden="true" tabindex="-1"></a> <span class="dt">"limit"</span><span class="fu">:</span> <span class="dv">50</span></span>
|
||||||
|
<span id="cb9-11"><a href="#cb9-11" aria-hidden="true" tabindex="-1"></a><span class="fu">}</span></span></pre></div>
|
||||||
|
<h3 id="traduction-sql">Traduction SQL</h3>
|
||||||
|
<div class="sourceCode" id="cb10"><pre
|
||||||
|
class="sourceCode sql"><code class="sourceCode sql"><span id="cb10-1"><a href="#cb10-1" aria-hidden="true" tabindex="-1"></a><span class="kw">SELECT</span> <span class="op">*</span> <span class="kw">FROM</span> music_items</span>
|
||||||
|
<span id="cb10-2"><a href="#cb10-2" aria-hidden="true" tabindex="-1"></a><span class="kw">WHERE</span> <span class="kw">id</span> <span class="kw">IN</span> (</span>
|
||||||
|
<span id="cb10-3"><a href="#cb10-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">SELECT</span> item_id <span class="kw">FROM</span> item_tags <span class="kw">WHERE</span> tag_id <span class="kw">IN</span> (<span class="st">'mood:energetic'</span>, <span class="st">'genre:rock'</span>)</span>
|
||||||
|
<span id="cb10-4"><a href="#cb10-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">GROUP</span> <span class="kw">BY</span> item_id <span class="kw">HAVING</span> <span class="fu">COUNT</span>(<span class="kw">DISTINCT</span> tag_id) <span class="op">=</span> <span class="dv">2</span> <span class="co">-- ALL tags</span></span>
|
||||||
|
<span id="cb10-5"><a href="#cb10-5" aria-hidden="true" tabindex="-1"></a>)</span>
|
||||||
|
<span id="cb10-6"><a href="#cb10-6" aria-hidden="true" tabindex="-1"></a><span class="kw">AND</span> <span class="kw">id</span> <span class="kw">NOT</span> <span class="kw">IN</span> (</span>
|
||||||
|
<span id="cb10-7"><a href="#cb10-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">SELECT</span> item_id <span class="kw">FROM</span> item_tags <span class="kw">WHERE</span> tag_id <span class="op">=</span> <span class="st">'mood:melancholic'</span></span>
|
||||||
|
<span id="cb10-8"><a href="#cb10-8" aria-hidden="true" tabindex="-1"></a>)</span>
|
||||||
|
<span id="cb10-9"><a href="#cb10-9" aria-hidden="true" tabindex="-1"></a><span class="kw">AND</span> <span class="dt">year</span> <span class="kw">BETWEEN</span> <span class="dv">1980</span> <span class="kw">AND</span> <span class="dv">1989</span></span>
|
||||||
|
<span id="cb10-10"><a href="#cb10-10" aria-hidden="true" tabindex="-1"></a><span class="kw">AND</span> rating <span class="op">>=</span> <span class="dv">4</span></span>
|
||||||
|
<span id="cb10-11"><a href="#cb10-11" aria-hidden="true" tabindex="-1"></a><span class="kw">AND</span> codec <span class="kw">IN</span> (<span class="st">'flac'</span>, <span class="st">'alac'</span>)</span>
|
||||||
|
<span id="cb10-12"><a href="#cb10-12" aria-hidden="true" tabindex="-1"></a><span class="kw">ORDER</span> <span class="kw">BY</span> play_count <span class="kw">DESC</span></span>
|
||||||
|
<span id="cb10-13"><a href="#cb10-13" aria-hidden="true" tabindex="-1"></a><span class="kw">LIMIT</span> <span class="dv">50</span>;</span></pre></div>
|
||||||
|
<hr />
|
||||||
|
<h2 id="exposition-upnp-musicsource">🎭 Exposition UPnP
|
||||||
|
(MusicSource)</h2>
|
||||||
|
<h3 id="structure-de-navigation">Structure de navigation</h3>
|
||||||
|
<pre class="mermaid">graph TB
|
||||||
|
ROOT[musicbox/] --> ARTIST[by-artist/]
|
||||||
|
ROOT --> ALBUM[by-album/]
|
||||||
|
ROOT --> GENRE[by-genre/]
|
||||||
|
ROOT --> TAG[by-tag/]
|
||||||
|
ROOT --> PLAYLISTS[playlists/]
|
||||||
|
ROOT --> SMART[smart-playlists/]
|
||||||
|
ROOT --> FAV[favorites/]
|
||||||
|
ROOT --> RECENT[recent/]
|
||||||
|
|
||||||
|
ARTIST --> PF[Pink Floyd/]
|
||||||
|
ARTIST --> Q[Queen/]
|
||||||
|
PF --> WALL[The Wall/]
|
||||||
|
PF --> WYWH[Wish You Were Here/]
|
||||||
|
WALL --> ITEM1[Another Brick... 🎵]
|
||||||
|
|
||||||
|
TAG --> MOOD[mood/]
|
||||||
|
TAG --> OCC[occasion/]
|
||||||
|
TAG --> ERA[era/]
|
||||||
|
|
||||||
|
MOOD --> ENRG[energetic/]
|
||||||
|
MOOD --> CHILL[chill/]
|
||||||
|
ENRG --> ITEMS1[items taggués 🎵]
|
||||||
|
|
||||||
|
OCC --> WORK[workout/]
|
||||||
|
OCC --> FOCUS[focus/]
|
||||||
|
|
||||||
|
ERA --> E80[80s/]
|
||||||
|
ERA --> E90[90s/]
|
||||||
|
|
||||||
|
PLAYLISTS --> PL1[My Favorites/]
|
||||||
|
PLAYLISTS --> PL2[Summer 2024/]
|
||||||
|
|
||||||
|
SMART --> SP1[80s Rock Workout/]
|
||||||
|
SMART --> SP2[Jazz Dinner/]
|
||||||
|
|
||||||
|
style ITEM1 fill:#e1f5ff
|
||||||
|
style ITEMS1 fill:#e1f5ff</pre>
|
||||||
|
<h3 id="object-ids">Object IDs</h3>
|
||||||
|
<pre><code>musicbox:by-artist:{artist_name}
|
||||||
|
musicbox:by-album:{album_id}
|
||||||
|
musicbox:by-tag:{category}:{tag_name}
|
||||||
|
musicbox:playlist:{playlist_id}
|
||||||
|
musicbox:smart:{smart_playlist_id}
|
||||||
|
musicbox:item:{item_id}</pre>
|
||||||
|
<hr />
|
||||||
|
<h2 id="intégration-avec-lécosystème-pmomusic">🔌 Intégration avec
|
||||||
|
l’écosystème PMOMusic</h2>
|
||||||
|
<h3 id="avec-pmoaudiocache">Avec pmoaudiocache</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Import → Déclencher cache automatique (si
|
||||||
|
<code>auto_cache: true</code>)</li>
|
||||||
|
<li><code>resolve_uri()</code> → Retourner URI cachée si disponible</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="avec-pmocovers">Avec pmocovers</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Import → Télécharger cover art</li>
|
||||||
|
<li>Browse → Inclure <code>album_art</code> dans DIDL-Lite</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="avec-pmoserver-feature-server">Avec pmoserver (feature
|
||||||
|
<code>server</code>)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>API REST pour manipulation (CRUD items, tags, playlists)</li>
|
||||||
|
<li>SSE pour notifications de changements</li>
|
||||||
|
<li>Endpoints OpenAPI (utoipa)</li>
|
||||||
|
</ul>
|
||||||
|
<hr />
|
||||||
|
<h2 id="plan-dimplémentation-phases">📝 Plan d’implémentation
|
||||||
|
(Phases)</h2>
|
||||||
|
<h3 id="phase-1-fondations">Phase 1 : Fondations</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Schéma SQLite complet</li>
|
||||||
|
<li>Crate <code>pmojspf</code> (parser playlists)</li>
|
||||||
|
<li>CRUD basique dans <code>pmomusicbox</code> (items, tags)</li>
|
||||||
|
<li>Taxonomie par défaut</li>
|
||||||
|
<li>Import URL simple</li>
|
||||||
|
<li>Extension pmoconfig</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="phase-2-import-cross-sources">Phase 2 : Import
|
||||||
|
cross-sources</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Import depuis MusicSource (Qobuz, Paradise)</li>
|
||||||
|
<li>Import playlists (JSPF/M3U8)</li>
|
||||||
|
<li>Intégration caches (audio, covers)</li>
|
||||||
|
<li>Crate <code>pmolocal</code> (fichiers locaux)</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="phase-3-smart-playlists">Phase 3 : Smart Playlists</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Query builder (DSL)</li>
|
||||||
|
<li>Exécuteur SQL</li>
|
||||||
|
<li>CRUD smart playlists</li>
|
||||||
|
<li>Export JSPF</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="phase-4-musicsource-upnp">Phase 4 : MusicSource UPnP</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Implémentation trait <code>MusicSource</code></li>
|
||||||
|
<li>Génération DIDL-Lite</li>
|
||||||
|
<li>Browse multi-axes (artist, album, tag)</li>
|
||||||
|
<li>Recherche full-text (FTS5)</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="phase-5-fonctionnalités-avancées">Phase 5 : Fonctionnalités
|
||||||
|
avancées</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Statistiques d’écoute (play_count, last_played)</li>
|
||||||
|
<li>Auto-tagging (genre depuis métadonnées)</li>
|
||||||
|
<li>API REST (feature <code>server</code>)</li>
|
||||||
|
<li>Recommandations (items similaires)</li>
|
||||||
|
</ul>
|
||||||
|
<hr />
|
||||||
|
<h2 id="cas-dusage">🎯 Cas d’usage</h2>
|
||||||
|
<h3 id="workflow-typique">Workflow typique</h3>
|
||||||
|
<ol type="1">
|
||||||
|
<li><strong>Découverte</strong> : Écouter Radio Paradise, tomber sur un
|
||||||
|
morceau génial</li>
|
||||||
|
<li><strong>Ajout</strong> :
|
||||||
|
<code>musicbox.import_from_source(&paradise, "track-123")</code></li>
|
||||||
|
<li><strong>Organisation</strong> : Ajouter tags
|
||||||
|
<code>mood:chill</code>, <code>occasion:focus</code></li>
|
||||||
|
<li><strong>Playlist</strong> : Smart playlist “Focus Music” avec
|
||||||
|
requête <code>mood:chill + occasion:focus</code></li>
|
||||||
|
<li><strong>Écoute</strong> : Naviguer dans UPnP →
|
||||||
|
<code>musicbox/smart-playlists/Focus Music/</code></li>
|
||||||
|
</ol>
|
||||||
|
<h3 id="scénario-bibliothèque-mixte">Scénario : Bibliothèque mixte</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Albums Qobuz haute résolution</li>
|
||||||
|
<li>Playlists M3U8 importées depuis iTunes</li>
|
||||||
|
<li>Fichiers FLAC locaux scannés</li>
|
||||||
|
<li>URLs de SoundCloud</li>
|
||||||
|
<li>Tracks Radio Paradise capturés</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Tout unifié dans MusicBox, accessible via UPnP, organisé par
|
||||||
|
tags.</strong></p>
|
||||||
|
<hr />
|
||||||
|
<h2 id="références">📚 Références</h2>
|
||||||
|
<h3 id="standards">Standards</h3>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://www.xspf.org/jspf">JSPF Spec</a></li>
|
||||||
|
<li><a href="https://www.xspf.org/spec">XSPF Spec</a></li>
|
||||||
|
<li><a href="https://www.sqlite.org/fts5.html">SQLite FTS5</a></li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="inspirations">Inspirations</h3>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://beets.io/">Beets</a> - Music library manager</li>
|
||||||
|
<li><a href="https://www.navidrome.org/">Navidrome</a> - Music
|
||||||
|
server</li>
|
||||||
|
<li><a href="https://picard.musicbrainz.org/">MusicBrainz Picard</a> -
|
||||||
|
Tagger</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
539
Blackboard_HTML/ToThinkAbout_PlayListSource.html
Normal file
539
Blackboard_HTML/ToThinkAbout_PlayListSource.html
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>PlayListSource</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
||||||
|
<script type="module">
|
||||||
|
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||||
|
mermaid.initialize({startOnLoad: true, theme: "default"});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
pre.mermaid {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="markdown-body">
|
||||||
|
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
|
||||||
|
<p><strong>Il faut suivre les instructions générales placées dans le
|
||||||
|
fichier : Blackboard/Rules.md</strong></p>
|
||||||
|
<h1 id="playlistsource-musicsource-pour-playlists">PlaylistSource :
|
||||||
|
MusicSource pour playlists</h1>
|
||||||
|
<p>Implémenter une source PMOMusic capable de servir un catalogue de
|
||||||
|
playlists hiérarchisé via UPnP.</p>
|
||||||
|
<hr />
|
||||||
|
<h2 id="décisions-de-conception">📋 Décisions de conception</h2>
|
||||||
|
<h3 id="format-pivot-jspf-json">Format pivot : JSPF (JSON)</h3>
|
||||||
|
<p><strong>Choix</strong> : JSPF comme format interne central -
|
||||||
|
Métadonnées riches (title, creator, album, annotation, image, duration,
|
||||||
|
etc.) - JSON natif avec serde (Rust-friendly) - Standard ouvert
|
||||||
|
(Xiph.Org) - Extensible via champ <code>meta</code></p>
|
||||||
|
<p><strong>Formats supportés</strong> : - ✅ <strong>JSPF</strong>
|
||||||
|
(.jspf) - JSON, format natif - ✅ <strong>XSPF</strong> (.xspf) - XML,
|
||||||
|
conversion vers JSPF - ✅ <strong>M3U8</strong> (.m3u8) - Texte,
|
||||||
|
métadonnées limitées - ✅ <strong>PLS</strong> (.pls) - INI-like, très
|
||||||
|
basique</p>
|
||||||
|
<p><strong>Architecture</strong> : 1 Writer (JSPF) + 4 Readers (JSPF,
|
||||||
|
XSPF, M3U8, PLS) → Structure JSPF centrale</p>
|
||||||
|
<pre class="mermaid">flowchart LR
|
||||||
|
JSPF[JSPF JSON] --> JR[JspfReader]
|
||||||
|
XSPF[XSPF XML] --> XR[XspfReader]
|
||||||
|
M3U8[M3U8 Text] --> MR[M3uReader]
|
||||||
|
PLS[PLS INI] --> PR[PlsReader]
|
||||||
|
|
||||||
|
JR --> CORE[JSPF Structure]
|
||||||
|
XR --> CORE
|
||||||
|
MR --> CORE
|
||||||
|
PR --> CORE
|
||||||
|
|
||||||
|
CORE --> W[JspfWriter]
|
||||||
|
W --> OUT[.jspf]</pre>
|
||||||
|
<hr />
|
||||||
|
<h2 id="structure-du-répertoire">🗂️ Structure du répertoire</h2>
|
||||||
|
<pre><code>playlists/
|
||||||
|
├── metadata.json # Métadonnées du conteneur racine
|
||||||
|
├── Jazz/
|
||||||
|
│ ├── metadata.json # Métadonnées catégorie Jazz
|
||||||
|
│ ├── standards.jspf
|
||||||
|
│ ├── bebop.jspf
|
||||||
|
│ └── covers/
|
||||||
|
│ └── standards.webp
|
||||||
|
├── Classical/
|
||||||
|
│ ├── metadata.json
|
||||||
|
│ ├── baroque.jspf
|
||||||
|
│ └── romantic.jspf
|
||||||
|
└── Rock/
|
||||||
|
├── metadata.json
|
||||||
|
└── 70s.jspf</pre>
|
||||||
|
<h3 id="fichier-metadata.json-conteneur">Fichier
|
||||||
|
<code>metadata.json</code> (conteneur)</h3>
|
||||||
|
<div class="sourceCode" id="cb3"><pre
|
||||||
|
class="sourceCode json"><code class="sourceCode json"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span></span>
|
||||||
|
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a> <span class="dt">"container"</span><span class="fu">:</span> <span class="fu">{</span></span>
|
||||||
|
<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a> <span class="dt">"title"</span><span class="fu">:</span> <span class="st">"Collection Jazz"</span><span class="fu">,</span></span>
|
||||||
|
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a> <span class="dt">"description"</span><span class="fu">:</span> <span class="st">"Mes playlists jazz favorites"</span><span class="fu">,</span></span>
|
||||||
|
<span id="cb3-5"><a href="#cb3-5" aria-hidden="true" tabindex="-1"></a> <span class="dt">"creator"</span><span class="fu">:</span> <span class="st">"John Doe"</span><span class="fu">,</span></span>
|
||||||
|
<span id="cb3-6"><a href="#cb3-6" aria-hidden="true" tabindex="-1"></a> <span class="dt">"image"</span><span class="fu">:</span> <span class="st">"covers/jazz-collection.webp"</span><span class="fu">,</span></span>
|
||||||
|
<span id="cb3-7"><a href="#cb3-7" aria-hidden="true" tabindex="-1"></a> <span class="dt">"date"</span><span class="fu">:</span> <span class="st">"2026-01-15"</span><span class="fu">,</span></span>
|
||||||
|
<span id="cb3-8"><a href="#cb3-8" aria-hidden="true" tabindex="-1"></a> <span class="dt">"meta"</span><span class="fu">:</span> <span class="ot">[</span></span>
|
||||||
|
<span id="cb3-9"><a href="#cb3-9" aria-hidden="true" tabindex="-1"></a> <span class="fu">{</span><span class="dt">"rel"</span><span class="fu">:</span> <span class="st">"genre"</span><span class="fu">,</span> <span class="dt">"content"</span><span class="fu">:</span> <span class="st">"Jazz"</span><span class="fu">}</span><span class="ot">,</span></span>
|
||||||
|
<span id="cb3-10"><a href="#cb3-10" aria-hidden="true" tabindex="-1"></a> <span class="fu">{</span><span class="dt">"rel"</span><span class="fu">:</span> <span class="st">"mood"</span><span class="fu">,</span> <span class="dt">"content"</span><span class="fu">:</span> <span class="st">"Relaxing"</span><span class="fu">}</span></span>
|
||||||
|
<span id="cb3-11"><a href="#cb3-11" aria-hidden="true" tabindex="-1"></a> <span class="ot">]</span></span>
|
||||||
|
<span id="cb3-12"><a href="#cb3-12" aria-hidden="true" tabindex="-1"></a> <span class="fu">}</span></span>
|
||||||
|
<span id="cb3-13"><a href="#cb3-13" aria-hidden="true" tabindex="-1"></a><span class="fu">}</span></span></pre></div>
|
||||||
|
<hr />
|
||||||
|
<h2 id="composants-à-implémenter">🏗️ Composants à implémenter</h2>
|
||||||
|
<h3 id="crate-pmojspf-parsing-playlists">1. Crate <code>pmojspf</code>
|
||||||
|
(parsing playlists)</h3>
|
||||||
|
<p><strong>Responsabilité</strong> : Parser différents formats de
|
||||||
|
playlist vers structure JSPF unifiée</p>
|
||||||
|
<h4 id="structure">Structure</h4>
|
||||||
|
<pre><code>pmojspf/
|
||||||
|
├── Cargo.toml
|
||||||
|
├── src/
|
||||||
|
│ ├── lib.rs # API publique
|
||||||
|
│ ├── model.rs # Structures JSPF
|
||||||
|
│ ├── writer.rs # JspfWriter
|
||||||
|
│ ├── reader/
|
||||||
|
│ │ ├── mod.rs # Trait PlaylistReader
|
||||||
|
│ │ ├── jspf.rs # Reader JSON natif (serde_json)
|
||||||
|
│ │ ├── xspf.rs # Reader XML (xml-rs)
|
||||||
|
│ │ ├── m3u.rs # Reader M3U8 (parsing ligne par ligne)
|
||||||
|
│ │ └── pls.rs # Reader PLS (format INI-like)
|
||||||
|
│ └── error.rs
|
||||||
|
└── tests/
|
||||||
|
└── fixtures/</pre>
|
||||||
|
<h4 id="modèle-de-données">Modèle de données</h4>
|
||||||
|
<p><strong>Inspiré de la crate <a
|
||||||
|
href="https://crates.io/crates/xspf">xspf</a> v0.4.2</strong></p>
|
||||||
|
<div class="sourceCode" id="cb5"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">serde::</span><span class="op">{</span>Deserialize<span class="op">,</span> Serialize<span class="op">};</span></span>
|
||||||
|
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> Deserialize<span class="at">)]</span></span>
|
||||||
|
<span id="cb5-4"><a href="#cb5-4" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> Jspf <span class="op">{</span></span>
|
||||||
|
<span id="cb5-5"><a href="#cb5-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> playlist<span class="op">:</span> JspfPlaylist<span class="op">,</span></span>
|
||||||
|
<span id="cb5-6"><a href="#cb5-6" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb5-7"><a href="#cb5-7" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb5-8"><a href="#cb5-8" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> Deserialize<span class="op">,</span> <span class="bu">Default</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-9"><a href="#cb5-9" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>serde<span class="at">(</span>rename_all <span class="op">=</span> <span class="st">"camelCase"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-10"><a href="#cb5-10" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> JspfPlaylist <span class="op">{</span></span>
|
||||||
|
<span id="cb5-11"><a href="#cb5-11" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Option::is_none"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-12"><a href="#cb5-12" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> title<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-13"><a href="#cb5-13" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Option::is_none"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-14"><a href="#cb5-14" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> creator<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-15"><a href="#cb5-15" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Option::is_none"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-16"><a href="#cb5-16" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> annotation<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-17"><a href="#cb5-17" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Option::is_none"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-18"><a href="#cb5-18" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> info<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-19"><a href="#cb5-19" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Option::is_none"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-20"><a href="#cb5-20" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> location<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-21"><a href="#cb5-21" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Option::is_none"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-22"><a href="#cb5-22" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> identifier<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-23"><a href="#cb5-23" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Option::is_none"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-24"><a href="#cb5-24" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> image<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-25"><a href="#cb5-25" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Option::is_none"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-26"><a href="#cb5-26" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> date<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-27"><a href="#cb5-27" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Option::is_none"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-28"><a href="#cb5-28" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> license<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-29"><a href="#cb5-29" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Vec::is_empty"</span><span class="op">,</span> <span class="kw">default</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-30"><a href="#cb5-30" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> attribution<span class="op">:</span> <span class="dt">Vec</span><span class="op"><</span>JspfAttribution<span class="op">>,</span></span>
|
||||||
|
<span id="cb5-31"><a href="#cb5-31" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Vec::is_empty"</span><span class="op">,</span> <span class="kw">default</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-32"><a href="#cb5-32" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> meta<span class="op">:</span> <span class="dt">Vec</span><span class="op"><</span>JspfMeta<span class="op">>,</span></span>
|
||||||
|
<span id="cb5-33"><a href="#cb5-33" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span><span class="kw">default</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-34"><a href="#cb5-34" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> track<span class="op">:</span> <span class="dt">Vec</span><span class="op"><</span>JspfTrack<span class="op">>,</span></span>
|
||||||
|
<span id="cb5-35"><a href="#cb5-35" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb5-36"><a href="#cb5-36" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb5-37"><a href="#cb5-37" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> Deserialize<span class="op">,</span> <span class="bu">Default</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-38"><a href="#cb5-38" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>serde<span class="at">(</span>rename_all <span class="op">=</span> <span class="st">"camelCase"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-39"><a href="#cb5-39" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> JspfTrack <span class="op">{</span></span>
|
||||||
|
<span id="cb5-40"><a href="#cb5-40" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Vec::is_empty"</span><span class="op">,</span> <span class="kw">default</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-41"><a href="#cb5-41" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> location<span class="op">:</span> <span class="dt">Vec</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-42"><a href="#cb5-42" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Vec::is_empty"</span><span class="op">,</span> <span class="kw">default</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-43"><a href="#cb5-43" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> identifier<span class="op">:</span> <span class="dt">Vec</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-44"><a href="#cb5-44" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Option::is_none"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-45"><a href="#cb5-45" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> title<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-46"><a href="#cb5-46" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Option::is_none"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-47"><a href="#cb5-47" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> creator<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-48"><a href="#cb5-48" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Option::is_none"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-49"><a href="#cb5-49" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> annotation<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-50"><a href="#cb5-50" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Option::is_none"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-51"><a href="#cb5-51" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> info<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-52"><a href="#cb5-52" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Option::is_none"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-53"><a href="#cb5-53" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> image<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-54"><a href="#cb5-54" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Option::is_none"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-55"><a href="#cb5-55" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> album<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-56"><a href="#cb5-56" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Option::is_none"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-57"><a href="#cb5-57" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> track_num<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">u32</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb5-58"><a href="#cb5-58" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Option::is_none"</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-59"><a href="#cb5-59" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> duration<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">u64</span><span class="op">>,</span> <span class="co">// millisecondes</span></span>
|
||||||
|
<span id="cb5-60"><a href="#cb5-60" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">"Vec::is_empty"</span><span class="op">,</span> <span class="kw">default</span><span class="at">)]</span></span>
|
||||||
|
<span id="cb5-61"><a href="#cb5-61" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> meta<span class="op">:</span> <span class="dt">Vec</span><span class="op"><</span>JspfMeta<span class="op">>,</span></span>
|
||||||
|
<span id="cb5-62"><a href="#cb5-62" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb5-63"><a href="#cb5-63" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb5-64"><a href="#cb5-64" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> Deserialize<span class="at">)]</span></span>
|
||||||
|
<span id="cb5-65"><a href="#cb5-65" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>serde<span class="at">(</span>untagged<span class="at">)]</span></span>
|
||||||
|
<span id="cb5-66"><a href="#cb5-66" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">enum</span> JspfAttribution <span class="op">{</span></span>
|
||||||
|
<span id="cb5-67"><a href="#cb5-67" aria-hidden="true" tabindex="-1"></a> Location <span class="op">{</span> location<span class="op">:</span> <span class="dt">String</span> <span class="op">},</span></span>
|
||||||
|
<span id="cb5-68"><a href="#cb5-68" aria-hidden="true" tabindex="-1"></a> Identifier <span class="op">{</span> identifier<span class="op">:</span> <span class="dt">String</span> <span class="op">},</span></span>
|
||||||
|
<span id="cb5-69"><a href="#cb5-69" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb5-70"><a href="#cb5-70" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb5-71"><a href="#cb5-71" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> Deserialize<span class="at">)]</span></span>
|
||||||
|
<span id="cb5-72"><a href="#cb5-72" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> JspfMeta <span class="op">{</span></span>
|
||||||
|
<span id="cb5-73"><a href="#cb5-73" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> rel<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
|
||||||
|
<span id="cb5-74"><a href="#cb5-74" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> content<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
|
||||||
|
<span id="cb5-75"><a href="#cb5-75" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h4 id="trait-playlistreader">Trait PlaylistReader</h4>
|
||||||
|
<div class="sourceCode" id="cb6"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb6-1"><a href="#cb6-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">std::io::</span><span class="bu">Read</span><span class="op">;</span></span>
|
||||||
|
<span id="cb6-2"><a href="#cb6-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb6-3"><a href="#cb6-3" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">trait</span> PlaylistReader <span class="op">{</span></span>
|
||||||
|
<span id="cb6-4"><a href="#cb6-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> read<span class="op"><</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">></span>(reader<span class="op">:</span> R) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Jspf<span class="op">>;</span></span>
|
||||||
|
<span id="cb6-5"><a href="#cb6-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> from_str(s<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Jspf<span class="op">>;</span></span>
|
||||||
|
<span id="cb6-6"><a href="#cb6-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> from_file<span class="op"><</span>P<span class="op">:</span> <span class="bu">AsRef</span><span class="op"><</span><span class="dt">Path</span><span class="op">>></span>(path<span class="op">:</span> P) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Jspf<span class="op">>;</span></span>
|
||||||
|
<span id="cb6-7"><a href="#cb6-7" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h4 id="implémentations-des-readers">Implémentations des Readers</h4>
|
||||||
|
<h5 id="jspfreader-simple---serde_json">JspfReader (✅ Simple -
|
||||||
|
serde_json)</h5>
|
||||||
|
<div class="sourceCode" id="cb7"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb7-1"><a href="#cb7-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> JspfReader<span class="op">;</span></span>
|
||||||
|
<span id="cb7-2"><a href="#cb7-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb7-3"><a href="#cb7-3" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> PlaylistReader <span class="cf">for</span> JspfReader <span class="op">{</span></span>
|
||||||
|
<span id="cb7-4"><a href="#cb7-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> read<span class="op"><</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">></span>(reader<span class="op">:</span> R) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Jspf<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb7-5"><a href="#cb7-5" aria-hidden="true" tabindex="-1"></a> <span class="pp">serde_json::</span>from_reader(reader)</span>
|
||||||
|
<span id="cb7-6"><a href="#cb7-6" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="bu">Error</span><span class="pp">::</span>ParseError(<span class="pp">format!</span>(<span class="st">"JSON: {}"</span><span class="op">,</span> e)))</span>
|
||||||
|
<span id="cb7-7"><a href="#cb7-7" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb7-8"><a href="#cb7-8" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<p><strong>Dépendances</strong> : <code>serde_json</code></p>
|
||||||
|
<h5 id="xspfreader-complexe---xml-rs">XspfReader (⚠️ Complexe -
|
||||||
|
xml-rs)</h5>
|
||||||
|
<p><strong>Approche</strong> : Machine à états XML pour parser
|
||||||
|
<code><playlist></code>, <code><track></code>, etc.</p>
|
||||||
|
<p><strong>Alternative</strong> : Utiliser la crate <code>xspf</code>
|
||||||
|
existante puis convertir → JSPF</p>
|
||||||
|
<div class="sourceCode" id="cb8"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb8-1"><a href="#cb8-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> XspfReader<span class="op">;</span></span>
|
||||||
|
<span id="cb8-2"><a href="#cb8-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb8-3"><a href="#cb8-3" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> PlaylistReader <span class="cf">for</span> XspfReader <span class="op">{</span></span>
|
||||||
|
<span id="cb8-4"><a href="#cb8-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> read<span class="op"><</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">></span>(reader<span class="op">:</span> R) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Jspf<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb8-5"><a href="#cb8-5" aria-hidden="true" tabindex="-1"></a> <span class="co">// Parser XML avec EventReader</span></span>
|
||||||
|
<span id="cb8-6"><a href="#cb8-6" aria-hidden="true" tabindex="-1"></a> <span class="co">// État : in_playlist, in_track, current_element</span></span>
|
||||||
|
<span id="cb8-7"><a href="#cb8-7" aria-hidden="true" tabindex="-1"></a> <span class="co">// Mapping: <title> → playlist.title, <track> → JspfTrack</span></span>
|
||||||
|
<span id="cb8-8"><a href="#cb8-8" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb8-9"><a href="#cb8-9" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<p><strong>Dépendances</strong> : <code>xml-rs</code> ou réutiliser
|
||||||
|
<code>xspf</code> crate</p>
|
||||||
|
<h5 id="m3ureader-modéré---ligne-par-ligne">M3uReader (⚙️ Modéré - ligne
|
||||||
|
par ligne)</h5>
|
||||||
|
<p><strong>Format</strong> :</p>
|
||||||
|
<pre class="m3u"><code>#EXTM3U
|
||||||
|
#PLAYLIST:Ma Playlist Jazz
|
||||||
|
#EXTINF:284,John Coltrane - Giant Steps
|
||||||
|
#EXTART:John Coltrane
|
||||||
|
#EXTALB:Giant Steps
|
||||||
|
file:///music/coltrane.flac</pre>
|
||||||
|
<div class="sourceCode" id="cb10"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb10-1"><a href="#cb10-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> M3uReader<span class="op">;</span></span>
|
||||||
|
<span id="cb10-2"><a href="#cb10-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb10-3"><a href="#cb10-3" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> PlaylistReader <span class="cf">for</span> M3uReader <span class="op">{</span></span>
|
||||||
|
<span id="cb10-4"><a href="#cb10-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> read<span class="op"><</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">></span>(reader<span class="op">:</span> R) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Jspf<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb10-5"><a href="#cb10-5" aria-hidden="true" tabindex="-1"></a> <span class="co">// BufReader ligne par ligne</span></span>
|
||||||
|
<span id="cb10-6"><a href="#cb10-6" aria-hidden="true" tabindex="-1"></a> <span class="co">// Parser #EXTINF:duration,artist - title</span></span>
|
||||||
|
<span id="cb10-7"><a href="#cb10-7" aria-hidden="true" tabindex="-1"></a> <span class="co">// Gérer extensions non-standard (#EXTART, #EXTALB, #EXTIMG)</span></span>
|
||||||
|
<span id="cb10-8"><a href="#cb10-8" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb10-9"><a href="#cb10-9" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<p><strong>Dépendances</strong> : stdlib uniquement</p>
|
||||||
|
<p><strong>Limitations</strong> : Métadonnées pauvres, beaucoup de
|
||||||
|
champs <code>None</code></p>
|
||||||
|
<h5 id="plsreader-modéré---format-ini">PlsReader (⚙️ Modéré - format
|
||||||
|
INI)</h5>
|
||||||
|
<p><strong>Format</strong> :</p>
|
||||||
|
<div class="sourceCode" id="cb11"><pre
|
||||||
|
class="sourceCode ini"><code class="sourceCode ini"><span id="cb11-1"><a href="#cb11-1" aria-hidden="true" tabindex="-1"></a><span class="kw">[playlist]</span></span>
|
||||||
|
<span id="cb11-2"><a href="#cb11-2" aria-hidden="true" tabindex="-1"></a><span class="dt">NumberOfEntries</span><span class="ot">=</span><span class="dv">2</span></span>
|
||||||
|
<span id="cb11-3"><a href="#cb11-3" aria-hidden="true" tabindex="-1"></a><span class="dt">File1</span><span class="ot">=</span><span class="st">file:///music/coltrane.flac</span></span>
|
||||||
|
<span id="cb11-4"><a href="#cb11-4" aria-hidden="true" tabindex="-1"></a><span class="dt">Title1</span><span class="ot">=</span><span class="st">John Coltrane - Giant Steps</span></span>
|
||||||
|
<span id="cb11-5"><a href="#cb11-5" aria-hidden="true" tabindex="-1"></a><span class="dt">Length1</span><span class="ot">=</span><span class="dv">284</span></span></pre></div>
|
||||||
|
<div class="sourceCode" id="cb12"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb12-1"><a href="#cb12-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> PlsReader<span class="op">;</span></span>
|
||||||
|
<span id="cb12-2"><a href="#cb12-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb12-3"><a href="#cb12-3" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> PlaylistReader <span class="cf">for</span> PlsReader <span class="op">{</span></span>
|
||||||
|
<span id="cb12-4"><a href="#cb12-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> read<span class="op"><</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">></span>(reader<span class="op">:</span> R) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Jspf<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb12-5"><a href="#cb12-5" aria-hidden="true" tabindex="-1"></a> <span class="co">// HashMap<index, (file, title, duration)></span></span>
|
||||||
|
<span id="cb12-6"><a href="#cb12-6" aria-hidden="true" tabindex="-1"></a> <span class="co">// Parser FileN=..., TitleN=..., LengthN=...</span></span>
|
||||||
|
<span id="cb12-7"><a href="#cb12-7" aria-hidden="true" tabindex="-1"></a> <span class="co">// Trier par index et convertir en JspfTrack</span></span>
|
||||||
|
<span id="cb12-8"><a href="#cb12-8" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb12-9"><a href="#cb12-9" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<p><strong>Dépendances</strong> : stdlib uniquement</p>
|
||||||
|
<p><strong>Limitations</strong> : File, Title, Length seulement</p>
|
||||||
|
<h4 id="jspfwriter">JspfWriter</h4>
|
||||||
|
<div class="sourceCode" id="cb13"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb13-1"><a href="#cb13-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> JspfWriter<span class="op">;</span></span>
|
||||||
|
<span id="cb13-2"><a href="#cb13-2" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb13-3"><a href="#cb13-3" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> JspfWriter <span class="op">{</span></span>
|
||||||
|
<span id="cb13-4"><a href="#cb13-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> <span class="kw">fn</span> write<span class="op"><</span>W<span class="op">:</span> <span class="bu">Write</span><span class="op">></span>(jspf<span class="op">:</span> <span class="op">&</span>Jspf<span class="op">,</span> writer<span class="op">:</span> W) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">>;</span></span>
|
||||||
|
<span id="cb13-5"><a href="#cb13-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> <span class="kw">fn</span> write_pretty<span class="op"><</span>W<span class="op">:</span> <span class="bu">Write</span><span class="op">></span>(jspf<span class="op">:</span> <span class="op">&</span>Jspf<span class="op">,</span> writer<span class="op">:</span> W) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">>;</span></span>
|
||||||
|
<span id="cb13-6"><a href="#cb13-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> <span class="kw">fn</span> to_string(jspf<span class="op">:</span> <span class="op">&</span>Jspf) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">String</span><span class="op">>;</span></span>
|
||||||
|
<span id="cb13-7"><a href="#cb13-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> <span class="kw">fn</span> to_string_pretty(jspf<span class="op">:</span> <span class="op">&</span>Jspf) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">String</span><span class="op">>;</span></span>
|
||||||
|
<span id="cb13-8"><a href="#cb13-8" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h4 id="api-publique">API publique</h4>
|
||||||
|
<div class="sourceCode" id="cb14"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb14-1"><a href="#cb14-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">use</span> <span class="pp">model::</span><span class="op">{</span>Jspf<span class="op">,</span> JspfPlaylist<span class="op">,</span> JspfTrack<span class="op">,</span> JspfMeta<span class="op">,</span> JspfAttribution<span class="op">};</span></span>
|
||||||
|
<span id="cb14-2"><a href="#cb14-2" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">use</span> <span class="pp">reader::</span><span class="op">{</span>PlaylistReader<span class="op">,</span> JspfReader<span class="op">,</span> XspfReader<span class="op">,</span> M3uReader<span class="op">,</span> PlsReader<span class="op">};</span></span>
|
||||||
|
<span id="cb14-3"><a href="#cb14-3" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">use</span> <span class="pp">writer::</span>JspfWriter<span class="op">;</span></span>
|
||||||
|
<span id="cb14-4"><a href="#cb14-4" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb14-5"><a href="#cb14-5" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">enum</span> PlaylistFormat <span class="op">{</span></span>
|
||||||
|
<span id="cb14-6"><a href="#cb14-6" aria-hidden="true" tabindex="-1"></a> Jspf<span class="op">,</span></span>
|
||||||
|
<span id="cb14-7"><a href="#cb14-7" aria-hidden="true" tabindex="-1"></a> Xspf<span class="op">,</span></span>
|
||||||
|
<span id="cb14-8"><a href="#cb14-8" aria-hidden="true" tabindex="-1"></a> M3u8<span class="op">,</span></span>
|
||||||
|
<span id="cb14-9"><a href="#cb14-9" aria-hidden="true" tabindex="-1"></a> Pls<span class="op">,</span></span>
|
||||||
|
<span id="cb14-10"><a href="#cb14-10" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb14-11"><a href="#cb14-11" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb14-12"><a href="#cb14-12" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> PlaylistFormat <span class="op">{</span></span>
|
||||||
|
<span id="cb14-13"><a href="#cb14-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> <span class="kw">fn</span> from_extension(ext<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Option</span><span class="op"><</span><span class="dt">Self</span><span class="op">>;</span></span>
|
||||||
|
<span id="cb14-14"><a href="#cb14-14" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb14-15"><a href="#cb14-15" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb14-16"><a href="#cb14-16" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">fn</span> read_playlist<span class="op"><</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">></span>(reader<span class="op">:</span> R<span class="op">,</span> format<span class="op">:</span> PlaylistFormat) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Jspf<span class="op">>;</span></span></pre></div>
|
||||||
|
<hr />
|
||||||
|
<h3 id="crate-pmoplaylists-playlistsource">2. Crate
|
||||||
|
<code>pmoplaylists</code> (PlaylistSource)</h3>
|
||||||
|
<p><strong>Responsabilité</strong> : Implémenter
|
||||||
|
<code>MusicSource</code> pour servir playlists via UPnP</p>
|
||||||
|
<h4 id="structures-principales">Structures principales</h4>
|
||||||
|
<div class="sourceCode" id="cb15"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb15-1"><a href="#cb15-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> PlaylistSource <span class="op">{</span></span>
|
||||||
|
<span id="cb15-2"><a href="#cb15-2" aria-hidden="true" tabindex="-1"></a> root_path<span class="op">:</span> <span class="dt">PathBuf</span><span class="op">,</span></span>
|
||||||
|
<span id="cb15-3"><a href="#cb15-3" aria-hidden="true" tabindex="-1"></a> playlists<span class="op">:</span> Arc<span class="op"><</span>RwLock<span class="op"><</span>HashMap<span class="op"><</span><span class="dt">String</span><span class="op">,</span> ParsedPlaylist<span class="op">>>>,</span></span>
|
||||||
|
<span id="cb15-4"><a href="#cb15-4" aria-hidden="true" tabindex="-1"></a> containers<span class="op">:</span> Arc<span class="op"><</span>RwLock<span class="op"><</span>HashMap<span class="op"><</span><span class="dt">PathBuf</span><span class="op">,</span> ContainerMetadata<span class="op">>>>,</span></span>
|
||||||
|
<span id="cb15-5"><a href="#cb15-5" aria-hidden="true" tabindex="-1"></a> watcher<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="pp">notify::</span>RecommendedWatcher<span class="op">>,</span></span>
|
||||||
|
<span id="cb15-6"><a href="#cb15-6" aria-hidden="true" tabindex="-1"></a> base_url<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
|
||||||
|
<span id="cb15-7"><a href="#cb15-7" aria-hidden="true" tabindex="-1"></a> update_counter<span class="op">:</span> Arc<span class="op"><</span>RwLock<span class="op"><</span><span class="dt">u32</span><span class="op">>>,</span></span>
|
||||||
|
<span id="cb15-8"><a href="#cb15-8" aria-hidden="true" tabindex="-1"></a> last_change<span class="op">:</span> Arc<span class="op"><</span>RwLock<span class="op"><</span>SystemTime<span class="op">>>,</span></span>
|
||||||
|
<span id="cb15-9"><a href="#cb15-9" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb15-10"><a href="#cb15-10" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb15-11"><a href="#cb15-11" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> ParsedPlaylist <span class="op">{</span></span>
|
||||||
|
<span id="cb15-12"><a href="#cb15-12" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> metadata<span class="op">:</span> PlaylistMetadata<span class="op">,</span></span>
|
||||||
|
<span id="cb15-13"><a href="#cb15-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> tracks<span class="op">:</span> <span class="dt">Vec</span><span class="op"><</span>PlaylistTrack<span class="op">>,</span></span>
|
||||||
|
<span id="cb15-14"><a href="#cb15-14" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> source_path<span class="op">:</span> <span class="dt">PathBuf</span><span class="op">,</span></span>
|
||||||
|
<span id="cb15-15"><a href="#cb15-15" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> format<span class="op">:</span> PlaylistFormat<span class="op">,</span></span>
|
||||||
|
<span id="cb15-16"><a href="#cb15-16" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb15-17"><a href="#cb15-17" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb15-18"><a href="#cb15-18" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> ContainerMetadata <span class="op">{</span></span>
|
||||||
|
<span id="cb15-19"><a href="#cb15-19" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> title<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb15-20"><a href="#cb15-20" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> description<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb15-21"><a href="#cb15-21" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> creator<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb15-22"><a href="#cb15-22" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> image<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb15-23"><a href="#cb15-23" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> date<span class="op">:</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||||
|
<span id="cb15-24"><a href="#cb15-24" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> meta<span class="op">:</span> <span class="dt">Vec</span><span class="op"><</span>MetaEntry<span class="op">>,</span></span>
|
||||||
|
<span id="cb15-25"><a href="#cb15-25" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb15-26"><a href="#cb15-26" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb15-27"><a href="#cb15-27" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> ContainerMetadataFile <span class="op">{</span></span>
|
||||||
|
<span id="cb15-28"><a href="#cb15-28" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> container<span class="op">:</span> ContainerMetadata<span class="op">,</span></span>
|
||||||
|
<span id="cb15-29"><a href="#cb15-29" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<h4 id="fonctionnalités">Fonctionnalités</h4>
|
||||||
|
<ol type="1">
|
||||||
|
<li><strong>Scan hiérarchique</strong> : Parser récursivement dossiers +
|
||||||
|
<code>metadata.json</code> + playlists</li>
|
||||||
|
<li><strong>Cache</strong> : Éviter re-parsing (playlists +
|
||||||
|
conteneurs)</li>
|
||||||
|
<li><strong>Hot reload</strong> : <code>notify</code> pour détecter
|
||||||
|
changements</li>
|
||||||
|
<li><strong>Browse UPnP</strong> : Générer DIDL-Lite avec métadonnées
|
||||||
|
conteneurs</li>
|
||||||
|
<li><strong>Content resolution</strong> : Résoudre URIs via
|
||||||
|
<code>SourceCacheManager</code></li>
|
||||||
|
<li><strong>Cover art</strong> : Servir images playlists, tracks,
|
||||||
|
conteneurs</li>
|
||||||
|
</ol>
|
||||||
|
<h4 id="object-ids">Object IDs</h4>
|
||||||
|
<pre><code>playlists # Racine
|
||||||
|
playlists:category:{path} # Catégorie (dossier)
|
||||||
|
playlists:playlist:{id} # Playlist
|
||||||
|
playlists:playlist:{id}:track:{index} # Track dans playlist</pre>
|
||||||
|
<h4 id="gestion-metadata.json">Gestion <code>metadata.json</code></h4>
|
||||||
|
<div class="sourceCode" id="cb17"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb17-1"><a href="#cb17-1" aria-hidden="true" tabindex="-1"></a><span class="kw">fn</span> load_container_metadata(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> dir_path<span class="op">:</span> <span class="op">&</span><span class="dt">Path</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>ContainerMetadata<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb17-2"><a href="#cb17-2" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> metadata_path <span class="op">=</span> dir_path<span class="op">.</span>join(<span class="st">"metadata.json"</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb17-3"><a href="#cb17-3" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb17-4"><a href="#cb17-4" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> metadata_path<span class="op">.</span>exists() <span class="op">{</span></span>
|
||||||
|
<span id="cb17-5"><a href="#cb17-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> content <span class="op">=</span> <span class="pp">fs::</span>read_to_string(<span class="op">&</span>metadata_path)<span class="op">?;</span></span>
|
||||||
|
<span id="cb17-6"><a href="#cb17-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> file<span class="op">:</span> ContainerMetadataFile <span class="op">=</span> <span class="pp">serde_json::</span>from_str(<span class="op">&</span>content)<span class="op">?;</span></span>
|
||||||
|
<span id="cb17-7"><a href="#cb17-7" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(file<span class="op">.</span>container)</span>
|
||||||
|
<span id="cb17-8"><a href="#cb17-8" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span> <span class="cf">else</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb17-9"><a href="#cb17-9" aria-hidden="true" tabindex="-1"></a> <span class="co">// Fallback : nom du répertoire</span></span>
|
||||||
|
<span id="cb17-10"><a href="#cb17-10" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(ContainerMetadata <span class="op">{</span></span>
|
||||||
|
<span id="cb17-11"><a href="#cb17-11" aria-hidden="true" tabindex="-1"></a> title<span class="op">:</span> <span class="cn">Some</span>(dir_path<span class="op">.</span>file_name()<span class="op">?.</span>to_str()<span class="op">?.</span>to_string())<span class="op">,</span></span>
|
||||||
|
<span id="cb17-12"><a href="#cb17-12" aria-hidden="true" tabindex="-1"></a> <span class="op">..</span><span class="bu">Default</span><span class="pp">::</span><span class="kw">default</span>()</span>
|
||||||
|
<span id="cb17-13"><a href="#cb17-13" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
|
||||||
|
<span id="cb17-14"><a href="#cb17-14" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb17-15"><a href="#cb17-15" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<hr />
|
||||||
|
<h3 id="extension-pmoconfig">3. Extension pmoconfig</h3>
|
||||||
|
<p><strong>Fichier</strong> :
|
||||||
|
<code>pmoplaylists/src/config_ext.rs</code></p>
|
||||||
|
<p><strong>Pattern</strong> : <a
|
||||||
|
href="../Architecture/pmoconfig_ext.md">pmoconfig_ext.md</a></p>
|
||||||
|
<div class="sourceCode" id="cb18"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb18-1"><a href="#cb18-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmoconfig::</span>Config<span class="op">;</span></span>
|
||||||
|
<span id="cb18-2"><a href="#cb18-2" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">std::path::</span><span class="op">{</span><span class="dt">Path</span><span class="op">,</span> <span class="dt">PathBuf</span><span class="op">};</span></span>
|
||||||
|
<span id="cb18-3"><a href="#cb18-3" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb18-4"><a href="#cb18-4" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> DEFAULT_PLAYLISTS_DIR<span class="op">:</span> <span class="op">&</span><span class="dt">str</span> <span class="op">=</span> <span class="st">"playlists"</span><span class="op">;</span></span>
|
||||||
|
<span id="cb18-5"><a href="#cb18-5" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb18-6"><a href="#cb18-6" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">trait</span> PlaylistSourceConfigExt <span class="op">{</span></span>
|
||||||
|
<span id="cb18-7"><a href="#cb18-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> get_playlists_dir(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">PathBuf</span><span class="op">;</span></span>
|
||||||
|
<span id="cb18-8"><a href="#cb18-8" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> set_playlists_dir<span class="op"><</span>P<span class="op">:</span> <span class="bu">AsRef</span><span class="op"><</span><span class="dt">Path</span><span class="op">>></span>(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> path<span class="op">:</span> P) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>()<span class="op">>;</span></span>
|
||||||
|
<span id="cb18-9"><a href="#cb18-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> get_playlists_enabled(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">bool</span><span class="op">;</span></span>
|
||||||
|
<span id="cb18-10"><a href="#cb18-10" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> set_playlists_enabled(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> enabled<span class="op">:</span> <span class="dt">bool</span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>()<span class="op">>;</span></span>
|
||||||
|
<span id="cb18-11"><a href="#cb18-11" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> get_playlists_supported_formats(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">Vec</span><span class="op"><</span><span class="dt">String</span><span class="op">>;</span></span>
|
||||||
|
<span id="cb18-12"><a href="#cb18-12" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> set_playlists_supported_formats(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> formats<span class="op">:</span> <span class="dt">Vec</span><span class="op"><</span><span class="dt">String</span><span class="op">></span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>()<span class="op">>;</span></span>
|
||||||
|
<span id="cb18-13"><a href="#cb18-13" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
|
||||||
|
<span id="cb18-14"><a href="#cb18-14" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb18-15"><a href="#cb18-15" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> PlaylistSourceConfigExt <span class="cf">for</span> Config <span class="op">{</span></span>
|
||||||
|
<span id="cb18-16"><a href="#cb18-16" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> get_playlists_dir(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">PathBuf</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb18-17"><a href="#cb18-17" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>get_managed_dir(<span class="st">"sources.playlists.directory"</span><span class="op">,</span> DEFAULT_PLAYLISTS_DIR)</span>
|
||||||
|
<span id="cb18-18"><a href="#cb18-18" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>expect(<span class="st">"Failed to get playlists directory"</span>)</span>
|
||||||
|
<span id="cb18-19"><a href="#cb18-19" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb18-20"><a href="#cb18-20" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb18-21"><a href="#cb18-21" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> set_playlists_dir<span class="op"><</span>P<span class="op">:</span> <span class="bu">AsRef</span><span class="op"><</span><span class="dt">Path</span><span class="op">>></span>(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> path<span class="op">:</span> P) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>()<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb18-22"><a href="#cb18-22" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>set_managed_dir(<span class="st">"sources.playlists.directory"</span><span class="op">,</span> path)</span>
|
||||||
|
<span id="cb18-23"><a href="#cb18-23" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb18-24"><a href="#cb18-24" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb18-25"><a href="#cb18-25" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> get_playlists_enabled(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">bool</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb18-26"><a href="#cb18-26" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>get_value(<span class="st">"sources.playlists.enabled"</span>)</span>
|
||||||
|
<span id="cb18-27"><a href="#cb18-27" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>unwrap_or_else(<span class="op">|</span>_<span class="op">|</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb18-28"><a href="#cb18-28" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> _ <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>set_value(<span class="st">"sources.playlists.enabled"</span><span class="op">,</span> <span class="cn">true</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb18-29"><a href="#cb18-29" aria-hidden="true" tabindex="-1"></a> <span class="cn">true</span></span>
|
||||||
|
<span id="cb18-30"><a href="#cb18-30" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
|
||||||
|
<span id="cb18-31"><a href="#cb18-31" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb18-32"><a href="#cb18-32" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb18-33"><a href="#cb18-33" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> set_playlists_enabled(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> enabled<span class="op">:</span> <span class="dt">bool</span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>()<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb18-34"><a href="#cb18-34" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>set_value(<span class="st">"sources.playlists.enabled"</span><span class="op">,</span> enabled)</span>
|
||||||
|
<span id="cb18-35"><a href="#cb18-35" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb18-36"><a href="#cb18-36" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb18-37"><a href="#cb18-37" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> get_playlists_supported_formats(<span class="op">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">Vec</span><span class="op"><</span><span class="dt">String</span><span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb18-38"><a href="#cb18-38" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>get_value(<span class="st">"sources.playlists.formats"</span>)</span>
|
||||||
|
<span id="cb18-39"><a href="#cb18-39" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>unwrap_or_else(<span class="op">|</span>_<span class="op">|</span> <span class="op">{</span></span>
|
||||||
|
<span id="cb18-40"><a href="#cb18-40" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">default</span> <span class="op">=</span> <span class="pp">vec!</span>[<span class="st">"jspf"</span><span class="op">.</span>into()<span class="op">,</span> <span class="st">"xspf"</span><span class="op">.</span>into()<span class="op">,</span> <span class="st">"m3u8"</span><span class="op">.</span>into()<span class="op">,</span> <span class="st">"pls"</span><span class="op">.</span>into()]<span class="op">;</span></span>
|
||||||
|
<span id="cb18-41"><a href="#cb18-41" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> _ <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>set_value(<span class="st">"sources.playlists.formats"</span><span class="op">,</span> <span class="op">&</span><span class="kw">default</span>)<span class="op">;</span></span>
|
||||||
|
<span id="cb18-42"><a href="#cb18-42" aria-hidden="true" tabindex="-1"></a> <span class="kw">default</span></span>
|
||||||
|
<span id="cb18-43"><a href="#cb18-43" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
|
||||||
|
<span id="cb18-44"><a href="#cb18-44" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb18-45"><a href="#cb18-45" aria-hidden="true" tabindex="-1"></a> </span>
|
||||||
|
<span id="cb18-46"><a href="#cb18-46" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> set_playlists_supported_formats(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> formats<span class="op">:</span> <span class="dt">Vec</span><span class="op"><</span><span class="dt">String</span><span class="op">></span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>()<span class="op">></span> <span class="op">{</span></span>
|
||||||
|
<span id="cb18-47"><a href="#cb18-47" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>set_value(<span class="st">"sources.playlists.formats"</span><span class="op">,</span> formats)</span>
|
||||||
|
<span id="cb18-48"><a href="#cb18-48" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
|
||||||
|
<span id="cb18-49"><a href="#cb18-49" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<p><strong>Config YAML</strong> :</p>
|
||||||
|
<div class="sourceCode" id="cb19"><pre
|
||||||
|
class="sourceCode yaml"><code class="sourceCode yaml"><span id="cb19-1"><a href="#cb19-1" aria-hidden="true" tabindex="-1"></a><span class="fu">sources</span><span class="kw">:</span></span>
|
||||||
|
<span id="cb19-2"><a href="#cb19-2" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">playlists</span><span class="kw">:</span></span>
|
||||||
|
<span id="cb19-3"><a href="#cb19-3" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">enabled</span><span class="kw">:</span><span class="at"> </span><span class="ch">true</span></span>
|
||||||
|
<span id="cb19-4"><a href="#cb19-4" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">directory</span><span class="kw">:</span><span class="at"> </span><span class="st">"playlists"</span></span>
|
||||||
|
<span id="cb19-5"><a href="#cb19-5" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">formats</span><span class="kw">:</span></span>
|
||||||
|
<span id="cb19-6"><a href="#cb19-6" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="kw">-</span><span class="at"> jspf</span></span>
|
||||||
|
<span id="cb19-7"><a href="#cb19-7" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="kw">-</span><span class="at"> xspf</span></span>
|
||||||
|
<span id="cb19-8"><a href="#cb19-8" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="kw">-</span><span class="at"> m3u8</span></span>
|
||||||
|
<span id="cb19-9"><a href="#cb19-9" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="kw">-</span><span class="at"> pls</span></span></pre></div>
|
||||||
|
<p><strong>Utilisation</strong> :</p>
|
||||||
|
<div class="sourceCode" id="cb20"><pre
|
||||||
|
class="sourceCode rust"><code class="sourceCode rust"><span id="cb20-1"><a href="#cb20-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmoconfig::</span>Config<span class="op">;</span></span>
|
||||||
|
<span id="cb20-2"><a href="#cb20-2" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmoplaylists::config_ext::</span>PlaylistSourceConfigExt<span class="op">;</span></span>
|
||||||
|
<span id="cb20-3"><a href="#cb20-3" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb20-4"><a href="#cb20-4" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> config <span class="op">=</span> <span class="pp">Config::</span>load()<span class="op">?;</span></span>
|
||||||
|
<span id="cb20-5"><a href="#cb20-5" aria-hidden="true" tabindex="-1"></a></span>
|
||||||
|
<span id="cb20-6"><a href="#cb20-6" aria-hidden="true" tabindex="-1"></a><span class="cf">if</span> config<span class="op">.</span>get_playlists_enabled() <span class="op">{</span></span>
|
||||||
|
<span id="cb20-7"><a href="#cb20-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> playlists_dir <span class="op">=</span> config<span class="op">.</span>get_playlists_dir()<span class="op">;</span></span>
|
||||||
|
<span id="cb20-8"><a href="#cb20-8" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> playlist_source <span class="op">=</span> <span class="pp">PlaylistSource::</span>new(playlists_dir<span class="op">,</span> config<span class="op">.</span>clone())<span class="op">?;</span></span>
|
||||||
|
<span id="cb20-9"><a href="#cb20-9" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
|
||||||
|
<hr />
|
||||||
|
<h2 id="intégration-musicbrainz-optionnelle---phase-2">🔌 Intégration
|
||||||
|
MusicBrainz (optionnelle - Phase 2)</h2>
|
||||||
|
<h3 id="crate-recommandée-musicbrainz_rs">Crate recommandée :
|
||||||
|
<code>musicbrainz_rs</code></h3>
|
||||||
|
<p><a href="https://crates.io/crates/musicbrainz_rs">musicbrainz_rs</a>
|
||||||
|
v0.5+ - Client async/blocking - Rate limiting automatique (1 req/sec) -
|
||||||
|
Support CoverArt Archive - MSRV: Rust 1.71.1</p>
|
||||||
|
<h3 id="cas-dusage">Cas d’usage</h3>
|
||||||
|
<ol type="1">
|
||||||
|
<li><p><strong>Résolution d’identifiants</strong> :</p>
|
||||||
|
<div class="sourceCode" id="cb21"><pre
|
||||||
|
class="sourceCode json"><code class="sourceCode json"><span id="cb21-1"><a href="#cb21-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span><span class="dt">"identifier"</span><span class="fu">:</span> <span class="ot">[</span><span class="st">"musicbrainz://recording/abc123"</span><span class="ot">]</span><span class="fu">,</span> <span class="dt">"title"</span><span class="fu">:</span> <span class="kw">null</span><span class="fu">}</span></span></pre></div>
|
||||||
|
<p>→ Récupérer métadonnées depuis MusicBrainz</p></li>
|
||||||
|
<li><p><strong>Enrichissement playlists pauvres</strong> : M3U8/PLS →
|
||||||
|
MusicBrainz → métadonnées complètes</p></li>
|
||||||
|
<li><p><strong>Cover art</strong> : CoverArt Archive</p></li>
|
||||||
|
</ol>
|
||||||
|
<h3 id="configuration">Configuration</h3>
|
||||||
|
<div class="sourceCode" id="cb22"><pre
|
||||||
|
class="sourceCode yaml"><code class="sourceCode yaml"><span id="cb22-1"><a href="#cb22-1" aria-hidden="true" tabindex="-1"></a><span class="fu">sources</span><span class="kw">:</span></span>
|
||||||
|
<span id="cb22-2"><a href="#cb22-2" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">playlists</span><span class="kw">:</span></span>
|
||||||
|
<span id="cb22-3"><a href="#cb22-3" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">musicbrainz</span><span class="kw">:</span></span>
|
||||||
|
<span id="cb22-4"><a href="#cb22-4" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">enabled</span><span class="kw">:</span><span class="at"> </span><span class="ch">false</span></span>
|
||||||
|
<span id="cb22-5"><a href="#cb22-5" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">enrich_metadata</span><span class="kw">:</span><span class="at"> </span><span class="ch">false</span></span>
|
||||||
|
<span id="cb22-6"><a href="#cb22-6" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">rate_limit_per_sec</span><span class="kw">:</span><span class="at"> </span><span class="dv">1</span></span></pre></div>
|
||||||
|
<p><strong>Stratégie</strong> : - <strong>Phase 1 (MVP)</strong> : Ne
|
||||||
|
pas implémenter, stocker identifiants tel quel - <strong>Phase
|
||||||
|
2</strong> : Dépendance optionnelle, service asynchrone,
|
||||||
|
configurable</p>
|
||||||
|
<hr />
|
||||||
|
<h2 id="prochaines-étapes">📝 Prochaines étapes</h2>
|
||||||
|
<ol type="1">
|
||||||
|
<li>✅ Choix format : JSPF central</li>
|
||||||
|
<li>✅ Modèle données : Structures JSPF</li>
|
||||||
|
<li>✅ Extension pmoconfig : Trait défini</li>
|
||||||
|
<li>⏳ <strong>Implémenter <code>pmojspf</code></strong> :
|
||||||
|
<ul>
|
||||||
|
<li><code>JspfReader</code> (serde_json)</li>
|
||||||
|
<li><code>XspfReader</code> (xml-rs ou crate xspf)</li>
|
||||||
|
<li><code>M3uReader</code> (parsing ligne par ligne)</li>
|
||||||
|
<li><code>PlsReader</code> (format INI)</li>
|
||||||
|
<li><code>JspfWriter</code> (serde_json)</li>
|
||||||
|
</ul></li>
|
||||||
|
<li>⏳ <strong>Implémenter <code>pmoplaylists</code></strong> :
|
||||||
|
<ul>
|
||||||
|
<li><code>PlaylistSource</code> (trait <code>MusicSource</code>)</li>
|
||||||
|
<li>Scan hiérarchique + cache</li>
|
||||||
|
<li>Hot reload (notify)</li>
|
||||||
|
<li>Browse UPnP (DIDL-Lite)</li>
|
||||||
|
<li>Gestion <code>metadata.json</code></li>
|
||||||
|
</ul></li>
|
||||||
|
<li>⏳ Tests avec clients UPnP</li>
|
||||||
|
</ol>
|
||||||
|
<hr />
|
||||||
|
<h2 id="sources">📚 Sources</h2>
|
||||||
|
<h3 id="spécifications">Spécifications</h3>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://www.xspf.org/spec">XSPF Spec</a></li>
|
||||||
|
<li><a href="https://www.xspf.org/jspf">JSPF Spec</a></li>
|
||||||
|
<li><a href="https://en.wikipedia.org/wiki/M3U">M3U - Wikipedia</a></li>
|
||||||
|
<li><a href="https://en.wikipedia.org/wiki/PLS_(file_format)">PLS -
|
||||||
|
Wikipedia</a></li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="crates-rust">Crates Rust</h3>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://crates.io/crates/xspf">xspf</a> - Parser XML
|
||||||
|
XSPF</li>
|
||||||
|
<li><a href="https://crates.io/crates/musicbrainz_rs">musicbrainz_rs</a>
|
||||||
|
- API MusicBrainz</li>
|
||||||
|
<li><a href="https://musicbrainz.org/doc/MusicBrainz_API">MusicBrainz
|
||||||
|
API Docs</a></li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
55
Blackboard_HTML/Todo_config_ext.html
Normal file
55
Blackboard_HTML/Todo_config_ext.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>config_ext</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
||||||
|
<script type="module">
|
||||||
|
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||||
|
mermaid.initialize({startOnLoad: true, theme: "default"});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
pre.mermaid {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="markdown-body">
|
||||||
|
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
|
||||||
|
<p><strong>Il faut suivre les instructions générales placées dans le
|
||||||
|
fichier : Blackboard/Rules.md</strong></p>
|
||||||
|
<p>Partir des fichiers suivants:</p>
|
||||||
|
<ul>
|
||||||
|
<li>pmocovers/src/config_ext.rs</li>
|
||||||
|
<li>pmoaudiocache/src/config_ext.rs</li>
|
||||||
|
<li>pmoqobuz/src/config_ext.rs</li>
|
||||||
|
<li>pmocache/src/config_ext.rs</li>
|
||||||
|
<li>pmoconfig/PASSWORD_ENCRYPTION.md</li>
|
||||||
|
<li>pmoupnp/src/config_ext.rs</li>
|
||||||
|
<li>pmoparadise/src/config_ext.rs</li>
|
||||||
|
</ul>
|
||||||
|
<p>réalise une fiche descriptive sur le pattern à réaliser pour
|
||||||
|
implémenter un trait d’extension de PMOConfig (pmoconfig::Config).</p>
|
||||||
|
<p>Le résultat sera une documentation d’implémentation qui sera placé
|
||||||
|
dans le fichier:
|
||||||
|
<code>Blackboard/Architecture/pmoconfig_ext.md</code></p>
|
||||||
|
<p>Reste bien focalisé sur l’objectif principal.</p>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
52
Blackboard_HTML/Todo_music_source.html
Normal file
52
Blackboard_HTML/Todo_music_source.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>music_source</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
||||||
|
<script type="module">
|
||||||
|
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||||
|
mermaid.initialize({startOnLoad: true, theme: "default"});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
pre.mermaid {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="markdown-body">
|
||||||
|
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
|
||||||
|
<p><strong>Il faut suivre les instructions générales placées dans le
|
||||||
|
fichier : Blackboard/Rules.md</strong></p>
|
||||||
|
<p>Partir des fichiers suivants:</p>
|
||||||
|
<ul>
|
||||||
|
<li>pmoparadise/src/source.rs</li>
|
||||||
|
<li>pmoqobuz/src/source.rs</li>
|
||||||
|
<li>pmosource/README.md</li>
|
||||||
|
<li>pmosource/ARCHITECTURE.md</li>
|
||||||
|
</ul>
|
||||||
|
<p>D’écrire dans un fichier d’architecture L’implémentation d’une
|
||||||
|
nouvelle MusicSource.</p>
|
||||||
|
<p>Le résultat sera une documentation d’implémentation qui sera placé
|
||||||
|
dans le fichier:
|
||||||
|
<code>Blackboard/Architecture/music_source.md</code></p>
|
||||||
|
<p>Reste bien focalisé sur l’objectif principal.</p>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
40
Blackboard_HTML/index.html
Normal file
40
Blackboard_HTML/index.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr"><head><meta charset="utf-8">
|
||||||
|
<title>PMOMusic Blackboard</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:sans-serif;margin:20px;background:#f5f5f5}
|
||||||
|
h1{color:#2c3e50}ul{list-style:none;padding:0}
|
||||||
|
li{margin:10px 0}a{color:#3498db;text-decoration:none}
|
||||||
|
a:hover{text-decoration:underline}.category{margin-top:30px}
|
||||||
|
.category h2{color:#e74c3c;border-bottom:2px solid #e74c3c;padding-bottom:5px}
|
||||||
|
</style></head><body>
|
||||||
|
<h1>📋 PMOMusic Blackboard</h1>
|
||||||
|
<div class='category'><h2>Architecture</h2><ul>
|
||||||
|
<li><a href='Architecture_music_source.html'>music_source</a></li>
|
||||||
|
<li><a href='Architecture_pmoconfig_ext.html'>pmoconfig_ext</a></li>
|
||||||
|
<li><a href='Architecture_pmoserver_ext.html'>pmoserver_ext</a></li>
|
||||||
|
</ul></div>
|
||||||
|
<div class='category'><h2>ToThinkAbout</h2><ul>
|
||||||
|
<li><a href='ToThinkAbout_MusicBoxSource.html'>MusicBoxSource</a></li>
|
||||||
|
<li><a href='ToThinkAbout_PlayListSource.html'>PlayListSource</a></li>
|
||||||
|
</ul></div>
|
||||||
|
<div class='category'><h2>ToDiscuss</h2><ul>
|
||||||
|
<li><a href='ToDiscuss_Pinnable_cache_item.html'>Pinnable_cache_item</a></li>
|
||||||
|
<li><a href='ToDiscuss_pmoserver_ext.html'>pmoserver_ext</a></li>
|
||||||
|
</ul></div>
|
||||||
|
<div class='category'><h2>Todo</h2><ul>
|
||||||
|
<li><a href='Todo_config_ext.html'>config_ext</a></li>
|
||||||
|
<li><a href='Todo_music_source.html'>music_source</a></li>
|
||||||
|
</ul></div>
|
||||||
|
<div class='category'><h2>Done</h2><ul>
|
||||||
|
<li><a href='Done_Pinnable_cache_item.html'>Pinnable_cache_item</a></li>
|
||||||
|
<li><a href='Done_WeabApp_debouncingSSE.html'>WeabApp_debouncingSSE</a></li>
|
||||||
|
</ul></div>
|
||||||
|
<div class='category'><h2>Report</h2><ul>
|
||||||
|
<li><a href='Report_Pinnable_cache_item.html'>Pinnable_cache_item</a></li>
|
||||||
|
<li><a href='Report_WeabApp_debouncingSSE.html'>WeabApp_debouncingSSE</a></li>
|
||||||
|
<li><a href='Report_config_ext.html'>config_ext</a></li>
|
||||||
|
<li><a href='Report_music_source.html'>music_source</a></li>
|
||||||
|
<li><a href='Report_pmoserver_ext.html'>pmoserver_ext</a></li>
|
||||||
|
</ul></div>
|
||||||
|
</body></html>
|
||||||
4945
Cargo.lock
generated
4945
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
55
Cargo.toml
55
Cargo.toml
@@ -1,3 +1,56 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp"]
|
members = [
|
||||||
|
"PMOMusic",
|
||||||
|
"pmoupnp",
|
||||||
|
"pmomediarenderer",
|
||||||
|
"pmomediaserver",
|
||||||
|
"pmoconfig",
|
||||||
|
"pmoutils",
|
||||||
|
"pmodidl",
|
||||||
|
"pmoserver",
|
||||||
|
"pmoapp",
|
||||||
|
"pmocache",
|
||||||
|
"pmocovers",
|
||||||
|
"pmoaudiocache",
|
||||||
|
"pmoaudio",
|
||||||
|
"pmoqobuz",
|
||||||
|
"pmoparadise",
|
||||||
|
"pmosource",
|
||||||
|
"pmoplaylist",
|
||||||
|
"pmoflac",
|
||||||
|
"pmometadata", "pmocontrol",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
# Core
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1.35", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
anyhow = "1.0"
|
||||||
|
thiserror = "2.0" # ⚠️ Unifier sur 2.0 (vous avez 1.0 et 2.0)
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
tracing = "0.1.41"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||||
|
|
||||||
|
# HTTP/XML
|
||||||
|
reqwest = { version = "0.12", default-features = false }
|
||||||
|
ureq = "3.1"
|
||||||
|
quick-xml = { version = "0.38", features = ["serialize"] } # ⚠️ Unifier 0.37→0.38
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
uuid = { version = "1.18", features = ["v4"] }
|
||||||
|
crossbeam-channel = "0.5"
|
||||||
|
rand = "0.9"
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
tokio-test = "0.4"
|
||||||
|
|||||||
72
DEPENDENCIES.md
Normal file
72
DEPENDENCIES.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
## Diagramme des dépendances PMOMusic (crates internes)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
|
||||||
|
PMOMusic --> pmoapp
|
||||||
|
PMOMusic --> pmoaudio_ext
|
||||||
|
PMOMusic --> pmoaudiocache
|
||||||
|
PMOMusic --> pmocovers
|
||||||
|
PMOMusic --> pmoconfig
|
||||||
|
PMOMusic --> pmoserver
|
||||||
|
PMOMusic --> pmosource
|
||||||
|
PMOMusic --> pmoupnp
|
||||||
|
PMOMusic --> pmomediaserver
|
||||||
|
PMOMusic --> pmomediarenderer
|
||||||
|
PMOMusic --> pmoqobuz
|
||||||
|
PMOMusic --> pmoparadise
|
||||||
|
|
||||||
|
pmoaudio_ext --> pmoaudio
|
||||||
|
pmoaudio_ext --> pmocovers
|
||||||
|
pmoaudio_ext --> pmocache
|
||||||
|
pmoaudio_ext --> pmoaudiocache
|
||||||
|
pmoaudio_ext --> pmometadata
|
||||||
|
pmoaudio_ext --> pmoplaylist
|
||||||
|
|
||||||
|
pmoaudiocache --> pmocache
|
||||||
|
pmoaudiocache --> pmometadata
|
||||||
|
|
||||||
|
pmocovers --> pmocache
|
||||||
|
|
||||||
|
pmoplaylist --> pmocache
|
||||||
|
pmoplaylist --> pmoaudiocache
|
||||||
|
pmoplaylist --> pmometadata
|
||||||
|
pmoplaylist --> pmodidl
|
||||||
|
|
||||||
|
pmosource --> pmoaudiocache
|
||||||
|
pmosource --> pmocovers
|
||||||
|
pmosource --> pmocache
|
||||||
|
pmosource --> pmoplaylist
|
||||||
|
pmosource --> pmodidl
|
||||||
|
pmosource --> pmoconfig
|
||||||
|
pmosource --> pmoserver
|
||||||
|
pmosource --> pmoupnp
|
||||||
|
|
||||||
|
pmoparadise --> pmosource
|
||||||
|
pmoparadise --> pmoaudiocache
|
||||||
|
pmoparadise --> pmoplaylist
|
||||||
|
pmoparadise --> pmoserver
|
||||||
|
pmoparadise --> pmoconfig
|
||||||
|
|
||||||
|
pmoqobuz --> pmosource
|
||||||
|
pmoqobuz --> pmoaudiocache
|
||||||
|
pmoqobuz --> pmocovers
|
||||||
|
pmoqobuz --> pmoserver
|
||||||
|
pmoqobuz --> pmoconfig
|
||||||
|
|
||||||
|
pmomediaserver --> pmoserver
|
||||||
|
pmomediaserver --> pmosource
|
||||||
|
pmomediaserver --> pmoconfig
|
||||||
|
pmomediaserver --> pmocovers
|
||||||
|
pmomediaserver --> pmoaudiocache
|
||||||
|
pmomediaserver --> pmoplaylist
|
||||||
|
|
||||||
|
pmoupnp --> pmoserver
|
||||||
|
pmoupnp --> pmocovers
|
||||||
|
pmoupnp --> pmoaudiocache
|
||||||
|
pmoupnp --> pmoplaylist
|
||||||
|
pmoupnp --> pmocache
|
||||||
|
pmoupnp --> pmoconfig
|
||||||
|
```
|
||||||
|
|
||||||
|
> Flèches = “dépend de”. Dépendances externes non représentées. Cette vue correspond aux features activées par défaut dans la workspace.
|
||||||
302
DOCKER.md
Normal file
302
DOCKER.md
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
# Docker Deployment Guide for PMOMusic
|
||||||
|
|
||||||
|
Ce guide explique comment construire et déployer PMOMusic avec Docker.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Le Dockerfile utilise une approche multi-stage pour créer une image minimale :
|
||||||
|
|
||||||
|
1. **Stage 1 (webapp-builder)** : Compile l'application Vue.js avec Node.js
|
||||||
|
2. **Stage 2 (rust-builder)** : Compile le binaire Rust avec toutes ses dépendances
|
||||||
|
3. **Stage 3 (runtime)** : Image finale minimale Debian Slim avec uniquement le binaire et les bibliothèques runtime
|
||||||
|
|
||||||
|
### Avantages
|
||||||
|
|
||||||
|
- **Binaire auto-contenu** : L'application web est embarquée dans le binaire Rust
|
||||||
|
- **Image minimale** : ~200-300MB (vs plusieurs GB pour les images de build)
|
||||||
|
- **Sécurité** : Exécution en tant qu'utilisateur non-root
|
||||||
|
- **Reproductibilité** : Build complet et déterministe
|
||||||
|
|
||||||
|
## Build de l'image
|
||||||
|
|
||||||
|
### Option 1 : Build manuel avec Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build l'image
|
||||||
|
docker build -t pmomusic:latest .
|
||||||
|
|
||||||
|
# Le build prend environ 10-15 minutes selon votre machine
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2 : Build avec docker-compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build et démarre le conteneur
|
||||||
|
docker-compose up --build
|
||||||
|
|
||||||
|
# Ou juste build
|
||||||
|
docker-compose build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build optimisé avec cache
|
||||||
|
|
||||||
|
Pour accélérer les builds successifs, Docker réutilise les couches en cache :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build avec cache
|
||||||
|
docker build -t pmomusic:latest .
|
||||||
|
|
||||||
|
# Build sans cache (force rebuild complet)
|
||||||
|
docker build --no-cache -t pmomusic:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exécution du conteneur
|
||||||
|
|
||||||
|
### Option 1 : Avec docker-compose (recommandé)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Démarrer en arrière-plan
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Voir les logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Arrêter
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Redémarrer
|
||||||
|
docker-compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2 : Avec docker run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run en mode interactif
|
||||||
|
docker run -it --rm \
|
||||||
|
--name pmomusic \
|
||||||
|
--network host \
|
||||||
|
-v $(pwd)/config:/home/pmomusic/.pmomusic \
|
||||||
|
-v $(pwd)/cache:/home/pmomusic/cache \
|
||||||
|
pmomusic:latest
|
||||||
|
|
||||||
|
# Run en mode détaché
|
||||||
|
docker run -d \
|
||||||
|
--name pmomusic \
|
||||||
|
--network host \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-v $(pwd)/config:/home/pmomusic/.pmomusic \
|
||||||
|
-v $(pwd)/cache:/home/pmomusic/cache \
|
||||||
|
pmomusic:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Ports
|
||||||
|
|
||||||
|
Par défaut, PMOMusic écoute sur le port **8080**. Vous pouvez modifier cela :
|
||||||
|
|
||||||
|
- Dans `docker-compose.yml` : modifier la section `ports`
|
||||||
|
- Avec `docker run` : utiliser `-p 8080:8080`
|
||||||
|
|
||||||
|
### Volumes
|
||||||
|
|
||||||
|
Deux volumes sont recommandés pour la persistance :
|
||||||
|
|
||||||
|
- **Configuration** : `/home/pmomusic/.pmomusic` - Fichiers de configuration
|
||||||
|
- **Cache** : `/home/pmomusic/cache` - Cache audio et métadonnées
|
||||||
|
|
||||||
|
### Variables d'environnement
|
||||||
|
|
||||||
|
Configurable via `docker-compose.yml` ou `-e` avec `docker run` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Niveau de logs Rust
|
||||||
|
RUST_LOG=debug
|
||||||
|
|
||||||
|
# Autres variables (selon votre configuration)
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Réseau
|
||||||
|
|
||||||
|
Pour UPnP/DLNA, utilisez **network_mode: host** pour permettre :
|
||||||
|
- La découverte multicast
|
||||||
|
- La communication avec les devices UPnP sur le réseau local
|
||||||
|
|
||||||
|
**Note** : Le mode `host` ne fonctionne que sur Linux. Sur macOS/Windows avec Docker Desktop, utilisez le mapping de ports standard.
|
||||||
|
|
||||||
|
## Gestion de l'image
|
||||||
|
|
||||||
|
### Taille de l'image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Voir la taille de l'image
|
||||||
|
docker images pmomusic:latest
|
||||||
|
|
||||||
|
# Résultat attendu : ~200-300MB
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nettoyage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Supprimer l'image
|
||||||
|
docker rmi pmomusic:latest
|
||||||
|
|
||||||
|
# Nettoyer les images de build intermédiaires
|
||||||
|
docker builder prune
|
||||||
|
|
||||||
|
# Nettoyer tous les caches Docker (libère beaucoup d'espace)
|
||||||
|
docker system prune -a
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build multi-plateforme
|
||||||
|
|
||||||
|
Pour builder pour différentes architectures (ARM64, AMD64) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Créer un builder multi-plateforme
|
||||||
|
docker buildx create --name multiarch --use
|
||||||
|
|
||||||
|
# Build pour AMD64 et ARM64
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
-t pmomusic:latest \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|
||||||
|
# Note : nécessite un registry Docker (Docker Hub, GHCR, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Déploiement en production
|
||||||
|
|
||||||
|
### 1. Avec docker-compose (simple)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sur le serveur de production
|
||||||
|
git clone <votre-repo>
|
||||||
|
cd pmomusic
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Avec un registry Docker (recommandé)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sur votre machine de dev
|
||||||
|
docker build -t yourregistry.com/pmomusic:v1.0.0 .
|
||||||
|
docker push yourregistry.com/pmomusic:v1.0.0
|
||||||
|
|
||||||
|
# Sur le serveur de production
|
||||||
|
docker pull yourregistry.com/pmomusic:v1.0.0
|
||||||
|
docker run -d ... yourregistry.com/pmomusic:v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Avec un orchestrateur (Kubernetes, Docker Swarm)
|
||||||
|
|
||||||
|
Créer un fichier de déploiement approprié selon votre orchestrateur.
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Logs du conteneur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Logs en temps réel
|
||||||
|
docker logs -f pmomusic
|
||||||
|
|
||||||
|
# Logs avec docker-compose
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Entrer dans le conteneur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Shell interactif (bash n'est pas disponible, utiliser sh)
|
||||||
|
docker exec -it pmomusic sh
|
||||||
|
|
||||||
|
# Vérifier les processus
|
||||||
|
docker exec -it pmomusic ps aux
|
||||||
|
|
||||||
|
# Vérifier les fichiers
|
||||||
|
docker exec -it pmomusic ls -la /home/pmomusic
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health check
|
||||||
|
|
||||||
|
Le conteneur inclut un health check. Vérifier l'état :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Voir l'état de santé
|
||||||
|
docker inspect --format='{{.State.Health.Status}}' pmomusic
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Le build échoue
|
||||||
|
|
||||||
|
1. **Erreur de dépendances npm** :
|
||||||
|
- Vérifier que `pmoapp/webapp/package.json` est correct
|
||||||
|
- Essayer `docker build --no-cache`
|
||||||
|
|
||||||
|
2. **Erreur de compilation Rust** :
|
||||||
|
- Vérifier que tous les fichiers Cargo.toml sont présents
|
||||||
|
- Vérifier les dépendances système (libsoxr, libasound2)
|
||||||
|
|
||||||
|
3. **Out of memory** :
|
||||||
|
- Augmenter la mémoire allouée à Docker Desktop (settings)
|
||||||
|
- Utiliser `--memory` pour limiter la mémoire du build
|
||||||
|
|
||||||
|
### Le conteneur ne démarre pas
|
||||||
|
|
||||||
|
1. **Port déjà utilisé** :
|
||||||
|
```bash
|
||||||
|
# Vérifier quel processus utilise le port 8080
|
||||||
|
sudo lsof -i :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Permissions** :
|
||||||
|
- Vérifier les permissions des volumes montés
|
||||||
|
- Le conteneur s'exécute en tant qu'utilisateur `pmomusic` (UID 1000)
|
||||||
|
|
||||||
|
3. **Configuration manquante** :
|
||||||
|
- Créer les répertoires de configuration avant de démarrer :
|
||||||
|
```bash
|
||||||
|
mkdir -p config cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### UPnP ne fonctionne pas
|
||||||
|
|
||||||
|
1. **Network mode** :
|
||||||
|
- Sur Linux : utiliser `network_mode: host`
|
||||||
|
- Sur macOS/Windows : UPnP peut ne pas fonctionner correctement avec Docker Desktop
|
||||||
|
|
||||||
|
2. **Firewall** :
|
||||||
|
- Vérifier que les ports UPnP ne sont pas bloqués
|
||||||
|
- Autoriser le multicast sur le réseau
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
### Optimisations du build
|
||||||
|
|
||||||
|
1. **Build cache** : Docker réutilise les couches en cache
|
||||||
|
2. **Multi-stage build** : Réduit la taille de l'image finale
|
||||||
|
3. **Strip des symboles** : Le binaire est strippé pour réduire sa taille
|
||||||
|
|
||||||
|
### Optimisations runtime
|
||||||
|
|
||||||
|
1. **Resource limits** : Définir des limites CPU/mémoire dans docker-compose.yml
|
||||||
|
2. **Volumes** : Utiliser des volumes pour les données persistantes
|
||||||
|
3. **Logs** : Configurer la rotation des logs Docker
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
- ✅ Exécution en tant qu'utilisateur non-root
|
||||||
|
- ✅ Image minimale (surface d'attaque réduite)
|
||||||
|
- ✅ Pas de secrets dans l'image
|
||||||
|
- ✅ Health checks activés
|
||||||
|
- ✅ Certificats CA inclus pour HTTPS
|
||||||
|
|
||||||
|
## Références
|
||||||
|
|
||||||
|
- [Dockerfile](./Dockerfile)
|
||||||
|
- [docker-compose.yml](./docker-compose.yml)
|
||||||
|
- [.dockerignore](./.dockerignore)
|
||||||
|
- [Documentation Rust](./Readme.md)
|
||||||
117
Dockerfile
Normal file
117
Dockerfile
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# ===================================
|
||||||
|
# Stage 1: Build Vue.js webapp
|
||||||
|
# ===================================
|
||||||
|
FROM node:22-alpine AS webapp-builder
|
||||||
|
|
||||||
|
WORKDIR /webapp
|
||||||
|
|
||||||
|
# Copy webapp package files
|
||||||
|
COPY pmoapp/webapp/package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci --production=false
|
||||||
|
|
||||||
|
# Copy webapp source
|
||||||
|
COPY pmoapp/webapp/ ./
|
||||||
|
|
||||||
|
# Build the webapp
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# Stage 2: Build Rust binary
|
||||||
|
# ===================================
|
||||||
|
FROM rustlang/rust:nightly-bookworm AS rust-builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Install system dependencies for building
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libsoxr-dev \
|
||||||
|
libasound2-dev \
|
||||||
|
pkg-config \
|
||||||
|
cmake \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy Cargo workspace files
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
|
||||||
|
# Copy all crates
|
||||||
|
COPY PMOMusic/ ./PMOMusic/
|
||||||
|
COPY pmoupnp/ ./pmoupnp/
|
||||||
|
COPY pmomediarenderer/ ./pmomediarenderer/
|
||||||
|
COPY pmomediaserver/ ./pmomediaserver/
|
||||||
|
COPY pmoconfig/ ./pmoconfig/
|
||||||
|
COPY pmoutils/ ./pmoutils/
|
||||||
|
COPY pmodidl/ ./pmodidl/
|
||||||
|
COPY pmoserver/ ./pmoserver/
|
||||||
|
COPY pmocache/ ./pmocache/
|
||||||
|
COPY pmocovers/ ./pmocovers/
|
||||||
|
COPY pmoaudiocache/ ./pmoaudiocache/
|
||||||
|
COPY pmoaudio/ ./pmoaudio/
|
||||||
|
COPY pmoqobuz/ ./pmoqobuz/
|
||||||
|
COPY pmoparadise/ ./pmoparadise/
|
||||||
|
COPY pmosource/ ./pmosource/
|
||||||
|
COPY pmoplaylist/ ./pmoplaylist/
|
||||||
|
COPY pmoflac/ ./pmoflac/
|
||||||
|
COPY pmometadata/ ./pmometadata/
|
||||||
|
COPY pmocontrol/ ./pmocontrol/
|
||||||
|
COPY pmoaudio-ext/ ./pmoaudio-ext/
|
||||||
|
COPY pmoapp/ ./pmoapp/
|
||||||
|
|
||||||
|
# Copy the webapp dist from previous stage
|
||||||
|
COPY --from=webapp-builder /webapp/dist ./pmoapp/webapp/dist/
|
||||||
|
|
||||||
|
# Build the Rust binary in release mode
|
||||||
|
RUN cargo build --release --bin PMOMusic
|
||||||
|
|
||||||
|
# Strip debug symbols to reduce binary size
|
||||||
|
RUN strip /build/target/release/PMOMusic
|
||||||
|
|
||||||
|
# ===================================
|
||||||
|
# Stage 3: Minimal runtime image
|
||||||
|
# ===================================
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
ARG BUILD_DATE
|
||||||
|
LABEL org.opencontainers.image.created=$BUILD_DATE
|
||||||
|
LABEL org.opencontainers.image.title="PMOMusic UPNP Music Server"
|
||||||
|
LABEL org.opencontainers.image.version="0.1"
|
||||||
|
LABEL org.opencontainers.image.authors="eric@coissac.eu"
|
||||||
|
LABEL org.opencontainers.image.licenses="CeCILL-2.0"
|
||||||
|
LABEL org.opencontainers.image.url="https://gargoton.petite-maison-orange.fr/eric/pmomusic"
|
||||||
|
LABEL org.opencontainers.image.description="PMOMusic est une application Rust proposant en un binaire unique une Serveur de Musique, et une point de controle UPNP controlable via une application web."
|
||||||
|
|
||||||
|
# Install only runtime dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libsoxr0 \
|
||||||
|
libasound2 \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create a non-root user
|
||||||
|
RUN useradd -m -u 1000 pmomusic
|
||||||
|
|
||||||
|
# Copy the binary from builder
|
||||||
|
COPY --from=rust-builder /build/target/release/PMOMusic /usr/local/bin/PMOMusic
|
||||||
|
|
||||||
|
# Set ownership
|
||||||
|
RUN chown pmomusic:pmomusic /usr/local/bin/PMOMusic
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER pmomusic
|
||||||
|
|
||||||
|
# Create directories for configuration and cache
|
||||||
|
RUN mkdir -p /home/pmomusic/.pmomusic
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /home/pmomusic
|
||||||
|
|
||||||
|
# Expose default port (adjust if needed)
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Health check (adjust the URL if needed)
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD ["/usr/local/bin/PMOMusic", "--help"] || exit 1
|
||||||
|
|
||||||
|
# Run the binary
|
||||||
|
ENTRYPOINT ["/usr/local/bin/PMOMusic"]
|
||||||
296
INSTALL_LIBSOXR.md
Normal file
296
INSTALL_LIBSOXR.md
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
# Installation des dépendances système sans droits sudo
|
||||||
|
|
||||||
|
Ce document explique comment installer les dépendances système de `pmoaudio` localement sans privilèges administrateur.
|
||||||
|
|
||||||
|
## Dépendances requises
|
||||||
|
|
||||||
|
1. **libsoxr** - Nécessaire pour `ResamplingNode` (resampling audio haute qualité)
|
||||||
|
2. **libasound2** (ALSA) - Nécessaire pour `AudioSink` via cpal (lecture audio sur Linux)
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Les crates `soxr` et `cpal` nécessitent des bibliothèques système. Dans un environnement sans droits sudo (comme Claude Code), voici comment les installer localement.
|
||||||
|
|
||||||
|
## Méthode : Installation locale via apt-get download
|
||||||
|
|
||||||
|
### 1. Télécharger les packages .deb
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.local
|
||||||
|
|
||||||
|
# Pour libsoxr (ResamplingNode)
|
||||||
|
apt-get download libsoxr-dev libsoxr0
|
||||||
|
|
||||||
|
# Pour ALSA (AudioSink)
|
||||||
|
# Note: libasound2t64 contient la bibliothèque partagée, libasound2-dev les headers
|
||||||
|
apt-get download libasound2-dev libasound2t64
|
||||||
|
```
|
||||||
|
|
||||||
|
Cela télécharge les fichiers `.deb` sans les installer système-wide.
|
||||||
|
|
||||||
|
### 2. Extraire les packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Extraire libsoxr
|
||||||
|
dpkg -x libsoxr-dev_*.deb .
|
||||||
|
dpkg -x libsoxr0_*.deb .
|
||||||
|
|
||||||
|
# Extraire ALSA
|
||||||
|
dpkg -x libasound2-dev_*.deb .
|
||||||
|
dpkg -x libasound2t64_*.deb .
|
||||||
|
```
|
||||||
|
|
||||||
|
Les fichiers sont extraits dans `~/.local/usr/lib/x86_64-linux-gnu/` et `~/.local/usr/include/`.
|
||||||
|
|
||||||
|
### 3. Configurer les variables d'environnement
|
||||||
|
|
||||||
|
Ajouter à votre `~/.bashrc` ou exporter dans votre session :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||||
|
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||||
|
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT:** Ces variables doivent être définies dans chaque session où vous compilez le projet.
|
||||||
|
|
||||||
|
### 4. Vérifier l'installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Vérifier libsoxr
|
||||||
|
pkg-config --libs --cflags soxr
|
||||||
|
|
||||||
|
# Vérifier ALSA
|
||||||
|
pkg-config --libs --cflags alsa
|
||||||
|
```
|
||||||
|
|
||||||
|
Devrait retourner quelque chose comme :
|
||||||
|
```
|
||||||
|
# soxr
|
||||||
|
-I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lsoxr
|
||||||
|
|
||||||
|
# alsa
|
||||||
|
-I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lasound
|
||||||
|
```
|
||||||
|
|
||||||
|
## Utilisation avec Cargo
|
||||||
|
|
||||||
|
### Pour les builds réguliers
|
||||||
|
|
||||||
|
Les variables d'environnement suffisent pour `cargo build` et `cargo run`.
|
||||||
|
|
||||||
|
### Pour les tests
|
||||||
|
|
||||||
|
Les tests nécessitent également la configuration du linker. Deux options :
|
||||||
|
|
||||||
|
#### Option A : Configuration locale du projet (NON RECOMMANDÉ pour le versioning)
|
||||||
|
|
||||||
|
Créer `.cargo/config.toml` dans chaque crate :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[build]
|
||||||
|
rustflags = ["-L", "/root/.local/usr/lib/x86_64-linux-gnu"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ NE PAS committer ces fichiers** - ils contiennent des chemins spécifiques à votre installation.
|
||||||
|
|
||||||
|
#### Option B : Variables d'environnement pour cargo test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||||
|
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pour d'autres distributions
|
||||||
|
|
||||||
|
### macOS (avec Homebrew)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install libsoxr
|
||||||
|
# Note: ALSA n'est pas nécessaire sur macOS (rodio utilise CoreAudio)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debian/Ubuntu (avec sudo)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get install libsoxr-dev libasound2-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fedora/RHEL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install soxr-devel
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Erreur : "Package 'soxr' was not found" ou "Package 'alsa' was not found"
|
||||||
|
|
||||||
|
- Vérifier que `PKG_CONFIG_PATH` contient le bon chemin
|
||||||
|
- Vérifier que les fichiers `soxr.pc` et `alsa.pc` existent dans ce répertoire
|
||||||
|
|
||||||
|
### Erreur de link : "unable to find library -lsoxr" ou "-lasound"
|
||||||
|
|
||||||
|
- Pour `cargo build` : vérifier `LD_LIBRARY_PATH`
|
||||||
|
- Pour `cargo test` : utiliser la configuration rustflags (Option A ci-dessus)
|
||||||
|
|
||||||
|
### Le test compile mais échoue au runtime
|
||||||
|
|
||||||
|
```
|
||||||
|
error while loading shared libraries: libsoxr.so.0: cannot open shared object file
|
||||||
|
```
|
||||||
|
|
||||||
|
Solution : Ajouter `LD_LIBRARY_PATH` également pour l'exécution :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guide complet pour environnement Claude Code
|
||||||
|
|
||||||
|
### Configuration initiale (à faire une seule fois)
|
||||||
|
|
||||||
|
Dans une session Claude Code (https://claude.ai/code), vous n'avez pas de droits sudo.
|
||||||
|
|
||||||
|
**🚀 Méthode rapide (recommandée) :**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Installation automatique des dépendances (une seule fois)
|
||||||
|
./setup-deps.sh
|
||||||
|
|
||||||
|
# 2. Configuration des variables d'environnement (à chaque session)
|
||||||
|
source setup-env.sh
|
||||||
|
|
||||||
|
# 3. Compilation
|
||||||
|
cargo build
|
||||||
|
```
|
||||||
|
|
||||||
|
**📋 Méthode manuelle (si les scripts ne fonctionnent pas) :**
|
||||||
|
|
||||||
|
#### 1. Installation des dépendances
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Créer le répertoire local
|
||||||
|
mkdir -p ~/.local
|
||||||
|
cd ~/.local
|
||||||
|
|
||||||
|
# Télécharger tous les packages nécessaires
|
||||||
|
apt-get download libsoxr-dev libsoxr0 libasound2-dev libasound2t64
|
||||||
|
|
||||||
|
# Extraire tous les packages
|
||||||
|
dpkg -x libsoxr-dev_*.deb .
|
||||||
|
dpkg -x libsoxr0_*.deb .
|
||||||
|
dpkg -x libasound2-dev_*.deb .
|
||||||
|
dpkg -x libasound2t64_*.deb .
|
||||||
|
|
||||||
|
# Retourner au projet
|
||||||
|
cd /home/user/pmomusic
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Configuration des variables d'environnement
|
||||||
|
|
||||||
|
**IMPORTANT:** Ces variables doivent être exportées dans CHAQUE session Claude Code avant de compiler :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||||
|
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||||
|
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Astuce :** Créez un fichier `setup-env.sh` pour ne pas avoir à retaper ces commandes à chaque session :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > setup-env.sh << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# Script de configuration des variables d'environnement pour PMOMusic
|
||||||
|
# Usage: source setup-env.sh
|
||||||
|
|
||||||
|
# Configuration des chemins pour libsoxr et libasound2
|
||||||
|
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||||
|
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||||
|
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
|
||||||
|
|
||||||
|
echo "Variables d'environnement configurées pour PMOMusic"
|
||||||
|
echo " PKG_CONFIG_PATH=$PKG_CONFIG_PATH"
|
||||||
|
echo " LD_LIBRARY_PATH=$LD_LIBRARY_PATH"
|
||||||
|
echo " RUSTFLAGS=$RUSTFLAGS"
|
||||||
|
echo ""
|
||||||
|
echo "Vous pouvez maintenant compiler avec: cargo build"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x setup-env.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis dans chaque session :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source setup-env.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Note :** Le fichier `setup-env.sh` est dans `.gitignore` (configuration locale), vous devez le créer vous-même avec le contenu ci-dessus.
|
||||||
|
|
||||||
|
#### 3. Vérifier l'installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Vérifier que pkg-config trouve les bibliothèques
|
||||||
|
pkg-config --libs --cflags soxr
|
||||||
|
pkg-config --libs --cflags alsa
|
||||||
|
|
||||||
|
# Devrait afficher quelque chose comme :
|
||||||
|
# -I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lsoxr
|
||||||
|
# -I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lasound
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Compiler et tester
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compiler le workspace complet
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# Tester l'exemple play_and_cache de pmoparadise
|
||||||
|
cargo run --package pmoparadise --example play_and_cache --features full -- 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow pour chaque nouvelle session
|
||||||
|
|
||||||
|
À chaque fois que vous démarrez une nouvelle session Claude Code :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Configuration de l'environnement
|
||||||
|
source setup-env.sh
|
||||||
|
|
||||||
|
# 2. Compilation
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# 3. Exécution des exemples
|
||||||
|
cargo run --package pmoparadise --example play_and_cache --features full -- 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT :** Si vous oubliez d'exporter les variables, vous obtiendrez des erreurs comme :
|
||||||
|
```
|
||||||
|
error: failed to run custom build command for `soxr-sys`
|
||||||
|
Package 'soxr' was not found in the pkg-config search path
|
||||||
|
```
|
||||||
|
|
||||||
|
ou
|
||||||
|
|
||||||
|
```
|
||||||
|
rust-lld: error: unable to find library -lasound
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution :** Exécutez `source setup-env.sh` et recompilez.
|
||||||
|
|
||||||
|
### Notes importantes
|
||||||
|
|
||||||
|
- ✅ Les dépendances installées dans `~/.local` persistent entre les sessions
|
||||||
|
- ✅ Les variables d'environnement doivent être réexportées à chaque nouvelle session
|
||||||
|
- ❌ NE JAMAIS créer de fichiers `.cargo/config.toml` dans le projet (chemins spécifiques)
|
||||||
|
- ❌ NE JAMAIS committer `setup-env.sh` (configuration locale)
|
||||||
|
- 💡 Sur macOS (via Homebrew) : seul `libsoxr` est nécessaire (pas d'ALSA)
|
||||||
|
|
||||||
|
## Références
|
||||||
|
|
||||||
|
- libsoxr GitHub: https://github.com/chirlu/soxr
|
||||||
|
- Documentation pkg-config: https://www.freedesktop.org/wiki/Software/pkg-config/
|
||||||
89
INSTALL_NOTES.md
Normal file
89
INSTALL_NOTES.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Notes d'installation pour PMOMusic
|
||||||
|
|
||||||
|
## Prérequis système
|
||||||
|
|
||||||
|
### libsoxr (obligatoire pour pmoaudio - resampling)
|
||||||
|
|
||||||
|
La bibliothèque `libsoxr` est requise pour le resampling audio dans `pmoaudio`.
|
||||||
|
|
||||||
|
### libasound2/ALSA (obligatoire pour pmoaudio - lecture audio sur Linux)
|
||||||
|
|
||||||
|
La bibliothèque ALSA est requise pour `AudioSink` via `cpal` sur Linux. Sur macOS et Windows, aucune dépendance externe n'est nécessaire (CoreAudio et WASAPI sont utilisés).
|
||||||
|
|
||||||
|
**Installation** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debian/Ubuntu
|
||||||
|
sudo apt-get install libsoxr-dev libasound2-dev
|
||||||
|
|
||||||
|
# Fedora/RHEL
|
||||||
|
sudo dnf install libsoxr-devel alsa-lib-devel
|
||||||
|
|
||||||
|
# Arch Linux
|
||||||
|
sudo pacman -S libsoxr alsa-lib
|
||||||
|
|
||||||
|
# macOS (Homebrew) - ALSA non nécessaire sur macOS
|
||||||
|
brew install libsoxr
|
||||||
|
|
||||||
|
# Alpine Linux
|
||||||
|
apk add soxr-dev alsa-lib-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sans privilèges root (Claude Code, environnements sans sudo)** :
|
||||||
|
|
||||||
|
🚀 **Installation automatique** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Installation des dépendances (une seule fois)
|
||||||
|
./setup-deps.sh
|
||||||
|
|
||||||
|
# 2. Configuration de l'environnement (à chaque session)
|
||||||
|
source setup-env.sh
|
||||||
|
|
||||||
|
# 3. Compilation
|
||||||
|
cargo build
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour plus de détails, consultez `INSTALL_LIBSOXR.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nouveaux composants
|
||||||
|
|
||||||
|
### PlaylistSource (pmoaudio-ext)
|
||||||
|
|
||||||
|
Source audio qui lit une playlist `pmoplaylist` et diffuse les pistes en continu.
|
||||||
|
|
||||||
|
**Feature** : `playlist`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compiler avec la feature playlist
|
||||||
|
cargo build --package pmoaudio-ext --features playlist
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Important** : Cette source émet du PCM avec sample_rate et bit_depth **variables**. Pour un flux homogène, ajoutez dans le pipeline :
|
||||||
|
- `ResamplingNode` (normalise le sample_rate)
|
||||||
|
- `ToI24Node` / `ToI16Node` (normalise la profondeur de bits)
|
||||||
|
|
||||||
|
### ResamplingNode (pmoaudio)
|
||||||
|
|
||||||
|
Nœud générique qui normalise le sample_rate vers une valeur cible fixe.
|
||||||
|
|
||||||
|
**Usage** :
|
||||||
|
```rust
|
||||||
|
let mut resampler = ResamplingNode::new(48000); // Force 48kHz
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compilation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compiler tout le workspace (nécessite libsoxr)
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# Compiler sans pmoaudio (si libsoxr manque)
|
||||||
|
cargo build --package pmoplaylist
|
||||||
|
cargo build --package pmoaudiocache
|
||||||
|
# etc.
|
||||||
|
```
|
||||||
113
Makefile
113
Makefile
@@ -1,6 +1,8 @@
|
|||||||
# Makefile pour projet Rust + Vue.js
|
# Makefile pour projet Rust + Vue.js
|
||||||
# Variables de configuration
|
# Variables de configuration
|
||||||
CARGO = cargo
|
CARGO = cargo
|
||||||
|
CARGO_NIGHTLY = rustup run nightly cargo
|
||||||
|
FEATURES ?=
|
||||||
NPM = npm
|
NPM = npm
|
||||||
WEBAPP_DIR = pmoapp/webapp
|
WEBAPP_DIR = pmoapp/webapp
|
||||||
DIST_DIR = $(WEBAPP_DIR)/dist
|
DIST_DIR = $(WEBAPP_DIR)/dist
|
||||||
@@ -11,10 +13,13 @@ BINARY_NAME = PMOMusic
|
|||||||
# Couleurs pour l'affichage
|
# Couleurs pour l'affichage
|
||||||
GREEN = \033[0;32m
|
GREEN = \033[0;32m
|
||||||
YELLOW = \033[1;33m
|
YELLOW = \033[1;33m
|
||||||
|
BLUE = \033[1;34m
|
||||||
RED = \033[0;31m
|
RED = \033[0;31m
|
||||||
NC = \033[0m # No Color
|
NC = \033[0m # No Color
|
||||||
|
|
||||||
.PHONY: all help build release debug test doc webapp clean install dev check fmt clippy watch
|
.DEFAULT_GOAL := simd
|
||||||
|
|
||||||
|
.PHONY: all help build release debug test doc webapp clean install dev check fmt clippy watch simd scalar
|
||||||
|
|
||||||
# Cible par défaut
|
# Cible par défaut
|
||||||
all: build
|
all: build
|
||||||
@@ -31,13 +36,13 @@ build: webapp release
|
|||||||
## release: Compile le binaire Rust en mode release
|
## release: Compile le binaire Rust en mode release
|
||||||
release: webapp
|
release: webapp
|
||||||
@echo "$(YELLOW)→ Compilation Rust (release)...$(NC)"
|
@echo "$(YELLOW)→ Compilation Rust (release)...$(NC)"
|
||||||
$(CARGO) build --release
|
$(CARGO) build --release $(FEATURES)
|
||||||
@echo "$(GREEN)✓ Binaire disponible : $(RUST_TARGET)/$(BINARY_NAME)$(NC)"
|
@echo "$(GREEN)✓ Binaire disponible : $(RUST_TARGET)/$(BINARY_NAME)$(NC)"
|
||||||
|
|
||||||
## debug: Compile le binaire Rust en mode debug
|
## debug: Compile le binaire Rust en mode debug
|
||||||
debug: webapp
|
debug: webapp
|
||||||
@echo "$(YELLOW)→ Compilation Rust (debug)...$(NC)"
|
@echo "$(YELLOW)→ Compilation Rust (debug)...$(NC)"
|
||||||
$(CARGO) build
|
$(CARGO) build $(FEATURES)
|
||||||
@echo "$(GREEN)✓ Binaire disponible : target/debug/$(BINARY_NAME)$(NC)"
|
@echo "$(GREEN)✓ Binaire disponible : target/debug/$(BINARY_NAME)$(NC)"
|
||||||
|
|
||||||
## test: Exécute tous les tests Rust
|
## test: Exécute tous les tests Rust
|
||||||
@@ -46,6 +51,18 @@ test:
|
|||||||
$(CARGO) test --all
|
$(CARGO) test --all
|
||||||
@echo "$(GREEN)✓ Tests terminés$(NC)"
|
@echo "$(GREEN)✓ Tests terminés$(NC)"
|
||||||
|
|
||||||
|
## simd: Compile l'application en mode SIMD (nightly requis)
|
||||||
|
simd:
|
||||||
|
@echo "$(YELLOW)→ Build SIMD (nightly)...$(NC)"
|
||||||
|
$(MAKE) release CARGO="$(CARGO_NIGHTLY)" FEATURES="--features simd"
|
||||||
|
@echo "$(GREEN)✓ Build SIMD terminé$(NC)"
|
||||||
|
|
||||||
|
## scalar: Compile l'application en mode scalaire
|
||||||
|
scalar:
|
||||||
|
@echo "$(YELLOW)→ Build scalaire...$(NC)"
|
||||||
|
$(MAKE) release FEATURES=""
|
||||||
|
@echo "$(GREEN)✓ Build scalaire terminé$(NC)"
|
||||||
|
|
||||||
## test-doc: Teste les exemples dans la documentation
|
## test-doc: Teste les exemples dans la documentation
|
||||||
test-doc:
|
test-doc:
|
||||||
@echo "$(YELLOW)→ Test des exemples de documentation...$(NC)"
|
@echo "$(YELLOW)→ Test des exemples de documentation...$(NC)"
|
||||||
@@ -177,6 +194,28 @@ update:
|
|||||||
cd $(WEBAPP_DIR) && $(NPM) update
|
cd $(WEBAPP_DIR) && $(NPM) update
|
||||||
@echo "$(GREEN)✓ Dépendances mises à jour$(NC)"
|
@echo "$(GREEN)✓ Dépendances mises à jour$(NC)"
|
||||||
|
|
||||||
|
## bump-version: Incrémente le numéro de version patch (x.y.z -> x.y.z+1)
|
||||||
|
bump-version:
|
||||||
|
@echo "$(YELLOW)→ Incrémentation de la version...$(NC)"
|
||||||
|
@current=$$(grep '^version = ' PMOMusic/Cargo.toml | head -n 1 | sed 's/version = "\(.*\)"/\1/'); \
|
||||||
|
echo " Version actuelle: $$current"; \
|
||||||
|
major=$$(echo $$current | cut -d. -f1); \
|
||||||
|
minor=$$(echo $$current | cut -d. -f2); \
|
||||||
|
patch=$$(echo $$current | cut -d. -f3); \
|
||||||
|
new_patch=$$((patch + 1)); \
|
||||||
|
new_version="$$major.$$minor.$$new_patch"; \
|
||||||
|
echo " Nouvelle version: $$new_version"; \
|
||||||
|
sed -i.bak "s/^version = \"$$current\"/version = \"$$new_version\"/" PMOMusic/Cargo.toml && \
|
||||||
|
rm PMOMusic/Cargo.toml.bak && \
|
||||||
|
echo "$$new_version" > version.txt
|
||||||
|
@echo "$(GREEN)✓ Version mise à jour dans PMOMusic/Cargo.toml et version.txt$(NC)"
|
||||||
|
|
||||||
|
## sync-version: Synchronise version.txt depuis PMOMusic/Cargo.toml
|
||||||
|
version.txt: PMOMusic/Cargo.toml
|
||||||
|
@echo "$(YELLOW)→ Synchronisation de version.txt...$(NC)"
|
||||||
|
@grep '^version = ' PMOMusic/Cargo.toml | head -n 1 | sed 's/version = "\(.*\)"/\1/' > version.txt
|
||||||
|
@echo "$(GREEN)✓ version.txt synchronisé: $$(cat version.txt)$(NC)"
|
||||||
|
|
||||||
## bench: Exécute les benchmarks
|
## bench: Exécute les benchmarks
|
||||||
bench:
|
bench:
|
||||||
@echo "$(YELLOW)→ Exécution des benchmarks...$(NC)"
|
@echo "$(YELLOW)→ Exécution des benchmarks...$(NC)"
|
||||||
@@ -187,4 +226,70 @@ coverage:
|
|||||||
@echo "$(YELLOW)→ Génération du rapport de couverture...$(NC)"
|
@echo "$(YELLOW)→ Génération du rapport de couverture...$(NC)"
|
||||||
$(CARGO) tarpaulin --out Html --output-dir target/coverage
|
$(CARGO) tarpaulin --out Html --output-dir target/coverage
|
||||||
@echo "$(GREEN)✓ Rapport disponible dans target/coverage/index.html$(NC)"
|
@echo "$(GREEN)✓ Rapport disponible dans target/coverage/index.html$(NC)"
|
||||||
|
|
||||||
|
jjnew:
|
||||||
|
@echo "$(YELLOW)→ Création d'un nouveau commit...$(NC)"
|
||||||
|
@echo "$(BLUE)→ Documentation du commit courrant...$(NC)"
|
||||||
|
@jj auto-describe
|
||||||
|
@echo "$(BLUE)→ C'est fait.$(NC)"
|
||||||
|
@jj new
|
||||||
|
@echo "$(GREEN)✓ nouveau commit créé$(NC)"
|
||||||
|
|
||||||
|
jjpush: bump-version
|
||||||
|
@echo "$(YELLOW)→ Push du commit sur le dépôt...$(NC)"
|
||||||
|
@echo "$(BLUE)→ Documentation du commit courrant...$(NC)"
|
||||||
|
@jj auto-describe
|
||||||
|
@echo "$(BLUE)→ C'est fait.$(NC)"
|
||||||
|
@jj git push --change @
|
||||||
|
@echo "$(GREEN)✓ Commit pushé sur le dépôt$(NC)"
|
||||||
|
|
||||||
|
jjfetch:
|
||||||
|
@echo "$(YELLOW)→ Pull des derniers commits...$(NC)"
|
||||||
|
@jj git fetch
|
||||||
|
@jj new main@origin
|
||||||
|
@echo "$(GREEN)✓ Derniers commits pullés$(NC)"
|
||||||
|
|
||||||
|
## blackboard-html: Génère les fichiers HTML du Blackboard avec support Mermaid
|
||||||
|
blackboard-html:
|
||||||
|
@echo "$(YELLOW)→ Génération des fichiers HTML du Blackboard...$(NC)"
|
||||||
|
@mkdir -p Blackboard_HTML
|
||||||
|
@echo "<!DOCTYPE html>" > Blackboard_HTML/index.html
|
||||||
|
@echo '<html lang="fr"><head><meta charset="utf-8">' >> Blackboard_HTML/index.html
|
||||||
|
@echo "<title>PMOMusic Blackboard</title>" >> Blackboard_HTML/index.html
|
||||||
|
@echo "<style>" >> Blackboard_HTML/index.html
|
||||||
|
@echo "body{font-family:sans-serif;margin:20px;background:#f5f5f5}" >> Blackboard_HTML/index.html
|
||||||
|
@echo "h1{color:#2c3e50}ul{list-style:none;padding:0}" >> Blackboard_HTML/index.html
|
||||||
|
@echo "li{margin:10px 0}a{color:#3498db;text-decoration:none}" >> Blackboard_HTML/index.html
|
||||||
|
@echo "a:hover{text-decoration:underline}.category{margin-top:30px}" >> Blackboard_HTML/index.html
|
||||||
|
@echo ".category h2{color:#e74c3c;border-bottom:2px solid #e74c3c;padding-bottom:5px}" >> Blackboard_HTML/index.html
|
||||||
|
@echo "</style></head><body>" >> Blackboard_HTML/index.html
|
||||||
|
@echo '<h1>📋 PMOMusic Blackboard</h1>' >> Blackboard_HTML/index.html
|
||||||
|
@for category in Architecture ToThinkAbout ToDiscuss Todo Done Report; do \
|
||||||
|
if [ -d "Blackboard/$$category" ]; then \
|
||||||
|
echo "<div class='category'><h2>$$category</h2><ul>" >> Blackboard_HTML/index.html; \
|
||||||
|
find "Blackboard/$$category" -name "*.md" -type f | sort | while read -r file; do \
|
||||||
|
basename=$$(basename "$$file" .md); \
|
||||||
|
relpath=$$(echo "$$file" | sed 's|Blackboard/||'); \
|
||||||
|
htmlfile=$$(echo "$$relpath" | sed 's|/|_|g' | sed 's|\.md$$|.html|'); \
|
||||||
|
echo "<li><a href='$$htmlfile'>$$basename</a></li>" >> Blackboard_HTML/index.html; \
|
||||||
|
echo " → Conversion: $$relpath → $$htmlfile"; \
|
||||||
|
/opt/homebrew/bin/pandoc "$$file" -o "Blackboard_HTML/$$htmlfile" \
|
||||||
|
--standalone \
|
||||||
|
--template=blackboard-template.html \
|
||||||
|
--metadata title="$$basename" \
|
||||||
|
--from markdown \
|
||||||
|
--to html; \
|
||||||
|
./fix-mermaid.sh "Blackboard_HTML/$$htmlfile"; \
|
||||||
|
done; \
|
||||||
|
echo "</ul></div>" >> Blackboard_HTML/index.html; \
|
||||||
|
fi; \
|
||||||
|
done
|
||||||
|
@echo "</body></html>" >> Blackboard_HTML/index.html
|
||||||
|
@echo "$(GREEN)✓ Fichiers HTML générés dans Blackboard_HTML/$(NC)"
|
||||||
|
@echo "$(BLUE) Ouvrir: open Blackboard_HTML/index.html$(NC)"
|
||||||
|
|
||||||
|
## blackboard-clean: Nettoie les fichiers HTML générés
|
||||||
|
blackboard-clean:
|
||||||
|
@echo "$(YELLOW)→ Nettoyage des fichiers HTML du Blackboard...$(NC)"
|
||||||
|
@rm -rf Blackboard_HTML
|
||||||
|
@echo "$(GREEN)✓ Fichiers HTML supprimés$(NC)"
|
||||||
|
|||||||
485
OPTIMIZATION_PREBUFFER_TO_PLAYLIST.md
Normal file
485
OPTIMIZATION_PREBUFFER_TO_PLAYLIST.md
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
# Optimisation: Réduction du délai prebuffer → playlist (19s → 1s)
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Le système de progressive caching fonctionne correctement, mais il y a un délai non optimal entre le moment où le prebuffer est atteint et le moment où la track est ajoutée à la playlist.
|
||||||
|
|
||||||
|
### État actuel (branche `claude/fix-play-and-cache-streaming-011CUsMBxH4fsgoadgkiPdoK`)
|
||||||
|
|
||||||
|
**Timing mesuré:**
|
||||||
|
```
|
||||||
|
t=0.6s : Prebuffer complete (512KB téléchargés) ✅
|
||||||
|
t=19.2s : tokio::join!() complete (pump_future finit)
|
||||||
|
t=19.2s : Track added to playlist
|
||||||
|
t=19.7s : Playback starts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Délai total: ~19 secondes**
|
||||||
|
|
||||||
|
### Code actuel problématique
|
||||||
|
|
||||||
|
Location: `pmoaudio-ext/src/sinks/flac_cache_sink.rs:167-178`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Exécuter pump et add_from_reader en parallèle
|
||||||
|
let pump_future = pump_track_segments(
|
||||||
|
first_segment,
|
||||||
|
&mut rx, // ← emprunte muablement rx
|
||||||
|
pcm_tx,
|
||||||
|
bits_per_sample,
|
||||||
|
sample_rate,
|
||||||
|
&stop_token,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Attendre les deux tâches en parallèle
|
||||||
|
let (cache_result, pump_result) = tokio::join!(cache_future, pump_future);
|
||||||
|
|
||||||
|
let pk = cache_result.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to add to cache: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (_chunks, _samples, _duration_sec, stop_reason) = pump_result?;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Le problème:** `tokio::join!()` attend que **LES DEUX** futures se terminent:
|
||||||
|
- `cache_future` retourne après prebuffer (~0.6s) ✅
|
||||||
|
- `pump_future` lit **toute** la première track du RadioParadiseStreamSource (~19s) ⏱️
|
||||||
|
|
||||||
|
Donc même si le prebuffer est atteint en 0.6s, on attend 19s avant de push à la playlist!
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Réduire le délai à **~1 seconde** en pushant à la playlist **immédiatement après le prebuffer**, sans attendre que `pump_future` se termine.
|
||||||
|
|
||||||
|
**Timing visé:**
|
||||||
|
```
|
||||||
|
t=0.6s : Prebuffer complete ✅
|
||||||
|
t=0.7s : Track added to playlist ← IMMÉDIAT!
|
||||||
|
t=1.2s : Playback starts ← ~1 seconde!
|
||||||
|
t=19.2s : pump_future finit en arrière-plan
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contraintes techniques
|
||||||
|
|
||||||
|
### 1. Problème du borrow checker
|
||||||
|
|
||||||
|
`pump_future` emprunte muablement `rx`:
|
||||||
|
```rust
|
||||||
|
async fn pump_track_segments(
|
||||||
|
first_segment: Arc<AudioSegment>,
|
||||||
|
rx: &mut mpsc::Receiver<Arc<AudioSegment>>, // ← &mut borrow
|
||||||
|
// ...
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
On ne peut pas faire:
|
||||||
|
```rust
|
||||||
|
tokio::pin!(cache_future);
|
||||||
|
tokio::pin!(pump_future); // ← pump_future contient un &mut rx
|
||||||
|
|
||||||
|
let pk = cache_future.await; // cache_future termine
|
||||||
|
|
||||||
|
// ❌ ERREUR: on a toujours un borrow mutable de rx dans pump_future
|
||||||
|
// On ne peut pas continuer à utiliser rx (ou l'objet qui le contient)
|
||||||
|
playlist_handle.push(pk.clone()).await;
|
||||||
|
|
||||||
|
let result = pump_future.await; // pump_future continue
|
||||||
|
```
|
||||||
|
|
||||||
|
Le borrow checker nous empêche d'attendre `cache_future` seul, puis de faire d'autres opérations, puis d'attendre `pump_future`, car `pump_future` garde un borrow mutable de `rx` pendant toute sa durée de vie.
|
||||||
|
|
||||||
|
### 2. Contraintes de l'API
|
||||||
|
|
||||||
|
- `pump_track_segments()` doit lire `rx` pour recevoir les segments du RadioParadiseStreamSource
|
||||||
|
- Le FlacCacheSinkLogic doit garder ownership de `rx` pour traiter les tracks suivantes
|
||||||
|
- `pump_future` ne peut pas être spawné dans un tokio::spawn car il retourne un `StopReason` nécessaire pour la logique métier
|
||||||
|
|
||||||
|
## Solutions possibles
|
||||||
|
|
||||||
|
### Solution A: Refactoriser pump_track_segments pour prendre ownership de rx
|
||||||
|
|
||||||
|
**Approche:**
|
||||||
|
1. Créer `pump_track_segments_owned` qui prend ownership de `rx`
|
||||||
|
2. Cette fonction retourne `(result, rx)` - elle rend ownership de `rx`
|
||||||
|
3. Spawner cette future dans tokio::spawn
|
||||||
|
4. Attendre cache_future seul, push immédiatement
|
||||||
|
5. Attendre la task spawnée plus tard
|
||||||
|
|
||||||
|
**Signature:**
|
||||||
|
```rust
|
||||||
|
async fn pump_track_segments_owned(
|
||||||
|
first_segment: Arc<AudioSegment>,
|
||||||
|
rx: mpsc::Receiver<Arc<AudioSegment>>, // ownership!
|
||||||
|
pcm_tx: mpsc::Sender<Vec<u8>>,
|
||||||
|
bits_per_sample: u8,
|
||||||
|
expected_rate: u32,
|
||||||
|
stop_token: CancellationToken,
|
||||||
|
) -> Result<(u64, u64, f64, StopReason, mpsc::Receiver<Arc<AudioSegment>>), AudioError>
|
||||||
|
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ rend rx
|
||||||
|
```
|
||||||
|
|
||||||
|
**Utilisation:**
|
||||||
|
```rust
|
||||||
|
let pump_handle = tokio::spawn(pump_track_segments_owned(
|
||||||
|
first_segment,
|
||||||
|
rx, // move ownership
|
||||||
|
pcm_tx,
|
||||||
|
bits_per_sample,
|
||||||
|
sample_rate,
|
||||||
|
stop_token.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Attendre SEULEMENT le prebuffer
|
||||||
|
let pk = cache_future.await?;
|
||||||
|
|
||||||
|
// Push IMMÉDIATEMENT à la playlist
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
|
if let Some(ref playlist_handle) = self.playlist_handle {
|
||||||
|
playlist_handle.push(pk.clone()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAINTENANT attendre que pump finisse
|
||||||
|
let (result, rx_returned) = pump_handle.await.unwrap()?;
|
||||||
|
rx = rx_returned; // récupérer rx pour la prochaine track
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avantages:**
|
||||||
|
- ✅ Pas de problème de borrow checker
|
||||||
|
- ✅ Push immédiat après prebuffer
|
||||||
|
- ✅ Délai réduit à ~1s
|
||||||
|
|
||||||
|
**Inconvénients:**
|
||||||
|
- ⚠️ Nécessite de modifier la signature de `pump_track_segments`
|
||||||
|
- ⚠️ Plus complexe (ownership passé puis rendu)
|
||||||
|
|
||||||
|
### Solution B: Utiliser un channel pour signaler le prebuffer
|
||||||
|
|
||||||
|
**Approche:**
|
||||||
|
1. Créer un oneshot channel `(prebuffer_tx, prebuffer_rx)`
|
||||||
|
2. `cache_future` envoie le pk via `prebuffer_tx` dès le prebuffer atteint
|
||||||
|
3. Le code principal attend `prebuffer_rx`, push immédiatement
|
||||||
|
4. Puis attend `tokio::join!()` normalement
|
||||||
|
|
||||||
|
**Code:**
|
||||||
|
```rust
|
||||||
|
let (prebuffer_tx, prebuffer_rx) = tokio::sync::oneshot::channel();
|
||||||
|
|
||||||
|
let cache_future = async {
|
||||||
|
let pk = self.cache.add_from_reader(...).await?;
|
||||||
|
let _ = prebuffer_tx.send(pk.clone()); // Signal prebuffer!
|
||||||
|
Ok(pk)
|
||||||
|
};
|
||||||
|
|
||||||
|
let pump_future = pump_track_segments(...);
|
||||||
|
|
||||||
|
// Spawner les deux en parallèle
|
||||||
|
let cache_handle = tokio::spawn(cache_future);
|
||||||
|
let pump_handle = tokio::spawn(pump_future);
|
||||||
|
|
||||||
|
// Attendre SEULEMENT le signal de prebuffer
|
||||||
|
let pk = prebuffer_rx.await.unwrap();
|
||||||
|
|
||||||
|
// Push IMMÉDIATEMENT à la playlist
|
||||||
|
playlist_handle.push(pk.clone()).await?;
|
||||||
|
|
||||||
|
// Puis attendre que tout finisse
|
||||||
|
let (cache_result, pump_result) = tokio::join!(cache_handle, pump_handle);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avantages:**
|
||||||
|
- ✅ Pas besoin de changer les signatures
|
||||||
|
- ✅ Push immédiat après prebuffer
|
||||||
|
|
||||||
|
**Inconvénients:**
|
||||||
|
- ⚠️ Nécessite de wrapper cache_future pour envoyer le signal
|
||||||
|
- ⚠️ Ajoute un oneshot channel
|
||||||
|
|
||||||
|
### Solution C: Modifier l'API du cache pour avoir un callback
|
||||||
|
|
||||||
|
**Approche:**
|
||||||
|
1. Ajouter un paramètre callback à `add_from_reader()`
|
||||||
|
2. Le cache appelle ce callback dès le prebuffer atteint
|
||||||
|
3. Le callback push à la playlist
|
||||||
|
|
||||||
|
**Signature:**
|
||||||
|
```rust
|
||||||
|
pub async fn add_from_reader_with_callback<R, F>(
|
||||||
|
&self,
|
||||||
|
source_uri: Option<&str>,
|
||||||
|
reader: R,
|
||||||
|
length: Option<u64>,
|
||||||
|
collection: Option<&str>,
|
||||||
|
on_prebuffer: F, // ← nouveau callback
|
||||||
|
) -> Result<String>
|
||||||
|
where
|
||||||
|
R: AsyncRead + Send + Unpin + 'static,
|
||||||
|
F: FnOnce(String) + Send + 'static, // F reçoit le pk
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avantages:**
|
||||||
|
- ✅ API propre et réutilisable
|
||||||
|
- ✅ Pas de problème de borrow checker
|
||||||
|
|
||||||
|
**Inconvénients:**
|
||||||
|
- ⚠️ Nécessite de modifier l'API du cache (impact sur autres parties du code)
|
||||||
|
- ⚠️ Ajoute de la complexité à l'API
|
||||||
|
|
||||||
|
## Recommandation
|
||||||
|
|
||||||
|
**Je recommande la Solution A** (refactoriser `pump_track_segments_owned`):
|
||||||
|
- Plus explicite et claire
|
||||||
|
- Pas d'impact sur l'API du cache
|
||||||
|
- Ownership bien défini (passage puis retour de rx)
|
||||||
|
- Testable indépendamment
|
||||||
|
|
||||||
|
## Plan d'implémentation
|
||||||
|
|
||||||
|
### Étape 1: Créer pump_track_segments_owned
|
||||||
|
|
||||||
|
Location: `pmoaudio-ext/src/sinks/flac_cache_sink.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Pompe les segments pour une seule track (s'arrête au TrackBoundary).
|
||||||
|
///
|
||||||
|
/// Version qui prend ownership de rx pour permettre un await séparé du cache.
|
||||||
|
/// Retourne rx à la fin pour permettre le traitement des tracks suivantes.
|
||||||
|
async fn pump_track_segments_owned(
|
||||||
|
first_segment: Arc<AudioSegment>,
|
||||||
|
mut rx: mpsc::Receiver<Arc<AudioSegment>>,
|
||||||
|
pcm_tx: mpsc::Sender<Vec<u8>>,
|
||||||
|
bits_per_sample: u8,
|
||||||
|
expected_rate: u32,
|
||||||
|
stop_token: CancellationToken,
|
||||||
|
) -> Result<(u64, u64, f64, StopReason, mpsc::Receiver<Arc<AudioSegment>>), AudioError> {
|
||||||
|
let mut chunks = 0u64;
|
||||||
|
let mut samples = 0u64;
|
||||||
|
let mut duration_sec = 0.0f64;
|
||||||
|
|
||||||
|
// Traiter le premier segment
|
||||||
|
if let Some(chunk) = first_segment.as_chunk() {
|
||||||
|
let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?;
|
||||||
|
if !pcm_bytes.is_empty() {
|
||||||
|
if pcm_tx.send(pcm_bytes).await.is_err() {
|
||||||
|
drop(pcm_tx);
|
||||||
|
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx));
|
||||||
|
}
|
||||||
|
chunks += 1;
|
||||||
|
samples += chunk.len() as u64;
|
||||||
|
duration_sec += chunk.len() as f64 / expected_rate as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop pour le reste des segments...
|
||||||
|
loop {
|
||||||
|
let segment = tokio::select! {
|
||||||
|
result = rx.recv() => {
|
||||||
|
match result {
|
||||||
|
Some(seg) => seg,
|
||||||
|
None => {
|
||||||
|
drop(pcm_tx);
|
||||||
|
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = stop_token.cancelled() => {
|
||||||
|
drop(pcm_tx);
|
||||||
|
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match &segment.segment {
|
||||||
|
_AudioSegment::Chunk(chunk) => {
|
||||||
|
let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?;
|
||||||
|
if !pcm_bytes.is_empty() {
|
||||||
|
if pcm_tx.send(pcm_bytes).await.is_err() {
|
||||||
|
drop(pcm_tx);
|
||||||
|
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx));
|
||||||
|
}
|
||||||
|
chunks += 1;
|
||||||
|
samples += chunk.len() as u64;
|
||||||
|
duration_sec += chunk.len() as f64 / expected_rate as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_AudioSegment::Sync(marker) => match &**marker {
|
||||||
|
SyncMarker::TrackBoundary { metadata, .. } => {
|
||||||
|
drop(pcm_tx);
|
||||||
|
return Ok((chunks, samples, duration_sec, StopReason::TrackBoundary(metadata.clone()), rx));
|
||||||
|
}
|
||||||
|
SyncMarker::EndOfStream => {
|
||||||
|
drop(pcm_tx);
|
||||||
|
return Ok((chunks, samples, duration_sec, StopReason::EndOfStream, rx));
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Étape 2: Modifier FlacCacheSinkLogic::process
|
||||||
|
|
||||||
|
Location: `pmoaudio-ext/src/sinks/flac_cache_sink.rs:~167`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let collection_ref = self.collection.as_deref();
|
||||||
|
let cache_future = self.cache.add_from_reader(
|
||||||
|
None,
|
||||||
|
flac_stream,
|
||||||
|
None,
|
||||||
|
collection_ref,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Spawner pump_future avec ownership de rx
|
||||||
|
let pump_handle = tokio::spawn(pump_track_segments_owned(
|
||||||
|
first_segment,
|
||||||
|
rx, // move ownership!
|
||||||
|
pcm_tx,
|
||||||
|
bits_per_sample,
|
||||||
|
sample_rate,
|
||||||
|
stop_token.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Attendre SEULEMENT le prebuffer (cache retourne après 512KB)
|
||||||
|
tracing::debug!("FlacCacheSink: Waiting for cache prebuffer to complete");
|
||||||
|
let pk = cache_future.await.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to add to cache: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::debug!("FlacCacheSink: Prebuffer complete with pk {}, pushing to playlist NOW", pk);
|
||||||
|
|
||||||
|
// Copier les métadonnées AVANT push
|
||||||
|
if let Some(src_metadata) = track_metadata.clone() {
|
||||||
|
let dest_metadata = self.cache.track_metadata(&pk);
|
||||||
|
pmometadata::copy_metadata_into(&src_metadata, &dest_metadata)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to copy metadata to cache: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push IMMÉDIATEMENT à la playlist (après prebuffer, avant pump complet!)
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
|
if let Some(ref playlist_handle) = self.playlist_handle {
|
||||||
|
tracing::debug!("FlacCacheSink: Pushing pk {} to playlist", pk);
|
||||||
|
playlist_handle.push(pk.clone()).await.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to add to playlist: {}", e))
|
||||||
|
})?;
|
||||||
|
tracing::debug!("FlacCacheSink: Successfully pushed to playlist");
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAINTENANT attendre que pump finisse (il continue en arrière-plan)
|
||||||
|
tracing::debug!("FlacCacheSink: Waiting for pump to complete");
|
||||||
|
let pump_result = pump_handle.await.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Pump task panicked: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (_chunks, _samples, _duration_sec, stop_reason, rx_returned) = pump_result?;
|
||||||
|
rx = rx_returned; // récupérer rx pour la prochaine track
|
||||||
|
tracing::debug!("FlacCacheSink: Pump completed");
|
||||||
|
|
||||||
|
// Continuer avec download des covers en arrière-plan...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Étape 3: Tester
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nettoyer et rebuild
|
||||||
|
rm -rf /tmp/pmomusic_test
|
||||||
|
source setup-env.sh
|
||||||
|
cargo build --example play_and_cache --features full
|
||||||
|
|
||||||
|
# Tester avec logs de timing
|
||||||
|
RUST_LOG=debug target/debug/examples/play_and_cache 0 --null-audio 2>&1 | \
|
||||||
|
grep -E "Prebuffer complete|Pushing pk.*to playlist|popped track" | \
|
||||||
|
head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
**Résultats attendus:**
|
||||||
|
```
|
||||||
|
[TIME_A] FlacCacheSink: Prebuffer complete with pk XXX, pushing to playlist NOW
|
||||||
|
[TIME_B] FlacCacheSink: Successfully pushed to playlist
|
||||||
|
[TIME_C] PlaylistSourceLogic: popped track from playlist
|
||||||
|
|
||||||
|
Délai (TIME_C - TIME_A) devrait être < 1 seconde!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Étape 4: Valider le comportement
|
||||||
|
|
||||||
|
Vérifier que:
|
||||||
|
1. ✅ Le prebuffer est atteint rapidement (~0.6s)
|
||||||
|
2. ✅ Le push à la playlist est immédiat (~0.1s après prebuffer)
|
||||||
|
3. ✅ La lecture démarre rapidement (~1s total)
|
||||||
|
4. ✅ Toutes les tracks se suivent correctement
|
||||||
|
5. ✅ Les completion markers sont créés
|
||||||
|
6. ✅ Les tracks suivantes fonctionnent (rx est bien récupéré)
|
||||||
|
7. ✅ Pas de panic ou deadlock
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Si le borrow checker proteste
|
||||||
|
|
||||||
|
Vérifier que:
|
||||||
|
- `pump_track_segments_owned` prend bien ownership de `rx` (pas `&mut`)
|
||||||
|
- `rx` est bien retourné dans le tuple de retour
|
||||||
|
- `rx = rx_returned;` récupère bien ownership après await
|
||||||
|
|
||||||
|
### Si les tracks suivantes ne fonctionnent pas
|
||||||
|
|
||||||
|
Vérifier que:
|
||||||
|
- `rx` est bien réassigné après le pump: `rx = rx_returned;`
|
||||||
|
- La loop dans `process()` continue correctement avec le nouveau `rx`
|
||||||
|
|
||||||
|
### Si le timing n'est pas amélioré
|
||||||
|
|
||||||
|
Ajouter des logs avec timestamps:
|
||||||
|
```rust
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let pk = cache_future.await?;
|
||||||
|
tracing::info!("Prebuffer took {:?}", start.elapsed());
|
||||||
|
|
||||||
|
let start2 = std::time::Instant::now();
|
||||||
|
playlist_handle.push(pk.clone()).await?;
|
||||||
|
tracing::info!("Playlist push took {:?}", start2.elapsed());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers à modifier
|
||||||
|
|
||||||
|
1. **pmoaudio-ext/src/sinks/flac_cache_sink.rs**
|
||||||
|
- Ajouter `pump_track_segments_owned()` (~ligne 432)
|
||||||
|
- Modifier `FlacCacheSinkLogic::process()` (~ligne 167)
|
||||||
|
|
||||||
|
## Tests de régression
|
||||||
|
|
||||||
|
Après l'implémentation, tester:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test 1: Premier download (cache vide)
|
||||||
|
rm -rf /tmp/pmomusic_test
|
||||||
|
target/debug/examples/play_and_cache 0 --null-audio
|
||||||
|
|
||||||
|
# Test 2: Deuxième download (fichier déjà en cache)
|
||||||
|
# Ne pas supprimer /tmp/pmomusic_test
|
||||||
|
target/debug/examples/play_and_cache 0 --null-audio
|
||||||
|
|
||||||
|
# Test 3: Download interrompu (Ctrl+C)
|
||||||
|
target/debug/examples/play_and_cache 0 --null-audio
|
||||||
|
# Appuyer Ctrl+C après 2 secondes
|
||||||
|
|
||||||
|
# Test 4: Plusieurs tracks consécutives
|
||||||
|
# Laisser tourner 1 minute pour voir plusieurs tracks
|
||||||
|
timeout 60 target/debug/examples/play_and_cache 0 --null-audio
|
||||||
|
```
|
||||||
|
|
||||||
|
## Métriques de succès
|
||||||
|
|
||||||
|
- ✅ Délai prebuffer → playlist: **< 1 seconde** (actuellement ~19s)
|
||||||
|
- ✅ Délai prebuffer → lecture: **< 2 secondes** (actuellement ~19.5s)
|
||||||
|
- ✅ Pas de régression fonctionnelle
|
||||||
|
- ✅ Toutes les tracks se suivent correctement
|
||||||
|
- ✅ Les completion markers sont créés
|
||||||
|
|
||||||
|
## Références
|
||||||
|
|
||||||
|
- Branche actuelle: `claude/fix-play-and-cache-streaming-011CUsMBxH4fsgoadgkiPdoK`
|
||||||
|
- Code de référence: commit `ed0bbfb` (Add FlacCacheSink debug logs - system now works!)
|
||||||
|
- Issue originale: "play_and_cache n'a pas le comportement souhaité"
|
||||||
285
PLAYER_PMOSOURCE_README.md
Normal file
285
PLAYER_PMOSOURCE_README.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
# Player Générique PMO Music
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
Ce document décrit l'implémentation d'un nouveau player web générique qui utilise **uniquement** l'API du trait `pmosource` sans dépendre d'aucune implémentation spécifique (comme `pmoparadise`).
|
||||||
|
|
||||||
|
## Objectifs
|
||||||
|
|
||||||
|
L'objectif principal est de **tester l'API `pmosource` dans un cas d'application concret** afin d'identifier ce qui manque ou pourrait être amélioré dans l'API générique.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 1. Service API TypeScript (`pmoapp/webapp/src/services/pmosource.ts`)
|
||||||
|
|
||||||
|
Service qui encapsule toutes les interactions avec l'API REST de pmosource :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Endpoints utilisés
|
||||||
|
GET /api/sources // Liste les sources
|
||||||
|
GET /api/sources/{id} // Info sur une source
|
||||||
|
GET /api/sources/{id}/root // Container racine
|
||||||
|
GET /api/sources/{id}/browse // Parcourt un container
|
||||||
|
GET /api/sources/{id}/resolve // Résout l'URI d'un item
|
||||||
|
GET /api/sources/{id}/image // Image de la source
|
||||||
|
GET /api/sources/{id}/capabilities // Capacités de la source
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fonctions implémentées :**
|
||||||
|
- `listSources()` - Liste toutes les sources enregistrées
|
||||||
|
- `getSource(id)` - Récupère une source spécifique
|
||||||
|
- `getSourceRoot(id)` - Récupère le container racine
|
||||||
|
- `browseSource(id, objectId?, pagination?)` - Navigation dans les containers
|
||||||
|
- `resolveUri(sourceId, objectId)` - Résout l'URI de streaming
|
||||||
|
- `getSourceImageUrl(id)` - URL de l'image de la source
|
||||||
|
|
||||||
|
### 2. Composant Player (`pmoapp/webapp/src/components/GenericMusicPlayer.vue`)
|
||||||
|
|
||||||
|
Composant Vue.js qui implémente :
|
||||||
|
|
||||||
|
#### Fonctionnalités implémentées
|
||||||
|
|
||||||
|
1. **Sélection de sources**
|
||||||
|
- Affichage de toutes les sources disponibles
|
||||||
|
- Affichage du logo de chaque source
|
||||||
|
- Affichage des capacités (FIFO, Search, Favorites)
|
||||||
|
|
||||||
|
2. **Navigation dans les containers**
|
||||||
|
- Breadcrumb pour remonter dans la hiérarchie
|
||||||
|
- Affichage des sous-containers (dossiers)
|
||||||
|
- Navigation par clic dans les containers
|
||||||
|
|
||||||
|
3. **Liste des morceaux**
|
||||||
|
- Affichage de tous les items audio d'un container
|
||||||
|
- Métadonnées : titre, artiste, album, cover art
|
||||||
|
- Numérotation des morceaux
|
||||||
|
|
||||||
|
4. **Lecteur audio**
|
||||||
|
- Lecture d'un morceau via résolution d'URI
|
||||||
|
- Contrôles audio natifs HTML5
|
||||||
|
- Section "Now Playing" avec métadonnées
|
||||||
|
- Gestion des erreurs de lecture
|
||||||
|
|
||||||
|
5. **Interface utilisateur**
|
||||||
|
- Design moderne avec dégradés et animations
|
||||||
|
- Responsive design
|
||||||
|
- Indicateurs visuels (morceau actif, en cours de lecture)
|
||||||
|
- Messages d'erreur clairs
|
||||||
|
|
||||||
|
### 3. Intégration
|
||||||
|
|
||||||
|
Le player a été configuré comme **page d'accueil par défaut** de l'application web PMO :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// router/index.ts
|
||||||
|
const routes = [
|
||||||
|
{ path: "/", name: "home", component: GenericMusicPlayer },
|
||||||
|
// ... autres routes
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ce qui fonctionne
|
||||||
|
|
||||||
|
✅ **Complètement fonctionnel avec l'API actuelle de pmosource :**
|
||||||
|
|
||||||
|
1. Découverte des sources disponibles
|
||||||
|
2. Navigation complète dans la hiérarchie des containers
|
||||||
|
3. Affichage des métadonnées des morceaux
|
||||||
|
4. Résolution des URIs et lecture audio
|
||||||
|
5. Affichage des images de sources
|
||||||
|
6. **Métadonnées temps réel via Server-Sent Events (SSE)** 🆕
|
||||||
|
- Mise à jour automatique toutes les 3 secondes
|
||||||
|
- Pas de polling, push serveur
|
||||||
|
- Reconnexion automatique
|
||||||
|
|
||||||
|
## Limitations identifiées et améliorations possibles
|
||||||
|
|
||||||
|
### 1. Métadonnées de couverture d'album
|
||||||
|
|
||||||
|
**Problème :** Le trait `MusicSource` n'expose pas directement de méthode pour résoudre les URIs de couvertures d'album.
|
||||||
|
|
||||||
|
**État actuel :**
|
||||||
|
- Le champ `album_art` dans `Item` contient parfois une URI
|
||||||
|
- Le champ `album_art_pk` contient une clé primaire mais pas d'URL exploitable directement
|
||||||
|
- Certaines implémentations (pmoparadise) utilisent `/cache/cover/{pk}` mais ce n'est pas standardisé
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
```rust
|
||||||
|
/// Résout l'URI de la couverture d'album pour un item
|
||||||
|
async fn resolve_cover_uri(&self, object_id: &str) -> Result<Option<String>>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Recherche globale
|
||||||
|
|
||||||
|
**Problème :** La méthode `search()` existe mais retourne `SearchNotSupported` par défaut.
|
||||||
|
|
||||||
|
**État actuel :**
|
||||||
|
- Pas d'interface standardisée pour la recherche dans l'UI
|
||||||
|
- Pas de retour clair sur les capacités de recherche
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
- Utiliser `capabilities().supports_search` pour afficher/masquer l'UI de recherche
|
||||||
|
- Documenter clairement le format attendu des requêtes de recherche
|
||||||
|
|
||||||
|
### 3. Pagination
|
||||||
|
|
||||||
|
**Problème :** L'API supporte la pagination mais les métadonnées ne permettent pas de connaître le nombre total d'items.
|
||||||
|
|
||||||
|
**État actuel :**
|
||||||
|
- `BrowseResponse.total` retourne le nombre d'items retournés, pas le total disponible
|
||||||
|
- Pas de méthode `get_total_count(object_id)` dans le trait
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
```rust
|
||||||
|
/// Retourne le nombre total d'items dans un container
|
||||||
|
async fn get_total_count(&self, object_id: &str) -> Result<usize>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou ajouter `total_available` dans `BrowseResponse` :
|
||||||
|
```rust
|
||||||
|
pub struct SourceBrowseResponse {
|
||||||
|
// ... champs existants
|
||||||
|
pub total_available: Option<usize>, // Total disponible (pas juste retourné)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Métadonnées de stream en temps réel ✅ **IMPLÉMENTÉ**
|
||||||
|
|
||||||
|
**Solution implémentée :**
|
||||||
|
- ✅ Méthode `get_item(object_id)` dans le trait `MusicSource`
|
||||||
|
- ✅ Endpoint REST `GET /api/sources/{id}/item?object_id={id}` pour récupérer les métadonnées d'un item
|
||||||
|
- ✅ Endpoint SSE `GET /api/sources/{id}/item/stream?object_id={id}` pour recevoir les mises à jour en temps réel
|
||||||
|
- ✅ Le player web utilise Server-Sent Events (SSE) pour les métadonnées temps réel
|
||||||
|
|
||||||
|
**Comment ça fonctionne :**
|
||||||
|
1. Le serveur envoie automatiquement les métadonnées à jour toutes les 3 secondes via SSE
|
||||||
|
2. Le client se connecte avec `EventSource` (API browser native)
|
||||||
|
3. Les métadonnées sont automatiquement mises à jour dans l'interface sans polling
|
||||||
|
|
||||||
|
**Pour RadioParadise :**
|
||||||
|
- La méthode `get_item()` pour les live streams récupère les métadonnées depuis `/radioparadise/metadata/{slug}`
|
||||||
|
- Le SSE permet d'avoir les métadonnées à jour en moins de 3 secondes (au lieu de 10 secondes avec le polling)
|
||||||
|
|
||||||
|
### 5. Playlists utilisateur
|
||||||
|
|
||||||
|
**Problème :** Les méthodes existent (`get_user_playlists()`, `add_to_playlist()`) mais retournent `NotSupported` par défaut.
|
||||||
|
|
||||||
|
**État actuel :**
|
||||||
|
- Pas encore testé dans le player
|
||||||
|
- Nécessiterait une UI dédiée
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
- Créer une section "Playlists" dans le player
|
||||||
|
- Tester l'API avec une implémentation qui supporte les playlists (ex: Qobuz)
|
||||||
|
|
||||||
|
### 6. Favoris
|
||||||
|
|
||||||
|
**Problème :** Similaire aux playlists, l'API existe mais n'est pas testée.
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
- Ajouter un bouton "⭐ Favoris" sur chaque morceau
|
||||||
|
- Afficher visuellement les morceaux favoris
|
||||||
|
- Créer une section "Mes Favoris"
|
||||||
|
|
||||||
|
### 7. Auto-play / Queue
|
||||||
|
|
||||||
|
**Problème :** Il n'y a pas de méthode pour gérer une file d'attente de lecture.
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
```rust
|
||||||
|
/// Interface pour gérer une queue de lecture
|
||||||
|
pub trait Playable: MusicSource {
|
||||||
|
async fn get_next_track(&self) -> Result<Option<Item>>;
|
||||||
|
async fn get_previous_track(&self) -> Result<Option<Item>>;
|
||||||
|
async fn add_to_queue(&self, item: Item) -> Result<()>;
|
||||||
|
async fn clear_queue(&self) -> Result<()>;
|
||||||
|
async fn get_queue(&self) -> Result<Vec<Item>>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Durée totale d'un container
|
||||||
|
|
||||||
|
**Problème :** Pour afficher "Album: 45:32 min, 12 morceaux", il faut parcourir tous les items.
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
```rust
|
||||||
|
/// Statistiques d'un container spécifique
|
||||||
|
async fn get_container_stats(&self, object_id: &str) -> Result<ContainerStats>;
|
||||||
|
|
||||||
|
pub struct ContainerStats {
|
||||||
|
pub item_count: usize,
|
||||||
|
pub total_duration_ms: Option<u64>,
|
||||||
|
pub total_size_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Formats audio disponibles
|
||||||
|
|
||||||
|
**Problème :** La méthode `get_available_formats()` existe mais n'est pas exploitée dans l'UI.
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
- Ajouter un sélecteur de qualité dans le player
|
||||||
|
- Afficher les formats disponibles (FLAC 24/96, MP3 320, etc.)
|
||||||
|
|
||||||
|
### 10. État du cache
|
||||||
|
|
||||||
|
**Problème :** Les méthodes existent (`get_cache_status()`, `cache_item()`) mais ne sont pas intégrées.
|
||||||
|
|
||||||
|
**Proposition :**
|
||||||
|
- Afficher un indicateur de cache sur chaque morceau
|
||||||
|
- Bouton "📥 Télécharger" pour mettre en cache
|
||||||
|
- Barre de progression pour le téléchargement
|
||||||
|
|
||||||
|
## Prochaines étapes
|
||||||
|
|
||||||
|
### Court terme
|
||||||
|
1. ✅ Tester le player avec `pmoparadise` (déjà implémenté)
|
||||||
|
2. 🔄 Identifier les bugs et limitations pratiques
|
||||||
|
3. 🔄 Tester avec une deuxième source (ex: `pmoqobuz`) pour valider la généricité
|
||||||
|
|
||||||
|
### Moyen terme
|
||||||
|
1. Implémenter les fonctionnalités manquantes identifiées ci-dessus
|
||||||
|
2. Ajouter la gestion de queue et auto-play
|
||||||
|
3. Ajouter la recherche si supportée
|
||||||
|
4. Intégrer la gestion du cache
|
||||||
|
|
||||||
|
### Long terme
|
||||||
|
1. Support des playlists utilisateur
|
||||||
|
2. Support des favoris
|
||||||
|
3. Égaliseur et effets audio
|
||||||
|
4. Visualisations audio
|
||||||
|
5. Mode hors-ligne avec cache
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Le player générique démontre que **l'API `pmosource` est déjà très utilisable** pour créer une application musicale fonctionnelle. Les principales limitations concernent :
|
||||||
|
|
||||||
|
1. **Les métadonnées de couvertures** (pas d'URL standardisée)
|
||||||
|
2. **La pagination avancée** (pas de compte total)
|
||||||
|
3. **Les métadonnées temps réel** (pour les streams live)
|
||||||
|
4. **La gestion de queue** (pas d'API dédiée)
|
||||||
|
|
||||||
|
Ces limitations ne sont pas bloquantes mais leur résolution améliorerait significativement l'expérience utilisateur et la complétude de l'API.
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
Pour tester le player :
|
||||||
|
|
||||||
|
1. Lancer le serveur backend avec au moins une source enregistrée :
|
||||||
|
```bash
|
||||||
|
cargo run --example single_channel_server --features full
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Accéder à l'application web :
|
||||||
|
```
|
||||||
|
http://localhost:8080/app/
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Le player devrait afficher automatiquement les sources disponibles et permettre la navigation et la lecture.
|
||||||
|
|
||||||
|
## Remarques importantes
|
||||||
|
|
||||||
|
- ✅ Le player **n'utilise QUE l'API pmosource générique**
|
||||||
|
- ✅ Aucune dépendance sur `pmoparadise` ou toute autre implémentation spécifique
|
||||||
|
- ✅ Tout est basé sur les endpoints REST de `pmosource::api`
|
||||||
|
- ✅ Le code est totalement réutilisable pour toute nouvelle source (Qobuz, Spotify, etc.)
|
||||||
BIN
PMOMusic/.pmomusic/cache_audio/cache.db
Normal file
BIN
PMOMusic/.pmomusic/cache_audio/cache.db
Normal file
Binary file not shown.
BIN
PMOMusic/.pmomusic/cache_covers/cache.db
Normal file
BIN
PMOMusic/.pmomusic/cache_covers/cache.db
Normal file
Binary file not shown.
21
PMOMusic/.pmomusic/config.yaml
Normal file
21
PMOMusic/.pmomusic/config.yaml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
host:
|
||||||
|
http_port: '8080'
|
||||||
|
cover_cache:
|
||||||
|
directory: cache_covers
|
||||||
|
size: 2000
|
||||||
|
audio_cache:
|
||||||
|
directory: cache_audio
|
||||||
|
size: 500
|
||||||
|
logger:
|
||||||
|
buffer_capacity: 200
|
||||||
|
enable_console: true
|
||||||
|
min_level: INFO
|
||||||
|
playlists:
|
||||||
|
directory: playlists
|
||||||
|
devices:
|
||||||
|
mediarenderer:
|
||||||
|
pmo_mediarenderer:
|
||||||
|
udn: f77de90b-3a4a-408c-8462-3308ad500744
|
||||||
|
mediaserver:
|
||||||
|
pmo_mediaserver:
|
||||||
|
udn: 88b84e76-4de0-4ee6-b794-99cc4a278cc9
|
||||||
BIN
PMOMusic/.pmomusic/playlists/playlists.db
Normal file
BIN
PMOMusic/.pmomusic/playlists/playlists.db
Normal file
Binary file not shown.
@@ -1,17 +1,25 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.1.0"
|
version = "0.3.5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pmoconfig = { path = "../pmoconfig" }
|
pmoconfig = { path = "../pmoconfig" }
|
||||||
pmoupnp = { path = "../pmoupnp"}
|
pmoupnp = { path = "../pmoupnp"}
|
||||||
|
pmomediarenderer = { path = "../pmomediarenderer" }
|
||||||
|
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "api"] }
|
||||||
|
pmosource = { path = "../pmosource", features = ["server"] }
|
||||||
pmoserver = { path = "../pmoserver" }
|
pmoserver = { path = "../pmoserver" }
|
||||||
pmoapp = { path = "../pmoapp" }
|
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
|
||||||
|
pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"]}
|
||||||
|
pmoaudio-ext = { path = "../pmoaudio-ext", features = ["all"] }
|
||||||
|
pmoapp = { path = "../pmoapp", features = ["pmoserver"] }
|
||||||
|
pmocontrol = { path = "../pmocontrol", features = ["pmoserver"] }
|
||||||
|
|
||||||
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time", "signal"] }
|
||||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] }
|
tracing = { workspace = true }
|
||||||
tracing = "0.1.41"
|
|
||||||
tracing-subscriber = "0.3.20"
|
tracing-subscriber = "0.3.20"
|
||||||
axum = "0.8.4"
|
axum = "0.8.4"
|
||||||
serde_json = "1.0.145"
|
serde_json = "1.0.145"
|
||||||
|
utoipa = "5.4"
|
||||||
|
console-subscriber = "0.4.1"
|
||||||
|
|||||||
25
PMOMusic/Cargo.toml.backup
Normal file
25
PMOMusic/Cargo.toml.backup
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "PMOMusic"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pmoconfig = { path = "../pmoconfig" }
|
||||||
|
pmoupnp = { path = "../pmoupnp"}
|
||||||
|
pmomediarenderer = { path = "../pmomediarenderer" }
|
||||||
|
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "api"] }
|
||||||
|
pmosource = { path = "../pmosource", features = ["server"] }
|
||||||
|
pmoserver = { path = "../pmoserver" }
|
||||||
|
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
|
||||||
|
pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"]}
|
||||||
|
pmoaudio-ext = { path = "../pmoaudio-ext", features = ["all"] }
|
||||||
|
pmoapp = { path = "../pmoapp", features = ["pmoserver"] }
|
||||||
|
pmocontrol = { path = "../pmocontrol", features = ["pmoserver"] }
|
||||||
|
|
||||||
|
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] }
|
||||||
|
tracing = "0.1.41"
|
||||||
|
tracing-subscriber = "0.3.20"
|
||||||
|
axum = "0.8.4"
|
||||||
|
serde_json = "1.0.145"
|
||||||
|
utoipa = "5.4"
|
||||||
|
console-subscriber = "0.4.1"
|
||||||
@@ -1,58 +1,135 @@
|
|||||||
use pmoupnp::{mediarenderer::avtransport::AVTTRANSPORT, UpnpObject};
|
use pmoapp::{WebAppExt, Webapp};
|
||||||
use pmoserver::{
|
use pmocontrol::ControlPointExt;
|
||||||
logs::{log_dump, log_sse, LogState, SseLayer},
|
use pmomediarenderer::MEDIA_RENDERER;
|
||||||
ServerBuilder
|
use pmomediaserver::{
|
||||||
|
MEDIA_SERVER, MediaServerDeviceExt, ParadiseStreamingExt, sources::SourcesExt,
|
||||||
};
|
};
|
||||||
use pmoapp::Webapp;
|
use pmoserver::Server;
|
||||||
use tracing_subscriber::Registry;
|
use pmosource::MusicSourceExt;
|
||||||
use tracing_subscriber::prelude::*;
|
use pmoupnp::UpnpServerExt;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Charger la config
|
// ========== PHASE 1 : Infrastructure UPnP ==========
|
||||||
|
// #[cfg(tokio_unstable)]
|
||||||
let mut server = ServerBuilder::new_configured().build();
|
// console_subscriber::init();
|
||||||
|
|
||||||
// Ajouter des routes
|
|
||||||
server
|
|
||||||
.add_route("/hello", || async {
|
|
||||||
serde_json::json!({"message": "Hello World"})
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
|
let server = Server::create_upnp_server().await?; // Routes personnalisées de l'application
|
||||||
server
|
server
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
.add_route("/info", || async {
|
.add_route("/info", || async {
|
||||||
serde_json::json!({"version": "1.0.0"})
|
serde_json::json!({"version": "1.0.0"})
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
server.add_spa::<Webapp>("/app").await;
|
// Initialiser le système de gestion des sources musicales avec API REST
|
||||||
|
info!("📡 Initializing music sources management system...");
|
||||||
// Gère la sortie des logs et sur le serveur SSE pour l'interface web et sur la console
|
|
||||||
let log_state = LogState::new(1000);
|
|
||||||
let subscriber = Registry::default()
|
|
||||||
.with(
|
|
||||||
tracing_subscriber::fmt::layer()
|
|
||||||
.with_target(true)
|
|
||||||
.with_level(true)
|
|
||||||
.with_ansi(true), // Couleurs dans le terminal
|
|
||||||
)
|
|
||||||
.with(SseLayer::new(log_state.clone()));
|
|
||||||
tracing::subscriber::set_global_default(subscriber).unwrap();
|
|
||||||
|
|
||||||
server
|
server
|
||||||
.add_handler_with_state("/log-sse", log_sse, log_state.clone())
|
.write()
|
||||||
.await;
|
.await
|
||||||
|
.init_music_sources()
|
||||||
|
.await
|
||||||
|
.expect("Failed to initialize music sources API");
|
||||||
|
|
||||||
|
// ========== PHASE 2 : Configuration métier ==========
|
||||||
|
|
||||||
|
// Enregistrer les sources musicales
|
||||||
|
info!("🎵 Registering music sources...");
|
||||||
|
|
||||||
|
// Enregistrer Qobuz pour activer les lazy providers (QOBUZ:PK)
|
||||||
|
if let Err(e) = server.write().await.register_qobuz().await {
|
||||||
|
tracing::warn!("⚠️ Failed to register Qobuz source: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialiser les canaux de streaming Radio Paradise (pipelines + routes HTTP)
|
||||||
|
info!("📻 Initializing Radio Paradise streaming channels...");
|
||||||
|
if let Err(e) = server.write().await.init_paradise_streaming().await {
|
||||||
|
tracing::warn!("⚠️ Failed to initialize Paradise streaming: {}", e);
|
||||||
|
} else {
|
||||||
|
// Enregistrer la source Radio Paradise UPnP (inclut l'initialisation de l'API)
|
||||||
|
if let Err(e) = server.write().await.register_paradise().await {
|
||||||
|
tracing::warn!("⚠️ Failed to register Radio Paradise source: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lister toutes les sources enregistrées
|
||||||
|
let sources = server.read().await.list_music_sources().await;
|
||||||
|
info!("✅ {} music source(s) registered", sources.len());
|
||||||
|
for source in sources {
|
||||||
|
info!(" - {} ({})", source.name(), source.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enregistrer les devices UPnP (HTTP + SSDP automatique)
|
||||||
|
info!("📡 Registering UPnP devices...");
|
||||||
|
|
||||||
|
let renderer_instance = server
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.register_device(MEDIA_RENDERER.clone())
|
||||||
|
.await
|
||||||
|
.expect("Failed to register MediaRenderer");
|
||||||
|
|
||||||
|
let base_url = renderer_instance.base_url();
|
||||||
|
let desc_route = renderer_instance.description_route();
|
||||||
|
info!("✅ MediaRenderer ready at {}{}", base_url, desc_route);
|
||||||
|
|
||||||
|
let server_instance = server
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.register_device(MEDIA_SERVER.clone())
|
||||||
|
.await
|
||||||
|
.expect("Failed to register MediaServer");
|
||||||
|
|
||||||
|
// Enregistrer l'instance ContentDirectory pour les notifications GENA
|
||||||
|
if let Some(cd_service) = server_instance.get_service("ContentDirectory") {
|
||||||
|
pmomediaserver::contentdirectory::state::register_instance(&cd_service);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialiser les ProtocolInfo du MediaServer
|
||||||
|
server_instance.init_protocol_info();
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"✅ MediaServer ready at {}{}",
|
||||||
|
server_instance.base_url(),
|
||||||
|
server_instance.description_route()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enregistrer le Control Point (découverte renderers/serveurs + API REST + SSE)
|
||||||
|
info!("🎛️ Registering Control Point...");
|
||||||
|
let _control_point = server
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.register_control_point(5)
|
||||||
|
.await
|
||||||
|
.expect("Failed to register Control Point");
|
||||||
|
|
||||||
|
// Ajouter la webapp via le trait WebAppExt
|
||||||
|
info!("📡 Registering Web application...");
|
||||||
server
|
server
|
||||||
.add_handler_with_state("/log-dump", log_dump, log_state.clone())
|
.write()
|
||||||
|
.await
|
||||||
|
.add_webapp_with_redirect::<Webapp>("/app")
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
server.add_redirect("/", "/app").await;
|
// ========== PHASE 3 : Démarrage du serveur ==========
|
||||||
|
|
||||||
info!("{}",AVTTRANSPORT.to_markdown());
|
info!("🌐 Starting HTTP server...");
|
||||||
info!("{}",AVTTRANSPORT.scpd_xml());
|
server.write().await.start().await;
|
||||||
|
|
||||||
server.start().await;
|
info!("✅ PMOMusic is ready!");
|
||||||
server.wait().await;
|
info!("Press Ctrl+C to stop...");
|
||||||
|
|
||||||
|
// Attendre le signal Ctrl+C et l'arrêt du serveur HTTP
|
||||||
|
server.write().await.wait().await;
|
||||||
|
|
||||||
|
// Le serveur HTTP est arrêté, mais des threads (ControlPoint, etc.) peuvent encore tourner
|
||||||
|
// Attendre 2 secondes pour laisser le temps aux threads de se terminer
|
||||||
|
info!("Waiting for background threads to finish...");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
// Forcer l'arrêt du processus (les threads du ControlPoint tournent en boucle infinie)
|
||||||
|
info!("✅ PMOMusic stopped");
|
||||||
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
447
Plan_d_implementation_webui_control_point.md
Normal file
447
Plan_d_implementation_webui_control_point.md
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
# PMOControl WebUI - Design Recommendations & Implementation Plan
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Based on my analysis of the existing codebase, I'm providing comprehensive recommendations for implementing a Vue.js WebUI for PMOControl. The system already has:
|
||||||
|
- A complete REST API with OpenAPI documentation (`/api/control/*`)
|
||||||
|
- SSE endpoints for real-time updates (`/api/control/events/*`)
|
||||||
|
- Vue 3 + TypeScript + Vite setup
|
||||||
|
- Existing components (GenericMusicPlayer, UpnpExplorer, Cache Managers, LogView)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions & Recommendations
|
||||||
|
|
||||||
|
### 1. State Management: **Use Pinia**
|
||||||
|
|
||||||
|
**Recommendation: Pinia (Vue 3's official state management)**
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- **Centralized real-time state**: Essential for managing SSE updates from multiple sources (renderers, media servers)
|
||||||
|
- **Multi-client synchronization**: Single source of truth for renderer states, volumes, playback positions
|
||||||
|
- **TypeScript native**: Better type inference than Vuex
|
||||||
|
- **DevTools integration**: Built-in debugging for SSE event flows
|
||||||
|
- **Composition API friendly**: Matches existing Vue 3 patterns in codebase
|
||||||
|
- **Performance**: Lightweight (~1KB), modular stores
|
||||||
|
- **Official Vue 3 recommendation**: Future-proof choice
|
||||||
|
|
||||||
|
**Store Architecture:**
|
||||||
|
```typescript
|
||||||
|
// stores/renderers.ts - Renderer state (SSE updates)
|
||||||
|
// stores/mediaServers.ts - Media server state (SSE updates)
|
||||||
|
// stores/playback.ts - Current playback session
|
||||||
|
// stores/ui.ts - UI state (selected renderer, view preferences)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits for your use case:**
|
||||||
|
- Handle 20+ concurrent clients with shared state
|
||||||
|
- Real-time SSE event synchronization across all views
|
||||||
|
- Easy to scale with multi-renderer, multi-server, multi-session architecture
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. UI Component Library: **Headless UI + Custom Components**
|
||||||
|
|
||||||
|
**Recommendation: Hybrid approach - Headless UI components + custom styling**
|
||||||
|
|
||||||
|
**Component Library: Shadcn-vue (Headless UI primitives)**
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- **Lightweight & performant**: Only import what you need
|
||||||
|
- **Full style control**: Match "carte uniforme, responsive, colorée selon statut" spec exactly
|
||||||
|
- **TypeScript-first**: Perfect type safety
|
||||||
|
- **Accessibility built-in**: ARIA compliance out of the box
|
||||||
|
- **No theme lock-in**: Complete CSS freedom
|
||||||
|
- **Composable primitives**: Card, Dialog, Dropdown, Slider components
|
||||||
|
|
||||||
|
**Why NOT a full framework (Vuetify, Element Plus)?**
|
||||||
|
- Heavy bundle size (100-500KB vs ~10KB for headless)
|
||||||
|
- Theme customization overhead
|
||||||
|
- Your spec requires custom status-based coloring
|
||||||
|
- Performance critical with 20 concurrent clients
|
||||||
|
|
||||||
|
**Alternative if you prefer pre-styled:** PrimeVue
|
||||||
|
- Good performance
|
||||||
|
- Customizable themes
|
||||||
|
- Strong TypeScript support
|
||||||
|
- But: 150KB+ bundle size
|
||||||
|
|
||||||
|
**Custom Components to Build:**
|
||||||
|
- `RendererCard` - Status-colored cards for each renderer
|
||||||
|
- `TransportControls` - Play/Pause/Stop/Next buttons
|
||||||
|
- `VolumeControl` - Slider with mute toggle
|
||||||
|
- `QueueViewer` - Playlist display with drag-drop
|
||||||
|
- `MediaServerBrowser` - Container navigation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Existing Components: **Reorganize into Debug Section**
|
||||||
|
|
||||||
|
**Recommendation: Keep existing components, create new PMOControl home**
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```
|
||||||
|
/app (root) → PMOControl Dashboard (NEW)
|
||||||
|
/app/debug → Dropdown menu
|
||||||
|
├─ /logs → LogView
|
||||||
|
├─ /upnp → UpnpExplorer
|
||||||
|
├─ /covers-cache → CoverCacheManager
|
||||||
|
├─ /audio-cache → AudioCacheManager
|
||||||
|
├─ /api-dashboard → APIDashboard
|
||||||
|
└─ /radio-paradise → RadioParadiseExplorer
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Existing components are valuable for development/debugging
|
||||||
|
- Don't break existing functionality
|
||||||
|
- PMOControl becomes primary interface as specified
|
||||||
|
- Debug tools remain accessible but not prominent
|
||||||
|
- Matches current App.vue dropdown pattern
|
||||||
|
|
||||||
|
**Home Screen (/) - PMOControl Dashboard:**
|
||||||
|
- Grid of renderer cards (status-colored)
|
||||||
|
- Active playback session viewer
|
||||||
|
- Quick controls (play/pause/volume)
|
||||||
|
- Media server browser panel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Responsive Design: **Mobile-first with 3 breakpoints**
|
||||||
|
|
||||||
|
**Recommendation: Follow existing 768px pattern + add tablet/desktop**
|
||||||
|
|
||||||
|
**Breakpoints:**
|
||||||
|
```css
|
||||||
|
/* Mobile: < 768px (existing pattern) */
|
||||||
|
- Single column layout
|
||||||
|
- Stacked renderer cards
|
||||||
|
- Bottom-fixed playback controls
|
||||||
|
- Collapsible media browser
|
||||||
|
|
||||||
|
/* Tablet: 768px - 1024px */
|
||||||
|
- Two column layout
|
||||||
|
- Grid of renderer cards (2 columns)
|
||||||
|
- Side panel for media browser
|
||||||
|
- Floating playback controls
|
||||||
|
|
||||||
|
/* Desktop: > 1024px */
|
||||||
|
- Three column layout
|
||||||
|
- Renderer cards grid (3-4 columns)
|
||||||
|
- Persistent media browser sidebar
|
||||||
|
- Always-visible playback controls
|
||||||
|
```
|
||||||
|
|
||||||
|
**Target Devices:**
|
||||||
|
- **Primary**: Desktop browsers (control station)
|
||||||
|
- **Secondary**: Tablets (remote control)
|
||||||
|
- **Tertiary**: Mobile phones (quick controls)
|
||||||
|
|
||||||
|
**Performance considerations:**
|
||||||
|
- Virtualized lists for 20+ renderers (use vue-virtual-scroller)
|
||||||
|
- Lazy load album art
|
||||||
|
- Throttle SSE position updates (max 1/sec per renderer)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Icons: **Lucide Icons (SVG library)**
|
||||||
|
|
||||||
|
**Recommendation: Lucide Icons (NOT emoji)**
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- **Professional appearance**: Emojis inconsistent across platforms
|
||||||
|
- **Customizable**: Size, color, stroke width
|
||||||
|
- **Lightweight**: Tree-shakeable SVG imports (~1KB per icon)
|
||||||
|
- **Status coloring**: Icons can match card status colors
|
||||||
|
- **Accessibility**: Proper ARIA labels
|
||||||
|
- **Vue components**: `lucide-vue-next` package
|
||||||
|
|
||||||
|
**Icon mapping:**
|
||||||
|
```typescript
|
||||||
|
Play → PlayCircle
|
||||||
|
Pause → PauseCircle
|
||||||
|
Stop → StopCircle
|
||||||
|
Next → SkipForward
|
||||||
|
Volume → Volume2 / VolumeX (muted)
|
||||||
|
Renderer → Speaker / MonitorSpeaker
|
||||||
|
Server → Server / Database
|
||||||
|
Queue → ListMusic
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative if you prefer minimal bundle:** Heroicons
|
||||||
|
- Smaller set (fewer icons)
|
||||||
|
- Tailwind CSS integration
|
||||||
|
- But: less comprehensive for music player needs
|
||||||
|
|
||||||
|
**Why NOT emoji:**
|
||||||
|
- Platform inconsistencies (iOS ≠ Android ≠ Windows)
|
||||||
|
- No color control
|
||||||
|
- Accessibility issues
|
||||||
|
- Unprofessional for production UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Architecture
|
||||||
|
|
||||||
|
### Real-time SSE Integration
|
||||||
|
|
||||||
|
**SSE Event Handling:**
|
||||||
|
```typescript
|
||||||
|
// services/controlPointSSE.ts
|
||||||
|
class ControlPointSSE {
|
||||||
|
private eventSource: EventSource
|
||||||
|
private renderersStore: ReturnType<typeof useRenderersStore>
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.eventSource = new EventSource('/api/control/events')
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('control', (e) => {
|
||||||
|
const event = JSON.parse(e.data)
|
||||||
|
|
||||||
|
if (event.category === 'renderer') {
|
||||||
|
this.handleRendererEvent(event)
|
||||||
|
} else if (event.category === 'media_server') {
|
||||||
|
this.handleServerEvent(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRendererEvent(event: RendererEventPayload) {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'state_changed':
|
||||||
|
this.renderersStore.updateState(event.renderer_id, event.state)
|
||||||
|
break
|
||||||
|
case 'volume_changed':
|
||||||
|
this.renderersStore.updateVolume(event.renderer_id, event.volume)
|
||||||
|
break
|
||||||
|
// ... handle all event types
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Store Integration:**
|
||||||
|
```typescript
|
||||||
|
// stores/renderers.ts
|
||||||
|
export const useRenderersStore = defineStore('renderers', () => {
|
||||||
|
const renderers = ref<Map<string, RendererState>>(new Map())
|
||||||
|
|
||||||
|
// SSE updates
|
||||||
|
function updateState(id: string, state: string) {
|
||||||
|
const renderer = renderers.value.get(id)
|
||||||
|
if (renderer) {
|
||||||
|
renderer.transport_state = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// REST API calls
|
||||||
|
async function play(id: string) {
|
||||||
|
await fetch(`/api/control/renderers/${id}/play`, { method: 'POST' })
|
||||||
|
// SSE will update state automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
return { renderers, updateState, play }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Optimizations
|
||||||
|
|
||||||
|
**For 20+ concurrent clients:**
|
||||||
|
|
||||||
|
1. **Throttle position updates**:
|
||||||
|
```typescript
|
||||||
|
const throttledPositionUpdate = throttle((id, pos) => {
|
||||||
|
store.updatePosition(id, pos)
|
||||||
|
}, 1000) // Max 1 update/second
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Virtual scrolling** for renderer lists:
|
||||||
|
```bash
|
||||||
|
npm install vue-virtual-scroller
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Lazy load album art**:
|
||||||
|
```vue
|
||||||
|
<img :src="albumArt" loading="lazy" />
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Debounce volume sliders**:
|
||||||
|
```typescript
|
||||||
|
const debouncedVolumeChange = debounce((id, vol) => {
|
||||||
|
api.setVolume(id, vol)
|
||||||
|
}, 300)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Memoize computed properties**:
|
||||||
|
```typescript
|
||||||
|
const activeRenderers = computed(() =>
|
||||||
|
renderers.value.filter(r => r.online)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Roadmap
|
||||||
|
|
||||||
|
### Phase 1: Core Infrastructure (Week 1)
|
||||||
|
1. Install Pinia + configure stores
|
||||||
|
2. Install Lucide Icons
|
||||||
|
3. Create SSE service layer
|
||||||
|
4. Setup store structure (renderers, servers, playback, ui)
|
||||||
|
5. Connect SSE events to stores
|
||||||
|
|
||||||
|
### Phase 2: UI Components (Week 2)
|
||||||
|
5. Build RendererCard component (status-colored)
|
||||||
|
6. Build TransportControls component
|
||||||
|
7. Build VolumeControl component
|
||||||
|
8. Build QueueViewer component
|
||||||
|
9. Create responsive grid layouts
|
||||||
|
|
||||||
|
### Phase 3: Dashboard Assembly (Week 3)
|
||||||
|
10. Create PMOControl home view
|
||||||
|
11. Integrate all components
|
||||||
|
12. Add media server browser panel
|
||||||
|
13. Implement responsive breakpoints
|
||||||
|
14. Add loading states & error handling
|
||||||
|
|
||||||
|
### Phase 4: Polish & Testing (Week 4)
|
||||||
|
15. Test with 20+ concurrent clients
|
||||||
|
16. Performance profiling & optimization
|
||||||
|
17. Accessibility audit (ARIA, keyboard nav)
|
||||||
|
18. Cross-browser testing
|
||||||
|
19. Mobile/tablet testing
|
||||||
|
20. Documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies to Install
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"pinia": "^2.2.8",
|
||||||
|
"lucide-vue-next": "^0.470.0",
|
||||||
|
"vue-virtual-scroller": "^2.0.0-beta.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
// Already installed: vue, vue-router, typescript, vite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total bundle size estimate:** +15KB gzipped (Pinia + Lucide + Virtual Scroller)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status-based Coloring Scheme
|
||||||
|
|
||||||
|
Based on "carte uniforme, responsive, colorée selon statut" spec:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Renderer Card Status Colors */
|
||||||
|
.renderer-card.playing {
|
||||||
|
border-color: #22c55e; /* green */
|
||||||
|
background: linear-gradient(135deg, #22c55e10, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.paused {
|
||||||
|
border-color: #f59e0b; /* amber */
|
||||||
|
background: linear-gradient(135deg, #f59e0b10, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.stopped {
|
||||||
|
border-color: #6b7280; /* gray */
|
||||||
|
background: linear-gradient(135deg, #6b728010, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.offline {
|
||||||
|
border-color: #ef4444; /* red */
|
||||||
|
background: linear-gradient(135deg, #ef444410, transparent);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renderer-card.transitioning {
|
||||||
|
border-color: #3b82f6; /* blue */
|
||||||
|
background: linear-gradient(135deg, #3b82f610, transparent);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Answers to Your Specific Questions
|
||||||
|
|
||||||
|
### 1. State Management?
|
||||||
|
**Answer: Pinia** - Vue 3 official, perfect for SSE real-time updates, TypeScript native, lightweight
|
||||||
|
|
||||||
|
### 2. UI Component Library?
|
||||||
|
**Answer: Headless UI (Shadcn-vue) + Custom Components** - Full control over status-based styling, lightweight, no theme lock-in
|
||||||
|
|
||||||
|
### 3. Keep existing components?
|
||||||
|
**Answer: Yes, reorganize into Debug section** - Keep valuable dev tools, make PMOControl the new home screen
|
||||||
|
|
||||||
|
### 4. Responsive breakpoints?
|
||||||
|
**Answer: Mobile-first with 3 breakpoints** - <768px (mobile), 768-1024px (tablet), >1024px (desktop)
|
||||||
|
|
||||||
|
### 5. Icons?
|
||||||
|
**Answer: Lucide Icons (SVG library)** - Professional, customizable, status-colored, NOT emoji
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Mitigation
|
||||||
|
|
||||||
|
**Potential challenges:**
|
||||||
|
|
||||||
|
1. **SSE connection management across tabs**
|
||||||
|
- Solution: Use BroadcastChannel API for cross-tab sync
|
||||||
|
- Fallback: LocalStorage events
|
||||||
|
|
||||||
|
2. **20+ renderers performance**
|
||||||
|
- Solution: Virtual scrolling + throttled updates
|
||||||
|
- Monitor: Chrome DevTools Performance profiler
|
||||||
|
|
||||||
|
3. **Network reliability (SSE reconnection)**
|
||||||
|
- Solution: Exponential backoff reconnection
|
||||||
|
- UI indicator for connection status
|
||||||
|
|
||||||
|
4. **Album art loading (CORS, 404s)**
|
||||||
|
- Solution: Proxy through backend
|
||||||
|
- Fallback: Default placeholder image
|
||||||
|
|
||||||
|
5. **Browser compatibility (SSE support)**
|
||||||
|
- Chrome/Edge: Native support ✅
|
||||||
|
- Firefox: Native support ✅
|
||||||
|
- Safari: Native support ✅
|
||||||
|
- IE11: Use EventSource polyfill
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
**Performance targets:**
|
||||||
|
- Initial load: <2s (FCP)
|
||||||
|
- SSE event latency: <100ms
|
||||||
|
- UI interaction: <16ms (60fps)
|
||||||
|
- Memory usage: <50MB with 20 renderers
|
||||||
|
- Bundle size: <250KB gzipped
|
||||||
|
|
||||||
|
**Functionality checklist:**
|
||||||
|
- [ ] Display all discovered renderers in real-time
|
||||||
|
- [ ] Show accurate playback state (play/pause/stop)
|
||||||
|
- [ ] Volume control works across all renderer types
|
||||||
|
- [ ] Queue display syncs with server
|
||||||
|
- [ ] Media server browsing functional
|
||||||
|
- [ ] Playlist attachment working
|
||||||
|
- [ ] Responsive on mobile/tablet/desktop
|
||||||
|
- [ ] Accessible (WCAG AA compliance)
|
||||||
|
- [ ] 20+ concurrent clients supported
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Review & approve** this plan with stakeholders
|
||||||
|
2. **Clarify any ambiguities** in requirements
|
||||||
|
3. **Set up development environment** (install dependencies)
|
||||||
|
4. **Begin Phase 1** (Core Infrastructure)
|
||||||
|
|
||||||
|
Would you like me to proceed with implementation, or do you have questions about any of these recommendations?
|
||||||
45
Readme.md
45
Readme.md
@@ -1,5 +1,43 @@
|
|||||||
# Développement de l'application PMOMusic en RUST
|
# Développement de l'application PMOMusic en RUST
|
||||||
|
|
||||||
|
## 🚀 Démarrage rapide
|
||||||
|
|
||||||
|
### Installation des dépendances (environnement sans sudo)
|
||||||
|
|
||||||
|
Pour compiler PMOMusic dans un environnement sans privilèges sudo (comme Claude Code) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Installation automatique de libsoxr et libasound2 (une seule fois)
|
||||||
|
./setup-deps.sh
|
||||||
|
|
||||||
|
# 2. Créer le fichier setup-env.sh (une seule fois, voir INSTALL_LIBSOXR.md pour le contenu)
|
||||||
|
cat > setup-env.sh << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
|
||||||
|
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
|
||||||
|
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
|
||||||
|
echo "Variables d'environnement configurées pour PMOMusic"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 3. Configuration de l'environnement (à chaque nouvelle session)
|
||||||
|
source setup-env.sh
|
||||||
|
|
||||||
|
# 4. Compilation
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# 5. Test de l'exemple Radio Paradise
|
||||||
|
cargo run --package pmoparadise --example play_and_cache --features full -- 0
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Note :** Le fichier `setup-env.sh` est dans `.gitignore` car il contient une configuration locale.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **[INSTALL_NOTES.md](INSTALL_NOTES.md)** - Guide d'installation général
|
||||||
|
- **[INSTALL_LIBSOXR.md](INSTALL_LIBSOXR.md)** - Installation détaillée de libsoxr et ALSA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Création de la structure
|
## Création de la structure
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -98,3 +136,10 @@ jj rebase --continue
|
|||||||
|
|
||||||
pour résoudre les conflits
|
pour résoudre les conflits
|
||||||
|
|
||||||
|
## Installer rust sur mac
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install rustup-init
|
||||||
|
rustup-init
|
||||||
|
rustup default stable
|
||||||
|
```
|
||||||
27
SECURITY_CONFIG.md
Normal file
27
SECURITY_CONFIG.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Configuration Sécurisée
|
||||||
|
|
||||||
|
## Configuration de PMOMusic
|
||||||
|
|
||||||
|
Le fichier `.pmomusic.yml` contient des informations sensibles (mots de passe, identifiants).
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Copiez le fichier exemple :
|
||||||
|
```bash
|
||||||
|
cp .pmomusic.yml.example .pmomusic.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Éditez `.pmomusic.yml` et remplacez les valeurs par vos véritables identifiants :
|
||||||
|
- `accounts.qobuz.username` : votre email Qobuz
|
||||||
|
- `accounts.qobuz.password` : votre mot de passe Qobuz
|
||||||
|
|
||||||
|
3. **Important** : Ne commitez JAMAIS le fichier `.pmomusic.yml` dans git !
|
||||||
|
- Il est déjà dans `.gitignore`
|
||||||
|
- Utilisez des variables d'environnement pour la production
|
||||||
|
|
||||||
|
## Variables d'environnement (recommandé pour production)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export QOBUZ_USERNAME="votre-email@example.com"
|
||||||
|
export QOBUZ_PASSWORD="votre-mot-de-passe"
|
||||||
|
```
|
||||||
243
UPNP_ANALYSIS_REPORT.md
Normal file
243
UPNP_ANALYSIS_REPORT.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# Rapport d'Analyse UPnP - PMO Music vs Serveurs Fonctionnels
|
||||||
|
|
||||||
|
**Date:** 2025-11-26
|
||||||
|
**Problème:** Le serveur UPnP de PMO Music n'est pas reconnu par BubbleUPnP
|
||||||
|
|
||||||
|
## Résumé Exécutif
|
||||||
|
|
||||||
|
Le serveur PMO Music MediaServer est correctement découvert via SSDP et répond aux requêtes SOAP, mais présente plusieurs différences avec les serveurs qui fonctionnent (comme Upmpdcli). Les problèmes identifiés sont principalement liés aux en-têtes HTTP et aux métadonnées du device.
|
||||||
|
|
||||||
|
## Découverte Réseau
|
||||||
|
|
||||||
|
### Devices UPnP Détectés
|
||||||
|
|
||||||
|
| Device | IP | USN | Status |
|
||||||
|
|--------|------|-----|---------|
|
||||||
|
| PMO Music MediaServer | 192.168.0.138:8080 | uuid:8b8e9b19-9c65-4d59-b127-b34717658085 | ✅ Découvert |
|
||||||
|
| Upmpdcli (pizzicato) | 192.168.0.200:49152 | uuid:c110358f-d885-b44a-d6d3-dca6329ead0d | ✅ Découvert |
|
||||||
|
| Freebox | 192.168.0.254:52424 | uuid:e929a46e-d218-377d-2dde-32bd8080dfbf | ✅ Découvert |
|
||||||
|
| Jellyfin | 192.168.0.34:8096 | uuid:526dedec-fde2-4224-bac6-06f7b11711cf | ✅ Découvert |
|
||||||
|
|
||||||
|
**Conclusion SSDP:** ✅ PMO Music est correctement annoncé et découvert via SSDP
|
||||||
|
|
||||||
|
## Comparaison des Descripteurs XML
|
||||||
|
|
||||||
|
### PMO Music MediaServer
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||||
|
<specVersion>
|
||||||
|
<major>1</major>
|
||||||
|
<minor>0</minor> <!-- ⚠️ Version 1.0 -->
|
||||||
|
</specVersion>
|
||||||
|
<device>
|
||||||
|
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||||
|
<friendlyName>PMOMusic Media Server</friendlyName>
|
||||||
|
<manufacturer>PMOMusic</manufacturer>
|
||||||
|
<modelName>PMOMusic Media Server</modelName>
|
||||||
|
<UDN>uuid:8b8e9b19-9c65-4d59-b127-b34717658085</UDN> <!-- ✅ Format correct -->
|
||||||
|
<!-- ❌ Pas d'iconList -->
|
||||||
|
<serviceList>
|
||||||
|
<service>
|
||||||
|
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
|
||||||
|
<serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId>
|
||||||
|
<SCPDURL>/device/.../service/ContentDirectory/desc.xml</SCPDURL>
|
||||||
|
<controlURL>/device/.../service/ContentDirectory/control</controlURL>
|
||||||
|
<eventSubURL>/device/.../service/ContentDirectory/event</eventSubURL>
|
||||||
|
</service>
|
||||||
|
<service>
|
||||||
|
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
|
||||||
|
...
|
||||||
|
</service>
|
||||||
|
</serviceList>
|
||||||
|
</device>
|
||||||
|
</root>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upmpdcli (Fonctionnel)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||||
|
<specVersion>
|
||||||
|
<major>1</major>
|
||||||
|
<minor>1</minor> <!-- ✅ Version 1.1 -->
|
||||||
|
</specVersion>
|
||||||
|
<device>
|
||||||
|
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
|
||||||
|
<manufacturer>lesbonscomptes.com/upmpdcli</manufacturer>
|
||||||
|
<modelName>Upmpdcli Media Server</modelName>
|
||||||
|
<friendlyName>pizzicato-Music-mediaserver</friendlyName>
|
||||||
|
<iconList> <!-- ✅ Présence d'icônes -->
|
||||||
|
<icon>
|
||||||
|
<mimetype>image/png</mimetype>
|
||||||
|
<width>64</width>
|
||||||
|
<height>64</height>
|
||||||
|
<depth>32</depth>
|
||||||
|
<url>/uuid-.../icon.png</url>
|
||||||
|
</icon>
|
||||||
|
</iconList>
|
||||||
|
<UDN>uuid:c110358f-d885-b44a-d6d3-dca6329ead0d</UDN>
|
||||||
|
<serviceList>
|
||||||
|
<!-- Mêmes services -->
|
||||||
|
</serviceList>
|
||||||
|
</device>
|
||||||
|
</root>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Différences Clés dans le Descripteur
|
||||||
|
|
||||||
|
| Élément | PMO Music | Upmpdcli | Impact |
|
||||||
|
|---------|-----------|----------|---------|
|
||||||
|
| **specVersion minor** | 0 | 1 | ⚠️ Moyen - Certains clients peuvent filtrer par version |
|
||||||
|
| **Ordre des éléments** | deviceType, friendlyName, manufacturer, modelName, UDN | deviceType, manufacturer, modelName, friendlyName, iconList, UDN | ⚠️ Faible - Ordre différent mais valide XML |
|
||||||
|
| **iconList** | ❌ Absent | ✅ Présent | ⚠️ Moyen - Requis pour certains clients |
|
||||||
|
| **UDN prefix** | ✅ uuid: | ✅ uuid: | ✅ Correct |
|
||||||
|
|
||||||
|
## Comparaison des Réponses SOAP
|
||||||
|
|
||||||
|
### Test 1: ConnectionManager::GetProtocolInfo
|
||||||
|
|
||||||
|
#### PMO Music
|
||||||
|
```http
|
||||||
|
Status: 200 OK
|
||||||
|
Content-Type: (absent) ⚠️ PROBLÈME CRITIQUE
|
||||||
|
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||||
|
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||||
|
<s:Body>
|
||||||
|
<u:GetProtocolInfoResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
|
||||||
|
<Source></Source> ⚠️ Vide
|
||||||
|
<Sink></Sink> ⚠️ Vide
|
||||||
|
</u:GetProtocolInfoResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Upmpdcli
|
||||||
|
```http
|
||||||
|
Status: 200 OK
|
||||||
|
Content-Type: text/xml; charset="utf-8" ✅ Présent
|
||||||
|
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||||
|
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||||
|
<s:Body>
|
||||||
|
<u:GetProtocolInfoResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
|
||||||
|
<Source></Source>
|
||||||
|
<Sink>http-get:*:audio/flac:*,http-get:*:audio/mp3:*,...</Sink> ✅ Formats listés
|
||||||
|
</u:GetProtocolInfoResponse>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 2: ContentDirectory::Browse
|
||||||
|
|
||||||
|
Les deux serveurs répondent correctement, mais PMO Music manque toujours le header `Content-Type`.
|
||||||
|
|
||||||
|
## Problèmes Identifiés par Ordre de Criticité
|
||||||
|
|
||||||
|
### 🔴 CRITIQUE
|
||||||
|
|
||||||
|
1. **Absence du header Content-Type dans les réponses SOAP**
|
||||||
|
- **Impact:** Les clients UPnP stricts (comme BubbleUPnP) peuvent rejeter les réponses sans Content-Type
|
||||||
|
- **Spec UPnP:** La spécification UPnP Device Architecture 1.0 exige `Content-Type: text/xml; charset="utf-8"`
|
||||||
|
- **Localisation probable:** Dans le code de réponse SOAP du serveur UPnP
|
||||||
|
- **Fichiers à vérifier:**
|
||||||
|
- `pmoupnp/src/services/service_instance.rs` (handler SOAP)
|
||||||
|
- `pmoupnp/src/soap/builder.rs`
|
||||||
|
|
||||||
|
2. **ProtocolInfo vide pour Source et Sink**
|
||||||
|
- **Impact:** Les clients ne savent pas quels formats audio sont supportés
|
||||||
|
- **Spec UPnP:** ConnectionManager doit annoncer les formats supportés
|
||||||
|
- **Action:** Implémenter la liste des formats dans ConnectionManager
|
||||||
|
|
||||||
|
### 🟡 MOYEN
|
||||||
|
|
||||||
|
3. **specVersion 1.0 au lieu de 1.1**
|
||||||
|
- **Impact:** Certains clients modernes peuvent filtrer les devices UPnP 1.0
|
||||||
|
- **Solution:** Passer à specVersion 1.1
|
||||||
|
|
||||||
|
4. **Absence d'iconList**
|
||||||
|
- **Impact:** Pas d'icône visible dans les clients UPnP
|
||||||
|
- **Solution:** Ajouter au moins une icône PNG 64x64
|
||||||
|
|
||||||
|
### 🟢 FAIBLE
|
||||||
|
|
||||||
|
5. **Ordre des éléments XML différent**
|
||||||
|
- **Impact:** Minimal - XML valide dans tous les cas
|
||||||
|
- **Action:** Optionnel - standardiser l'ordre
|
||||||
|
|
||||||
|
## Recommandations d'Implémentation
|
||||||
|
|
||||||
|
### Priorité 1: Corriger le Content-Type
|
||||||
|
|
||||||
|
Localiser le code qui génère les réponses SOAP et ajouter le header:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Dans pmoupnp/src/services/service_instance.rs ou similaire
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[(header::CONTENT_TYPE, "text/xml; charset=\"utf-8\"")], // ← AJOUTER
|
||||||
|
xml
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Priorité 2: Implémenter GetProtocolInfo correctement
|
||||||
|
|
||||||
|
Dans ConnectionManager, retourner la liste des formats supportés:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Exemple de formats à supporter
|
||||||
|
let sink_protocols = vec![
|
||||||
|
"http-get:*:audio/flac:*",
|
||||||
|
"http-get:*:audio/mpeg:*",
|
||||||
|
"http-get:*:audio/mp4:*",
|
||||||
|
"http-get:*:audio/ogg:*",
|
||||||
|
// ...
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Priorité 3: Passer à UPnP 1.1
|
||||||
|
|
||||||
|
Changer la specVersion de 1.0 à 1.1 dans le device descriptor.
|
||||||
|
|
||||||
|
### Priorité 4: Ajouter une icône
|
||||||
|
|
||||||
|
Créer une icône PNG 64x64 et l'ajouter au descripteur:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<iconList>
|
||||||
|
<icon>
|
||||||
|
<mimetype>image/png</mimetype>
|
||||||
|
<width>64</width>
|
||||||
|
<height>64</height>
|
||||||
|
<depth>32</depth>
|
||||||
|
<url>/icon.png</url>
|
||||||
|
</icon>
|
||||||
|
</iconList>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers à Modifier
|
||||||
|
|
||||||
|
1. **pmoupnp/src/services/service_instance.rs** - Ajouter Content-Type aux réponses SOAP
|
||||||
|
2. **pmoupnp/src/devices/device_methods.rs** - Ajouter iconList au descripteur
|
||||||
|
3. **pmoupnp/src/devices/device.rs** - Passer specVersion à 1.1
|
||||||
|
4. **pmomediaserver/src/connectionmanager/actions/getprotocolinfo.rs** - Implémenter la liste des formats
|
||||||
|
|
||||||
|
## Tests de Validation
|
||||||
|
|
||||||
|
Après les corrections, vérifier:
|
||||||
|
|
||||||
|
1. ✅ `curl` sur le descripteur montre specVersion 1.1 et iconList
|
||||||
|
2. ✅ Requête SOAP GetProtocolInfo retourne `Content-Type: text/xml`
|
||||||
|
3. ✅ GetProtocolInfo retourne les formats supportés dans Sink
|
||||||
|
4. ✅ BubbleUPnP détecte et affiche le serveur PMO Music
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Le serveur PMO Music est **fonctionnellement correct** au niveau de SSDP et des services SOAP, mais présente des problèmes de conformité aux standards UPnP qui peuvent causer des rejets par certains clients stricts comme BubbleUPnP.
|
||||||
|
|
||||||
|
Les corrections sont simples et localisées. La priorité absolue est d'ajouter le header `Content-Type` aux réponses SOAP.
|
||||||
128
UPNP_FIX_SUMMARY.md
Normal file
128
UPNP_FIX_SUMMARY.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Résolution du Problème UPnP - PMO Music MediaServer
|
||||||
|
|
||||||
|
**Date:** 2025-11-26
|
||||||
|
**Problème:** Le serveur UPnP de PMO Music n'est pas reconnu par BubbleUPnP
|
||||||
|
|
||||||
|
## Diagnostic
|
||||||
|
|
||||||
|
Après une analyse approfondie avec des outils de découverte UPnP et de tests SOAP, le problème identifié était :
|
||||||
|
|
||||||
|
**🔴 PROBLÈME CRITIQUE : `SourceProtocolInfo` vide**
|
||||||
|
|
||||||
|
Le service `ConnectionManager` du MediaServer retournait des valeurs vides pour `SourceProtocolInfo`, ce qui empêchait les clients UPnP (comme BubbleUPnP) de savoir quels formats audio le serveur pouvait fournir.
|
||||||
|
|
||||||
|
### Réponse AVANT la correction :
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<u:GetProtocolInfoResponse>
|
||||||
|
<Source></Source> <!-- ❌ VIDE -->
|
||||||
|
<Sink></Sink>
|
||||||
|
</u:GetProtocolInfoResponse>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Solution Implémentée
|
||||||
|
|
||||||
|
### 1. Nouveau Module : `device_ext.rs`
|
||||||
|
|
||||||
|
Création d'un trait d'extension `MediaServerDeviceExt` pour `Arc<DeviceInstance>` qui initialise automatiquement les `ProtocolInfo`.
|
||||||
|
|
||||||
|
**Fichier:** [`pmomediaserver/src/device_ext.rs`](pmomediaserver/src/device_ext.rs)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait MediaServerDeviceExt {
|
||||||
|
/// Initialise les ProtocolInfo du ConnectionManager pour PMO Music.
|
||||||
|
///
|
||||||
|
/// PMO Music convertit tous les flux audio en FLAC (et OGG-FLAC).
|
||||||
|
fn init_protocol_info(&self);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Formats Supportés
|
||||||
|
|
||||||
|
PMO Music convertit tout au vol en FLAC, donc `SourceProtocolInfo` annonce :
|
||||||
|
|
||||||
|
- `http-get:*:audio/flac:*` - FLAC standard
|
||||||
|
- `http-get:*:audio/x-flac:*` - FLAC (format alternatif)
|
||||||
|
- `http-get:*:application/flac:*` - FLAC (MIME type alternatif)
|
||||||
|
- `http-get:*:application/x-flac:*` - FLAC (MIME type alternatif)
|
||||||
|
- `http-get:*:application/ogg:*` - OGG-FLAC
|
||||||
|
- `http-get:*:audio/ogg:*` - OGG-FLAC
|
||||||
|
- `http-get:*:audio/x-ogg:*` - OGG-FLAC (format alternatif)
|
||||||
|
|
||||||
|
### 3. Intégration dans `main.rs`
|
||||||
|
|
||||||
|
**Fichier:** [`PMOMusic/src/main.rs`](PMOMusic/src/main.rs)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pmomediaserver::MediaServerDeviceExt;
|
||||||
|
|
||||||
|
let server_instance = server
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.register_device(MEDIA_SERVER.clone())
|
||||||
|
.await
|
||||||
|
.expect("Failed to register MediaServer");
|
||||||
|
|
||||||
|
// ✅ Initialiser les ProtocolInfo du MediaServer
|
||||||
|
server_instance.init_protocol_info();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Export dans `lib.rs`
|
||||||
|
|
||||||
|
**Fichier:** [`pmomediaserver/src/lib.rs`](pmomediaserver/src/lib.rs)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod device_ext;
|
||||||
|
pub use device_ext::MediaServerDeviceExt;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Réponse APRÈS la correction
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<u:GetProtocolInfoResponse>
|
||||||
|
<Source>http-get:*:audio/flac:*,http-get:*:audio/x-flac:*,http-get:*:application/flac:*,http-get:*:application/x-flac:*,http-get:*:application/ogg:*,http-get:*:audio/ogg:*,http-get:*:audio/x-ogg:*</Source> <!-- ✅ INITIALISÉ -->
|
||||||
|
<Sink></Sink> <!-- ✅ Vide pour un MediaServer (normal) -->
|
||||||
|
</u:GetProtocolInfoResponse>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers Modifiés
|
||||||
|
|
||||||
|
1. ✅ **Nouveau:** `pmomediaserver/src/device_ext.rs` - Trait d'extension pour initialiser ProtocolInfo
|
||||||
|
2. ✅ **Modifié:** `pmomediaserver/src/lib.rs` - Export du trait
|
||||||
|
3. ✅ **Modifié:** `PMOMusic/src/main.rs` - Appel à `init_protocol_info()`
|
||||||
|
|
||||||
|
## Test de Validation
|
||||||
|
|
||||||
|
Après redémarrage du serveur PMO Music, vérifier avec :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/test_soap.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou directement :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "Content-Type: text/xml" \
|
||||||
|
-H "SOAPAction: \"urn:schemas-upnp-org:service:ConnectionManager:1#GetProtocolInfo\"" \
|
||||||
|
-d '<?xml version="1.0"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||||
|
<s:Body>
|
||||||
|
<u:GetProtocolInfo xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1"/>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>' \
|
||||||
|
http://localhost:8080/device/.../service/ConnectionManager/control
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prochaines Étapes
|
||||||
|
|
||||||
|
1. ✅ Redémarrer le serveur PMO Music
|
||||||
|
2. ⏳ Tester avec BubbleUPnP pour confirmer que le serveur est maintenant reconnu
|
||||||
|
3. ⏳ (Optionnel) Ajouter une icône pour le MediaServer (amélioration UX)
|
||||||
|
4. ⏳ (Optionnel) Passer à specVersion 1.1 (amélioration de compatibilité)
|
||||||
|
|
||||||
|
## Références
|
||||||
|
|
||||||
|
- Rapport d'analyse complet : [`UPNP_ANALYSIS_REPORT.md`](UPNP_ANALYSIS_REPORT.md)
|
||||||
|
- UPnP AV Architecture Specification :
|
||||||
|
https://openconnectivity.org/developer/specifications/upnp-resources/upnp/
|
||||||
31
analyze_flac.sh
Executable file
31
analyze_flac.sh
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Affiche toutes les stats d'un fichier FLAC
|
||||||
|
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
echo "Usage: $0 <fichier.flac>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
FILE="$1"
|
||||||
|
|
||||||
|
if [ ! -f "$FILE" ]; then
|
||||||
|
echo "Error: File not found: $FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Analyzing: $(basename "$FILE") ==="
|
||||||
|
echo ""
|
||||||
|
echo "--- SoX Statistics ---"
|
||||||
|
sox "$FILE" -n stat 2>&1
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- File Info ---"
|
||||||
|
file "$FILE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- FLAC Metadata ---"
|
||||||
|
metaflac --list "$FILE" 2>/dev/null || echo "metaflac not installed"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- Audio Integrity Check ---"
|
||||||
|
flac -t "$FILE" 2>&1 || echo "flac not installed"
|
||||||
38
blackboard-template.html
Normal file
38
blackboard-template.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>$title$</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
|
||||||
|
<script type="module">
|
||||||
|
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||||
|
mermaid.initialize({startOnLoad: true, theme: "default"});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.markdown-body {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 45px;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
pre.mermaid {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="markdown-body">
|
||||||
|
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
|
||||||
|
$body$
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
bubble_upmpdcli.pcap
Normal file
BIN
bubble_upmpdcli.pcap
Normal file
Binary file not shown.
90
check_audio_quality.sh
Executable file
90
check_audio_quality.sh
Executable file
@@ -0,0 +1,90 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script pour vérifier la qualité audio (détection de clics via le ratio delta)
|
||||||
|
|
||||||
|
CACHE_DIR="${1:-/tmp/pmomusic_test/audio_cache}"
|
||||||
|
THRESHOLD=10.0 # Ratio Maximum delta / Mean delta acceptable
|
||||||
|
|
||||||
|
if [ ! -d "$CACHE_DIR" ]; then
|
||||||
|
echo "Error: Cache directory not found: $CACHE_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Audio Quality Check ==="
|
||||||
|
echo "Scanning: $CACHE_DIR"
|
||||||
|
echo "Threshold: Maximum/Mean delta ratio < $THRESHOLD"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Vérifier que sox est installé
|
||||||
|
if ! command -v sox &> /dev/null; then
|
||||||
|
echo "Error: sox is not installed. Install it with: sudo apt install sox"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
count=0
|
||||||
|
suspicious=0
|
||||||
|
good=0
|
||||||
|
|
||||||
|
for file in "$CACHE_DIR"/*.orig.flac; do
|
||||||
|
if [ ! -f "$file" ]; then
|
||||||
|
echo "No FLAC files found in $CACHE_DIR"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
filename=$(basename "$file")
|
||||||
|
|
||||||
|
# Obtenir les stats delta
|
||||||
|
stats=$(sox "$file" -n stat 2>&1)
|
||||||
|
|
||||||
|
max_delta=$(echo "$stats" | grep "Maximum delta" | awk '{print $3}')
|
||||||
|
mean_delta=$(echo "$stats" | grep "Mean delta" | awk '{print $3}')
|
||||||
|
|
||||||
|
if [ -z "$max_delta" ] || [ -z "$mean_delta" ]; then
|
||||||
|
echo "❌ $filename - Cannot parse stats"
|
||||||
|
suspicious=$((suspicious + 1))
|
||||||
|
count=$((count + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Éviter division par zéro
|
||||||
|
if (( $(echo "$mean_delta == 0" | bc -l) )); then
|
||||||
|
echo "❌ $filename - Invalid mean delta (0)"
|
||||||
|
suspicious=$((suspicious + 1))
|
||||||
|
count=$((count + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Calculer le ratio
|
||||||
|
ratio=$(echo "scale=2; $max_delta / $mean_delta" | bc -l)
|
||||||
|
|
||||||
|
# Comparer au seuil
|
||||||
|
is_bad=$(echo "$ratio > $THRESHOLD" | bc -l)
|
||||||
|
|
||||||
|
if [ "$is_bad" = "1" ]; then
|
||||||
|
echo "⚠️ CLICKS DETECTED: $filename"
|
||||||
|
echo " Max delta: $max_delta, Mean delta: $mean_delta, Ratio: ${ratio}x (threshold: ${THRESHOLD}x)"
|
||||||
|
suspicious=$((suspicious + 1))
|
||||||
|
else
|
||||||
|
echo "✓ OK: $filename (ratio: ${ratio}x)"
|
||||||
|
good=$((good + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
count=$((count + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Summary ==="
|
||||||
|
echo "Total files scanned: $count"
|
||||||
|
echo "✓ Good quality: $good"
|
||||||
|
echo "⚠️ Clicks detected: $suspicious"
|
||||||
|
|
||||||
|
if [ $suspicious -gt 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ Warning: $suspicious file(s) have clicks."
|
||||||
|
echo "These files were likely encoded with the old buffer size (8)."
|
||||||
|
echo "Delete the cache and re-download to fix: rm -rf $CACHE_DIR/*.flac"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✓ All files are good quality!"
|
||||||
|
exit 0
|
||||||
76
detect_clicks.sh
Executable file
76
detect_clicks.sh
Executable file
@@ -0,0 +1,76 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script pour détecter les clics dans les fichiers FLAC du cache
|
||||||
|
|
||||||
|
CACHE_DIR="${1:-/tmp/pmomusic_test/audio_cache}"
|
||||||
|
|
||||||
|
if [ ! -d "$CACHE_DIR" ]; then
|
||||||
|
echo "Error: Cache directory not found: $CACHE_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== FLAC Click Detection Tool ==="
|
||||||
|
echo "Scanning: $CACHE_DIR"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Vérifier que sox est installé
|
||||||
|
if ! command -v sox &> /dev/null; then
|
||||||
|
echo "Error: sox is not installed. Install it with: sudo apt install sox"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
count=0
|
||||||
|
suspicious=0
|
||||||
|
|
||||||
|
for file in "$CACHE_DIR"/*.orig.flac; do
|
||||||
|
if [ ! -f "$file" ]; then
|
||||||
|
echo "No FLAC files found in $CACHE_DIR"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
filename=$(basename "$file")
|
||||||
|
echo "Analyzing: $filename"
|
||||||
|
|
||||||
|
# Obtenir toutes les stats
|
||||||
|
stats=$(sox "$file" -n stat 2>&1)
|
||||||
|
|
||||||
|
# Extraire les valeurs importantes
|
||||||
|
pk_lev=$(echo "$stats" | grep "Pk lev dB" | awk '{print $4}')
|
||||||
|
rms_lev=$(echo "$stats" | grep "RMS lev dB" | awk '{print $4}')
|
||||||
|
crest=$(echo "$stats" | grep "Crest factor" | awk '{print $3}')
|
||||||
|
|
||||||
|
echo " Peak level: ${pk_lev:-N/A} dB"
|
||||||
|
echo " RMS level: ${rms_lev:-N/A} dB"
|
||||||
|
echo " Crest factor: ${crest:-N/A} dB"
|
||||||
|
|
||||||
|
# Analyser la variance d'amplitude (détection de clics)
|
||||||
|
# On compte le nombre de pics au-dessus d'un seuil
|
||||||
|
peaks=$(sox "$file" -n stats 2>&1 | grep "Maximum amplitude" | awk '{print $3}')
|
||||||
|
|
||||||
|
if [ -n "$peaks" ]; then
|
||||||
|
# Si le peak est proche de 1.0 (clipping), c'est suspect
|
||||||
|
is_clipping=$(echo "$peaks > 0.95" | bc -l 2>/dev/null)
|
||||||
|
if [ "$is_clipping" = "1" ]; then
|
||||||
|
echo " ⚠️ WARNING: Possible clipping detected!"
|
||||||
|
suspicious=$((suspicious + 1))
|
||||||
|
else
|
||||||
|
echo " ✓ OK"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " ✓ OK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
count=$((count + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "=== Summary ==="
|
||||||
|
echo "Files scanned: $count"
|
||||||
|
echo "Suspicious files: $suspicious"
|
||||||
|
|
||||||
|
if [ $suspicious -gt 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ Some files may have issues. Listen to them carefully."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
135
docker-build.sh
Executable file
135
docker-build.sh
Executable file
@@ -0,0 +1,135 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script de build Docker pour PMOMusic
|
||||||
|
# Usage: ./docker-build.sh [OPTIONS]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Couleurs pour l'affichage
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Configuration par défaut
|
||||||
|
IMAGE_NAME="pmomusic"
|
||||||
|
TAG="latest"
|
||||||
|
NO_CACHE=false
|
||||||
|
PUSH=true
|
||||||
|
REGISTRY="niepce.petite-maison-orange.fr/public"
|
||||||
|
|
||||||
|
# Fonction d'aide
|
||||||
|
show_help() {
|
||||||
|
echo "Usage: $0 [OPTIONS]"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " -t, --tag TAG Tag de l'image (défaut: latest)"
|
||||||
|
echo " -r, --registry URL Registry Docker (ex: ghcr.io/user)"
|
||||||
|
echo " -n, --no-cache Build sans cache"
|
||||||
|
echo " -p, --push Push l'image vers le registry"
|
||||||
|
echo " -h, --help Affiche cette aide"
|
||||||
|
echo ""
|
||||||
|
echo "Exemples:"
|
||||||
|
echo " $0 # Build local avec tag 'latest'"
|
||||||
|
echo " $0 -t v1.0.0 # Build avec tag 'v1.0.0'"
|
||||||
|
echo " $0 -t v1.0.0 -r ghcr.io/user -p # Build et push vers GHCR"
|
||||||
|
echo " $0 -n # Build sans cache"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parsing des arguments
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
-t|--tag)
|
||||||
|
TAG="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-r|--registry)
|
||||||
|
REGISTRY="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-n|--no-cache)
|
||||||
|
NO_CACHE=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-p|--push)
|
||||||
|
PUSH=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Erreur: Option inconnue '$1'${NC}"
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Construire le nom complet de l'image
|
||||||
|
if [ -n "$REGISTRY" ]; then
|
||||||
|
FULL_IMAGE_NAME="$REGISTRY/$IMAGE_NAME:$TAG"
|
||||||
|
else
|
||||||
|
FULL_IMAGE_NAME="$IMAGE_NAME:$TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Afficher la configuration
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo -e "${GREEN}Build Docker PMOMusic${NC}"
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Image: $FULL_IMAGE_NAME"
|
||||||
|
echo "No cache: $NO_CACHE"
|
||||||
|
echo "Push: $PUSH"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Construire la commande Docker
|
||||||
|
DOCKER_CMD="docker build"
|
||||||
|
|
||||||
|
if [ "$NO_CACHE" = true ]; then
|
||||||
|
DOCKER_CMD="$DOCKER_CMD --no-cache"
|
||||||
|
fi
|
||||||
|
|
||||||
|
DOCKER_CMD="$DOCKER_CMD --build-arg BUILD_DATE=\"$(date -u +'%Y-%m-%dT%H:%M:%SZ')\" -t $FULL_IMAGE_NAME ."
|
||||||
|
|
||||||
|
# Exécuter le build
|
||||||
|
echo -e "${YELLOW}→ Démarrage du build...${NC}"
|
||||||
|
echo "Commande: $DOCKER_CMD"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if eval "$DOCKER_CMD"; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✓ Build réussi !${NC}"
|
||||||
|
|
||||||
|
# Afficher la taille de l'image
|
||||||
|
IMAGE_SIZE=$(docker images "$FULL_IMAGE_NAME" --format "{{.Size}}")
|
||||||
|
echo "Taille de l'image: $IMAGE_SIZE"
|
||||||
|
|
||||||
|
# Push si demandé
|
||||||
|
if [ "$PUSH" = true ]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}→ Push de l'image vers le registry...${NC}"
|
||||||
|
|
||||||
|
if docker push "$FULL_IMAGE_NAME"; then
|
||||||
|
echo -e "${GREEN}✓ Image pushée avec succès !${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Erreur lors du push${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo -e "${GREEN}Build terminé avec succès !${NC}"
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Pour lancer le conteneur:"
|
||||||
|
echo " docker run -it --rm --network host $FULL_IMAGE_NAME"
|
||||||
|
echo ""
|
||||||
|
echo "Ou avec docker-compose:"
|
||||||
|
echo " docker-compose up -d"
|
||||||
|
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo -e "${RED}✗ Erreur lors du build${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
pmomusic:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: pmomusic:latest
|
||||||
|
container_name: pmomusic
|
||||||
|
|
||||||
|
# Port mapping (adjust to your needs)
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
# Volume for persistent configuration
|
||||||
|
volumes:
|
||||||
|
- ./config:/home/pmomusic/.pmomusic
|
||||||
|
- ./cache:/home/pmomusic/cache
|
||||||
|
|
||||||
|
# Environment variables (adjust as needed)
|
||||||
|
environment:
|
||||||
|
- RUST_LOG=info
|
||||||
|
# Add other environment variables here
|
||||||
|
|
||||||
|
# Network mode for UPnP/DLNA discovery (host mode for multicast)
|
||||||
|
network_mode: host
|
||||||
|
|
||||||
|
# Restart policy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Resource limits (optional)
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2.0'
|
||||||
|
memory: 1G
|
||||||
|
reservations:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 256M
|
||||||
7
fix-mermaid.sh
Executable file
7
fix-mermaid.sh
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Fix Mermaid blocks in HTML generated by Pandoc
|
||||||
|
# Pandoc wraps mermaid code in <pre class="mermaid"><code>...</code></pre>
|
||||||
|
# But Mermaid.js needs <pre class="mermaid">...</pre> without the <code> tags
|
||||||
|
|
||||||
|
sed -i '' 's|<pre class="mermaid"><code>|<pre class="mermaid">|g' "$1"
|
||||||
|
sed -i '' 's|</code></pre>|</pre>|g' "$1"
|
||||||
5
headers.txt
Normal file
5
headers.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
HTTP/1.1 200 OK
|
||||||
|
content-type: text/xml; charset="utf-8"
|
||||||
|
content-length: 1593
|
||||||
|
date: Mon, 20 Oct 2025 17:44:48 GMT
|
||||||
|
|
||||||
431
media_
Normal file
431
media_
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoserver/src/config_ext.rs:35:5
|
||||||
|
|
|
||||||
|
35 | async fn init_config_api(&mut self) -> Result<()>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
= note: `#[warn(async_fn_in_trait)]` on by default
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
35 - async fn init_config_api(&mut self) -> Result<()>;
|
||||||
|
35 + fn init_config_api(&mut self) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: `pmoserver` (lib) generated 1 warning
|
||||||
|
warning: variable does not need to be mutable
|
||||||
|
--> pmocache/src/cache.rs:604:9
|
||||||
|
|
|
||||||
|
604 | mut reader: R,
|
||||||
|
| ----^^^^^^
|
||||||
|
| |
|
||||||
|
| help: remove this `mut`
|
||||||
|
|
|
||||||
|
= note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmocache/src/cache_trait.rs:97:5
|
||||||
|
|
|
||||||
|
97 | async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
= note: `#[warn(async_fn_in_trait)]` on by default
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
97 - async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String>;
|
||||||
|
97 + fn add_from_url(&self, url: &str, collection: Option<&str>) -> impl std::future::Future<Output = Result<String>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmocache/src/cache_trait.rs:111:5
|
||||||
|
|
|
||||||
|
111 | async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
111 - async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String>;
|
||||||
|
111 + fn add_from_file(&self, path: &str, collection: Option<&str>) -> impl std::future::Future<Output = Result<String>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmocache/src/cache_trait.rs:118:5
|
||||||
|
|
|
||||||
|
118 | async fn get(&self, pk: &str) -> Result<PathBuf>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
118 - async fn get(&self, pk: &str) -> Result<PathBuf>;
|
||||||
|
118 + fn get(&self, pk: &str) -> impl std::future::Future<Output = Result<PathBuf>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmocache/src/cache_trait.rs:125:5
|
||||||
|
|
|
||||||
|
125 | async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
125 - async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>>;
|
||||||
|
125 + fn get_collection(&self, collection: &str) -> impl std::future::Future<Output = Result<Vec<PathBuf>>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmocache/src/cache_trait.rs:128:5
|
||||||
|
|
|
||||||
|
128 | async fn purge(&self) -> Result<()>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
128 - async fn purge(&self) -> Result<()>;
|
||||||
|
128 + fn purge(&self) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmocache/src/cache_trait.rs:131:5
|
||||||
|
|
|
||||||
|
131 | async fn consolidate(&self) -> Result<()>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
131 - async fn consolidate(&self) -> Result<()>;
|
||||||
|
131 + fn consolidate(&self) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmocache/src/cache_trait.rs:147:5
|
||||||
|
|
|
||||||
|
147 | async fn is_valid_pk(&self, pk: &str) -> bool {
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
147 ~ fn is_valid_pk(&self, pk: &str) -> impl std::future::Future<Output = bool> + Send {async {
|
||||||
|
148 | if self.get_database().get(pk, false).is_err() {
|
||||||
|
...
|
||||||
|
218 | false
|
||||||
|
219 ~ } }
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmocache/src/pmoserver_ext.rs:391:5
|
||||||
|
|
|
||||||
|
391 | async fn init_generic_cache<C: CacheConfig + 'static>(
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
391 ~ fn init_generic_cache<C: CacheConfig + 'static>(
|
||||||
|
392 | &mut self,
|
||||||
|
...
|
||||||
|
395 | content_type: &'static str,
|
||||||
|
396 ~ ) -> impl std::future::Future<Output = anyhow::Result<Arc<Cache<C>>>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: `pmocache` (lib) generated 9 warnings (run `cargo fix --lib -p pmocache` to apply 1 suggestion)
|
||||||
|
warning: unused import: `serde_json::Value`
|
||||||
|
--> pmoaudiocache/src/cache.rs:11:5
|
||||||
|
|
|
||||||
|
11 | use serde_json::Value;
|
||||||
|
| ^^^^^^^^^^^^^^^^^
|
||||||
|
|
|
||||||
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
|
||||||
|
|
||||||
|
warning: unused import: `pmometadata::TrackMetadata`
|
||||||
|
--> pmoaudiocache/src/api.rs:11:5
|
||||||
|
|
|
||||||
|
11 | use pmometadata::TrackMetadata;
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoaudiocache/src/lib.rs:188:5
|
||||||
|
|
|
||||||
|
188 | async fn init_audio_cache(
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
= note: `#[warn(async_fn_in_trait)]` on by default
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
188 ~ fn init_audio_cache(
|
||||||
|
189 | &mut self,
|
||||||
|
190 | cache_dir: &str,
|
||||||
|
191 | limit: usize,
|
||||||
|
192 ~ ) -> impl std::future::Future<Output = anyhow::Result<std::sync::Arc<Cache>>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoaudiocache/src/lib.rs:197:5
|
||||||
|
|
|
||||||
|
197 | async fn init_audio_cache_configured(&mut self) -> anyhow::Result<std::sync::Arc<Cache>>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
197 - async fn init_audio_cache_configured(&mut self) -> anyhow::Result<std::sync::Arc<Cache>>;
|
||||||
|
197 + fn init_audio_cache_configured(&mut self) -> impl std::future::Future<Output = anyhow::Result<std::sync::Arc<Cache>>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoaudiocache/src/metadata_ext.rs:36:5
|
||||||
|
|
|
||||||
|
36 | async fn get_title(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
36 - async fn get_title(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||||
|
36 + fn get_title(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoaudiocache/src/metadata_ext.rs:37:5
|
||||||
|
|
|
||||||
|
37 | async fn get_artist(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
37 - async fn get_artist(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||||
|
37 + fn get_artist(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoaudiocache/src/metadata_ext.rs:38:5
|
||||||
|
|
|
||||||
|
38 | async fn get_album(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
38 - async fn get_album(&self, pk: &str) -> anyhow::Result<Option<String>>;
|
||||||
|
38 + fn get_album(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoaudiocache/src/metadata_ext.rs:39:5
|
||||||
|
|
|
||||||
|
39 | async fn get_duration_secs(&self, pk: &str) -> anyhow::Result<Option<i64>>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
39 - async fn get_duration_secs(&self, pk: &str) -> anyhow::Result<Option<i64>>;
|
||||||
|
39 + fn get_duration_secs(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<i64>>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: `pmoaudiocache` (lib) generated 8 warnings (run `cargo fix --lib -p pmoaudiocache` to apply 1 suggestion)
|
||||||
|
warning: unused import: `tokio_stream::StreamExt`
|
||||||
|
--> pmoplaylist/src/sse.rs:16:5
|
||||||
|
|
|
||||||
|
16 | use tokio_stream::StreamExt;
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
|
||||||
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
|
||||||
|
|
||||||
|
warning: methods `list_playlist_ids` and `remove_by_cache_pk` are never used
|
||||||
|
--> pmoplaylist/src/persistence/mod.rs:219:18
|
||||||
|
|
|
||||||
|
17 | impl PersistenceManager {
|
||||||
|
| ----------------------- methods in this implementation
|
||||||
|
...
|
||||||
|
219 | pub async fn list_playlist_ids(&self) -> Result<Vec<String>> {
|
||||||
|
| ^^^^^^^^^^^^^^^^^
|
||||||
|
...
|
||||||
|
240 | pub async fn remove_by_cache_pk(&self, cache_pk: &str) -> Result<()> {
|
||||||
|
| ^^^^^^^^^^^^^^^^^^
|
||||||
|
|
|
||||||
|
= note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default
|
||||||
|
|
||||||
|
warning: function `PlaylistManager` should have a snake case name
|
||||||
|
--> pmoplaylist/src/manager.rs:620:8
|
||||||
|
|
|
||||||
|
620 | pub fn PlaylistManager() -> &'static PlaylistManager {
|
||||||
|
| ^^^^^^^^^^^^^^^ help: convert the identifier to snake case: `playlist_manager`
|
||||||
|
|
|
||||||
|
= note: `#[warn(non_snake_case)]` (part of `#[warn(nonstandard_style)]`) on by default
|
||||||
|
|
||||||
|
warning: `pmoplaylist` (lib) generated 3 warnings
|
||||||
|
warning: unused variable: `base_url`
|
||||||
|
--> pmoupnp/src/upnp_server.rs:306:13
|
||||||
|
|
|
||||||
|
306 | let base_url = self.info().base_url.clone();
|
||||||
|
| ^^^^^^^^ help: if this is intentional, prefix it with an underscore: `_base_url`
|
||||||
|
|
|
||||||
|
= note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoupnp/src/upnp_api.rs:229:5
|
||||||
|
|
|
||||||
|
229 | async fn register_upnp_api(&mut self);
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
= note: `#[warn(async_fn_in_trait)]` on by default
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
229 - async fn register_upnp_api(&mut self);
|
||||||
|
229 + fn register_upnp_api(&mut self) -> impl std::future::Future<Output = ()> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoupnp/src/upnp_server.rs:96:5
|
||||||
|
|
|
||||||
|
96 | async fn register_device(
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
96 ~ fn register_device(
|
||||||
|
97 | &mut self,
|
||||||
|
98 | device: Arc<Device>,
|
||||||
|
99 ~ ) -> impl std::future::Future<Output = Result<Arc<DeviceInstance>, DeviceError>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoupnp/src/upnp_server.rs:125:5
|
||||||
|
|
|
||||||
|
125 | async fn init_cover_cache(
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
125 ~ fn init_cover_cache(
|
||||||
|
126 | &mut self,
|
||||||
|
127 | cache_dir: &str,
|
||||||
|
128 | limit: usize,
|
||||||
|
129 ~ ) -> impl std::future::Future<Output = Result<Arc<CoverCache>, anyhow::Error>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoupnp/src/upnp_server.rs:144:5
|
||||||
|
|
|
||||||
|
144 | async fn init_audio_cache(
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
144 ~ fn init_audio_cache(
|
||||||
|
145 | &mut self,
|
||||||
|
146 | cache_dir: &str,
|
||||||
|
147 | limit: usize,
|
||||||
|
148 ~ ) -> impl std::future::Future<Output = Result<Arc<AudioCache>, anyhow::Error>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoupnp/src/upnp_server.rs:158:5
|
||||||
|
|
|
||||||
|
158 | async fn init_caches(&mut self) -> Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
158 - async fn init_caches(&mut self) -> Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>;
|
||||||
|
158 + fn init_caches(&mut self) -> impl std::future::Future<Output = Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
|
||||||
|
--> pmoupnp/src/upnp_server.rs:225:5
|
||||||
|
|
|
||||||
|
225 | async fn create_upnp_server() -> Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>;
|
||||||
|
| ^^^^^
|
||||||
|
|
|
||||||
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
|
||||||
|
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
||||||
|
|
|
||||||
|
225 - async fn create_upnp_server() -> Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>;
|
||||||
|
225 + fn create_upnp_server() -> impl std::future::Future<Output = Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>> + Send;
|
||||||
|
|
|
||||||
|
|
||||||
|
warning: `pmoupnp` (lib) generated 7 warnings
|
||||||
|
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.16s
|
||||||
|
Running `target/debug/examples/media_server_events_demo`
|
||||||
|
ControlPoint started; waiting 5s for discovery...
|
||||||
|
Discovered media servers:
|
||||||
|
- BubbleUPnP Media Server (SM-A536B) | model=BubbleUPnP Media Server | udn=uuid:d38a2dc7-13c1-4a39-ab36-513490cf6766 | location=http://192.168.0.98:58645/dev/d38a2dc7-13c1-4a39-ab36-513490cf6766/desc.xml
|
||||||
|
- fenice | model=Jellyfin Server | udn=uuid:526dedec-fde2-4224-bac6-06f7b11711cf | location=http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml
|
||||||
|
- PMOMusic Media Server | model=PMOMusic Media Server | udn=uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 | location=http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml
|
||||||
|
- Freebox Server | model=Freebox Media Server | udn=uuid:e929a46e-d218-377d-2dde-32bd8080dfbf | location=http://192.168.0.254:52424/device.xml
|
||||||
|
Listening for ContentDirectory events for 90 seconds...
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=27)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:history
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=28)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=30)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=31)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=32)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=33)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=37)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=40)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=41)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=42)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=44)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=45)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=46)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=49)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=50)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=51)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=52)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=53)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=54)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=55)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=56)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=57)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=59)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=63)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=64)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=65)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=66)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=67)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=68)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=69)
|
||||||
|
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
|
||||||
|
Monitoring finished.
|
||||||
80
old_code/pmoaudio/examples/multiroom_demo.rs
Normal file
80
old_code/pmoaudio/examples/multiroom_demo.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
//! Exemple de configuration multiroom avec BufferNode
|
||||||
|
//!
|
||||||
|
//! Démontre l'utilisation du buffer circulaire pour synchroniser
|
||||||
|
//! plusieurs sorties avec des délais différents
|
||||||
|
|
||||||
|
use pmoaudio::{BufferNode, SinkNode, SourceNode};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
println!("=== Multiroom Demo ===\n");
|
||||||
|
|
||||||
|
// Buffer avec capacité pour gérer les délais
|
||||||
|
let (buffer, buffer_tx) = BufferNode::new(50, 10);
|
||||||
|
|
||||||
|
// Créer 3 sorties avec délais différents
|
||||||
|
let (sink1, sink1_tx) = SinkNode::new("Room 1 (no delay)".to_string(), 10);
|
||||||
|
let (sink2, sink2_tx) = SinkNode::new("Room 2 (5 chunks delay)".to_string(), 10);
|
||||||
|
let (sink3, sink3_tx) = SinkNode::new("Room 3 (10 chunks delay)".to_string(), 10);
|
||||||
|
|
||||||
|
buffer.add_subscriber_with_offset(sink1_tx, 0).await;
|
||||||
|
buffer.add_subscriber_with_offset(sink2_tx, 5).await;
|
||||||
|
buffer.add_subscriber_with_offset(sink3_tx, 10).await;
|
||||||
|
|
||||||
|
// Spawn buffer et sinks
|
||||||
|
tokio::spawn(async move {
|
||||||
|
buffer.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let sink1_handle = tokio::spawn(async move {
|
||||||
|
let stats = sink1.run_with_stats().await.unwrap();
|
||||||
|
stats.display();
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
|
||||||
|
let sink2_handle = tokio::spawn(async move {
|
||||||
|
let stats = sink2.run_with_stats().await.unwrap();
|
||||||
|
stats.display();
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
|
||||||
|
let sink3_handle = tokio::spawn(async move {
|
||||||
|
let stats = sink3.run_with_stats().await.unwrap();
|
||||||
|
stats.display();
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
|
||||||
|
// Générer de l'audio dans une tâche séparée
|
||||||
|
println!("Generating audio for multiroom playback...\n");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
source.add_subscriber(buffer_tx);
|
||||||
|
source
|
||||||
|
.generate_chunks(30, 4800, 48000, 440.0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
println!("Waiting for all rooms to finish...\n");
|
||||||
|
|
||||||
|
// Attendre toutes les sorties
|
||||||
|
let stats1 = sink1_handle.await.unwrap();
|
||||||
|
let stats2 = sink2_handle.await.unwrap();
|
||||||
|
let stats3 = sink3_handle.await.unwrap();
|
||||||
|
|
||||||
|
println!("\n=== Multiroom Summary ===");
|
||||||
|
println!(
|
||||||
|
"{}: {} chunks received",
|
||||||
|
stats1.name, stats1.chunks_received
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{}: {} chunks received",
|
||||||
|
stats2.name, stats2.chunks_received
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"{}: {} chunks received",
|
||||||
|
stats3.name, stats3.chunks_received
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("\nNote: Delayed rooms receive fewer chunks due to the offset");
|
||||||
|
}
|
||||||
168
old_code/pmoaudio/examples/multiroom_volume_demo.rs
Normal file
168
old_code/pmoaudio/examples/multiroom_volume_demo.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
//! Exemple complet de pipeline multiroom avec contrôle de volume
|
||||||
|
//!
|
||||||
|
//! Ce programme démontre :
|
||||||
|
//! - Une source audio unique
|
||||||
|
//! - Deux branches de sortie : Chromecast et DiskSink
|
||||||
|
//! - Un volume master avec deux VolumeNodes secondaires synchronisés
|
||||||
|
//! - Système d'événements pour la communication entre nodes
|
||||||
|
|
||||||
|
use pmoaudio::{
|
||||||
|
ChromecastConfig, ChromecastSink, DiskSink, DiskSinkConfig, SourceNode, VolumeNode,
|
||||||
|
};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
println!("=== PMOAudio Multiroom Volume Demo ===\n");
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
let sample_rate = 48000u32;
|
||||||
|
let chunk_size = 4800usize; // 100ms à 48kHz
|
||||||
|
let num_chunks = 50; // 5 secondes de lecture
|
||||||
|
let frequency = 440.0; // La 440 Hz
|
||||||
|
|
||||||
|
// ===== 1. Créer la source audio =====
|
||||||
|
println!("1. Creating audio source...");
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
|
||||||
|
// ===== 2. Créer le volume master =====
|
||||||
|
println!("2. Creating master volume node...");
|
||||||
|
let (mut master_volume, master_tx) = VolumeNode::new("master".to_string(), 1.0, 50);
|
||||||
|
let master_handle = master_volume.get_handle();
|
||||||
|
|
||||||
|
// Channel pour les événements du volume master
|
||||||
|
let (master_event_tx, master_event_rx_chromecast) = mpsc::channel(10);
|
||||||
|
let (_, master_event_rx_disk) = mpsc::channel(10);
|
||||||
|
|
||||||
|
master_volume.subscribe_volume_events(master_event_tx);
|
||||||
|
|
||||||
|
source.add_subscriber(master_tx);
|
||||||
|
|
||||||
|
// ===== 3. Créer les branches de sortie =====
|
||||||
|
|
||||||
|
// Branche 1: Chromecast avec volume secondaire
|
||||||
|
println!("3a. Creating Chromecast output branch...");
|
||||||
|
let (mut chromecast_volume, chromecast_volume_tx) =
|
||||||
|
VolumeNode::new("chromecast_volume".to_string(), 0.8, 50);
|
||||||
|
|
||||||
|
chromecast_volume.set_master_volume_source(master_event_rx_chromecast);
|
||||||
|
|
||||||
|
let chromecast_config = ChromecastConfig {
|
||||||
|
device_address: "192.168.1.100".to_string(),
|
||||||
|
device_name: "Living Room".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (chromecast_sink, chromecast_sink_tx) =
|
||||||
|
ChromecastSink::new("chromecast1".to_string(), chromecast_config, 50);
|
||||||
|
|
||||||
|
chromecast_volume.add_subscriber(chromecast_sink_tx);
|
||||||
|
master_volume.add_subscriber(chromecast_volume_tx);
|
||||||
|
|
||||||
|
// Branche 2: DiskSink avec volume secondaire
|
||||||
|
println!("3b. Creating DiskSink output branch...");
|
||||||
|
let (mut disk_volume, disk_volume_tx) = VolumeNode::new("disk_volume".to_string(), 0.9, 50);
|
||||||
|
|
||||||
|
disk_volume.set_master_volume_source(master_event_rx_disk);
|
||||||
|
|
||||||
|
let disk_config = DiskSinkConfig {
|
||||||
|
output_dir: std::env::temp_dir().join("pmoaudio_demo"),
|
||||||
|
filename: Some("multiroom_output.wav".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (disk_sink, disk_sink_tx) = DiskSink::new("disk1".to_string(), disk_config, 50);
|
||||||
|
|
||||||
|
disk_volume.add_subscriber(disk_sink_tx);
|
||||||
|
master_volume.add_subscriber(disk_volume_tx);
|
||||||
|
|
||||||
|
// ===== 4. Lancer tous les nodes =====
|
||||||
|
println!("4. Starting pipeline nodes...\n");
|
||||||
|
|
||||||
|
// Spawn master volume
|
||||||
|
let master_volume_handle = tokio::spawn(async move {
|
||||||
|
master_volume.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn chromecast branch
|
||||||
|
let chromecast_volume_handle = tokio::spawn(async move {
|
||||||
|
chromecast_volume.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let chromecast_sink_handle = tokio::spawn(async move {
|
||||||
|
let stats = chromecast_sink.run().await.unwrap();
|
||||||
|
stats.display();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn disk branch
|
||||||
|
let disk_volume_handle = tokio::spawn(async move {
|
||||||
|
disk_volume.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let disk_sink_handle = tokio::spawn(async move {
|
||||||
|
let stats = disk_sink.run().await.unwrap();
|
||||||
|
stats.display();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 5. Contrôler le volume pendant la lecture =====
|
||||||
|
let master_handle_clone = master_handle.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Attendre un peu, puis diminuer le volume
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||||
|
println!("\n>>> Decreasing master volume to 0.7");
|
||||||
|
master_handle_clone.set_volume(0.7).await;
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||||
|
println!(">>> Decreasing master volume to 0.4");
|
||||||
|
master_handle_clone.set_volume(0.4).await;
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||||
|
println!(">>> Increasing master volume back to 1.0");
|
||||||
|
master_handle_clone.set_volume(1.0).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 6. Générer et envoyer les chunks audio =====
|
||||||
|
println!("5. Generating and streaming audio...");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
source
|
||||||
|
.generate_chunks(num_chunks, chunk_size, sample_rate, frequency)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
println!("\n>>> Audio generation complete!");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 7. Attendre la fin de tous les nodes =====
|
||||||
|
println!("6. Waiting for all nodes to complete...\n");
|
||||||
|
|
||||||
|
// Attendre que les sinks terminent
|
||||||
|
chromecast_sink_handle.await?;
|
||||||
|
disk_sink_handle.await?;
|
||||||
|
|
||||||
|
// Nettoyer
|
||||||
|
master_volume_handle.abort();
|
||||||
|
chromecast_volume_handle.abort();
|
||||||
|
disk_volume_handle.abort();
|
||||||
|
|
||||||
|
println!("\n=== Demo completed successfully! ===");
|
||||||
|
println!("\nSummary:");
|
||||||
|
println!(
|
||||||
|
"- Generated {} chunks of {} samples each",
|
||||||
|
num_chunks, chunk_size
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"- Total duration: {:.2} seconds",
|
||||||
|
(num_chunks as usize * chunk_size) as f32 / sample_rate as f32
|
||||||
|
);
|
||||||
|
println!("- Output to Chromecast: Living Room (192.168.1.100)");
|
||||||
|
println!(
|
||||||
|
"- Output to file: {}",
|
||||||
|
std::env::temp_dir()
|
||||||
|
.join("pmoaudio_demo")
|
||||||
|
.join("multiroom_output.wav")
|
||||||
|
.display()
|
||||||
|
);
|
||||||
|
println!("- Master volume control demonstrated with live changes");
|
||||||
|
println!("\nAll streams received synchronized volume updates!");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
126
old_code/pmoaudio/examples/pipeline_demo.rs
Normal file
126
old_code/pmoaudio/examples/pipeline_demo.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
//! Exemple de pipeline audio stéréo complet avec tous les nodes
|
||||||
|
//!
|
||||||
|
//! Pipeline: SourceNode → DecoderNode → DspNode → BufferNode → TimerNode → SinkNode(s)
|
||||||
|
|
||||||
|
use pmoaudio::{BufferNode, DecoderNode, DspNode, SinkNode, SourceNode, TimerNode};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
println!("=== PMOAudio Pipeline Demo ===\n");
|
||||||
|
|
||||||
|
// Créer le pipeline de nodes
|
||||||
|
|
||||||
|
// 2. DecoderNode - passthrough dans cet exemple
|
||||||
|
let (mut decoder, decoder_tx) = DecoderNode::new(10);
|
||||||
|
|
||||||
|
// 3. DspNode - applique un gain de 0.5
|
||||||
|
let (mut dsp, dsp_tx) = DspNode::new(10, 0.5);
|
||||||
|
|
||||||
|
// 4. BufferNode - buffer circulaire pour multiroom
|
||||||
|
let (mut buffer, buffer_tx) = BufferNode::new(100, 10);
|
||||||
|
|
||||||
|
// 5. TimerNode - calcule la position temporelle
|
||||||
|
let (mut timer, timer_tx) = TimerNode::new(10);
|
||||||
|
|
||||||
|
// 6. SinkNodes - deux destinations finales
|
||||||
|
let (sink1, sink1_tx) = SinkNode::new("Main Output".to_string(), 10);
|
||||||
|
let (sink2, sink2_tx) = SinkNode::new("Secondary Output".to_string(), 10);
|
||||||
|
|
||||||
|
// Ajouter un abonné au BufferNode avec offset (multiroom simulation)
|
||||||
|
let (sink3, sink3_tx) = SinkNode::new("Delayed Output".to_string(), 10);
|
||||||
|
buffer.add_subscriber_with_offset(sink3_tx, 5).await; // 5 chunks de retard
|
||||||
|
|
||||||
|
// Connecter le pipeline
|
||||||
|
decoder.add_subscriber(dsp_tx);
|
||||||
|
dsp.add_subscriber(buffer_tx);
|
||||||
|
buffer.add_next_subscriber(timer_tx); // BufferNode -> TimerNode
|
||||||
|
timer.add_subscriber(sink1_tx);
|
||||||
|
timer.add_subscriber(sink2_tx);
|
||||||
|
|
||||||
|
// Obtenir un handle pour lire la position du TimerNode
|
||||||
|
let timer_handle = timer.get_position_handle();
|
||||||
|
|
||||||
|
// Spawn tous les nodes
|
||||||
|
let decoder_handle = tokio::spawn(async move {
|
||||||
|
decoder.run_passthrough().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let dsp_handle = tokio::spawn(async move {
|
||||||
|
dsp.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let buffer_handle = tokio::spawn(async move {
|
||||||
|
buffer.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let timer_handle_task = tokio::spawn(async move {
|
||||||
|
timer.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let sink1_handle = tokio::spawn(async move {
|
||||||
|
let stats = sink1.run_with_stats().await.unwrap();
|
||||||
|
stats.display();
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
|
||||||
|
let sink2_handle = tokio::spawn(async move {
|
||||||
|
sink2.run_silent().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let sink3_handle = tokio::spawn(async move {
|
||||||
|
let stats = sink3.run_with_stats().await.unwrap();
|
||||||
|
stats.display();
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn une tâche pour afficher la position périodiquement
|
||||||
|
let position_monitor = tokio::spawn(async move {
|
||||||
|
for _ in 0..10 {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
let position = timer_handle.position_sec().await;
|
||||||
|
let samples = timer_handle.elapsed_samples().await;
|
||||||
|
println!("Position: {:.3} sec ({} samples)", position, samples);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Générer des chunks audio
|
||||||
|
println!("Generating audio chunks...\n");
|
||||||
|
let chunk_size = 4800; // 100ms à 48kHz
|
||||||
|
let sample_rate = 48000;
|
||||||
|
let frequency = 440.0; // La 440Hz
|
||||||
|
|
||||||
|
// Source node dans une tâche séparée
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
source.add_subscriber(decoder_tx);
|
||||||
|
|
||||||
|
// Générer 50 chunks (environ 5 secondes)
|
||||||
|
source
|
||||||
|
.generate_chunks(50, chunk_size, sample_rate, frequency)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("\nChunks sent. Processing...\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attendre que tous les nodes terminent
|
||||||
|
decoder_handle.await.unwrap();
|
||||||
|
dsp_handle.await.unwrap();
|
||||||
|
buffer_handle.await.unwrap();
|
||||||
|
timer_handle_task.await.unwrap();
|
||||||
|
|
||||||
|
let stats1 = sink1_handle.await.unwrap();
|
||||||
|
sink2_handle.await.unwrap();
|
||||||
|
let stats3 = sink3_handle.await.unwrap();
|
||||||
|
position_monitor.await.unwrap();
|
||||||
|
|
||||||
|
println!("\n=== Pipeline Demo Complete ===");
|
||||||
|
println!(
|
||||||
|
"Main output processed: {} chunks, {:.3} sec",
|
||||||
|
stats1.chunks_received, stats1.total_duration_sec
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Delayed output processed: {} chunks, {:.3} sec",
|
||||||
|
stats3.chunks_received, stats3.total_duration_sec
|
||||||
|
);
|
||||||
|
}
|
||||||
96
old_code/pmoaudio/examples/quick_start.rs
Normal file
96
old_code/pmoaudio/examples/quick_start.rs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
//! Quick Start - Démonstration rapide des nouvelles fonctionnalités
|
||||||
|
//!
|
||||||
|
//! Cet exemple montre l'utilisation des principales nouvelles fonctionnalités :
|
||||||
|
//! - VolumeNode avec contrôle dynamique
|
||||||
|
//! - DiskSink pour écriture sur disque
|
||||||
|
//! - Pipeline simple et efficace
|
||||||
|
|
||||||
|
use pmoaudio::{AudioFileFormat, DiskSink, DiskSinkConfig, SourceNode, VolumeNode};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
println!("=== PMOAudio Quick Start ===\n");
|
||||||
|
|
||||||
|
// 1. Créer la source audio (génère un signal de test)
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
|
||||||
|
// 2. Créer un VolumeNode pour contrôler le volume
|
||||||
|
let (mut volume, volume_tx) = VolumeNode::new("main".to_string(), 0.8, 10);
|
||||||
|
let volume_handle = volume.get_handle();
|
||||||
|
|
||||||
|
// 3. Créer un DiskSink pour écrire sur disque
|
||||||
|
let output_dir = std::env::temp_dir().join("pmoaudio_quickstart");
|
||||||
|
let config = DiskSinkConfig {
|
||||||
|
output_dir: output_dir.clone(),
|
||||||
|
filename: Some("quickstart_output.wav".to_string()),
|
||||||
|
format: AudioFileFormat::Wav,
|
||||||
|
buffer_size: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (disk_sink, disk_tx) = DiskSink::new("disk".to_string(), config, 10);
|
||||||
|
|
||||||
|
// 4. Connecter le pipeline : Source → Volume → DiskSink
|
||||||
|
source.add_subscriber(volume_tx);
|
||||||
|
volume.add_subscriber(disk_tx);
|
||||||
|
|
||||||
|
println!("Pipeline configured:");
|
||||||
|
println!(" SourceNode → VolumeNode (vol=0.8) → DiskSink");
|
||||||
|
println!(" Output: {}/quickstart_output.wav\n", output_dir.display());
|
||||||
|
|
||||||
|
// 5. Lancer les nodes
|
||||||
|
let volume_handle_clone = volume_handle.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
volume.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let disk_handle = tokio::spawn(async move {
|
||||||
|
let stats = disk_sink.run().await.unwrap();
|
||||||
|
println!("\nDiskSink Statistics:");
|
||||||
|
stats.display();
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Démonstration du contrôle de volume pendant la lecture
|
||||||
|
tokio::spawn(async move {
|
||||||
|
println!("Generating audio with volume changes...");
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
println!(" → Volume: 0.8 (initial)");
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
volume_handle_clone.set_volume(0.5).await;
|
||||||
|
println!(" → Volume: 0.5 (decreased)");
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
volume_handle_clone.set_volume(1.0).await;
|
||||||
|
println!(" → Volume: 1.0 (maximum)");
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
volume_handle_clone.set_volume(0.3).await;
|
||||||
|
println!(" → Volume: 0.3 (low)");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Générer l'audio (10 chunks de 4800 samples à 48kHz = ~1 seconde)
|
||||||
|
source
|
||||||
|
.generate_chunks(
|
||||||
|
10, // nombre de chunks
|
||||||
|
4800, // samples par chunk (100ms @ 48kHz)
|
||||||
|
48000, // sample rate
|
||||||
|
440.0, // fréquence (La 440 Hz)
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 8. Attendre la fin du traitement
|
||||||
|
let stats = disk_handle.await?;
|
||||||
|
|
||||||
|
// 9. Résumé
|
||||||
|
println!("\n=== Summary ===");
|
||||||
|
println!("✓ Audio file generated successfully");
|
||||||
|
println!("✓ {} chunks written", stats.chunks_written);
|
||||||
|
println!("✓ Duration: {:.2} seconds", stats.total_duration_sec);
|
||||||
|
println!("✓ Volume was dynamically adjusted during playback");
|
||||||
|
println!("\nYou can play the file with:");
|
||||||
|
println!(" ffplay {}/quickstart_output.wav", output_dir.display());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
53
old_code/pmoaudio/examples/simple_pipeline.rs
Normal file
53
old_code/pmoaudio/examples/simple_pipeline.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//! Exemple simple de pipeline audio : Source → Timer → Sink
|
||||||
|
//!
|
||||||
|
//! Démontre l'utilisation basique du pipeline avec calcul de position
|
||||||
|
|
||||||
|
use pmoaudio::{SinkNode, SourceNode, TimerNode};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
println!("=== Simple Pipeline Example ===\n");
|
||||||
|
|
||||||
|
// Créer les nodes
|
||||||
|
let (mut timer, timer_tx) = TimerNode::new(10);
|
||||||
|
let (sink, sink_tx) = SinkNode::new("Output".to_string(), 10);
|
||||||
|
|
||||||
|
// Connecter
|
||||||
|
timer.add_subscriber(sink_tx);
|
||||||
|
|
||||||
|
// Handle pour monitorer la position
|
||||||
|
let timer_handle = timer.get_position_handle();
|
||||||
|
|
||||||
|
// Spawn timer et sink
|
||||||
|
tokio::spawn(async move {
|
||||||
|
timer.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let sink_handle = tokio::spawn(async move {
|
||||||
|
let stats = sink.run_with_stats().await.unwrap();
|
||||||
|
stats.display();
|
||||||
|
stats
|
||||||
|
});
|
||||||
|
|
||||||
|
// Générer quelques secondes d'audio dans une tâche séparée
|
||||||
|
println!("Generating 440Hz sine wave...\n");
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
source.add_subscriber(timer_tx);
|
||||||
|
|
||||||
|
source
|
||||||
|
.generate_chunks(30, 4800, 48000, 440.0) // ~3 secondes
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// La source est drop ici, fermant le channel
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attendre la fin
|
||||||
|
let stats = sink_handle.await.unwrap();
|
||||||
|
|
||||||
|
let final_position = timer_handle.position_sec().await;
|
||||||
|
println!("\nFinal position: {:.3} seconds", final_position);
|
||||||
|
println!("Total duration: {:.3} seconds", stats.total_duration_sec);
|
||||||
|
}
|
||||||
52
old_code/pmoaudio/examples/streaming_demo.rs
Normal file
52
old_code/pmoaudio/examples/streaming_demo.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
//! Exemple de streaming audio en temps réel
|
||||||
|
//!
|
||||||
|
//! Démontre l'utilisation du pipeline avec génération de chunks
|
||||||
|
//! en temps réel avec timing approprié
|
||||||
|
|
||||||
|
use pmoaudio::{SinkNode, SourceNode, TimerNode};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
println!("=== Streaming Demo ===\n");
|
||||||
|
println!("Streaming audio in real-time for 3 seconds...\n");
|
||||||
|
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
let (mut timer, timer_tx) = TimerNode::new(20);
|
||||||
|
let (sink, sink_tx) = SinkNode::new("Streaming Output".to_string(), 20);
|
||||||
|
|
||||||
|
source.add_subscriber(timer_tx);
|
||||||
|
timer.add_subscriber(sink_tx);
|
||||||
|
|
||||||
|
let timer_handle = timer.get_position_handle();
|
||||||
|
|
||||||
|
// Spawn le pipeline
|
||||||
|
tokio::spawn(async move {
|
||||||
|
timer.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let sink_handle = tokio::spawn(async move {
|
||||||
|
sink.run_with_logging().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Monitor la position
|
||||||
|
let monitor_handle = tokio::spawn(async move {
|
||||||
|
for _ in 0..15 {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
let position = timer_handle.position_sec().await;
|
||||||
|
println!("Playback position: {:.3} sec", position);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stream des chunks avec timing réel
|
||||||
|
// 100ms par chunk à 48kHz = 4800 samples
|
||||||
|
source
|
||||||
|
.stream_chunks(4800, 48000, 440.0, 3000) // 3 secondes
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("\nStreaming complete.");
|
||||||
|
|
||||||
|
// Attendre la fin
|
||||||
|
sink_handle.await.unwrap();
|
||||||
|
monitor_handle.await.unwrap();
|
||||||
|
}
|
||||||
58
old_code/pmoaudio/examples/volume_control_demo.rs
Normal file
58
old_code/pmoaudio/examples/volume_control_demo.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
//! Exemple simple de contrôle de volume
|
||||||
|
//!
|
||||||
|
//! Démontre l'utilisation du VolumeNode avec changements dynamiques
|
||||||
|
|
||||||
|
use pmoaudio::{SinkNode, SourceNode, VolumeNode};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
println!("=== Volume Control Demo ===\n");
|
||||||
|
|
||||||
|
// Créer la source
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
|
||||||
|
// Créer le volume node
|
||||||
|
let (mut volume, volume_tx) = VolumeNode::new("main".to_string(), 1.0, 10);
|
||||||
|
let volume_handle = volume.get_handle();
|
||||||
|
|
||||||
|
// Créer le sink
|
||||||
|
let (sink, sink_tx) = SinkNode::new("Output".to_string(), 10);
|
||||||
|
|
||||||
|
// Connecter le pipeline
|
||||||
|
source.add_subscriber(volume_tx);
|
||||||
|
volume.add_subscriber(sink_tx);
|
||||||
|
|
||||||
|
// Lancer les nodes
|
||||||
|
tokio::spawn(async move { volume.run().await.unwrap() });
|
||||||
|
|
||||||
|
let sink_handle = tokio::spawn(async move { sink.run_with_stats().await.unwrap() });
|
||||||
|
|
||||||
|
// Contrôler le volume pendant la lecture
|
||||||
|
let volume_control = tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
println!("Setting volume to 0.5");
|
||||||
|
volume_handle.set_volume(0.5).await;
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
println!("Setting volume to 0.2");
|
||||||
|
volume_handle.set_volume(0.2).await;
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
println!("Setting volume to 1.0");
|
||||||
|
volume_handle.set_volume(1.0).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Générer l'audio
|
||||||
|
source
|
||||||
|
.generate_chunks(20, 4800, 48000, 440.0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
volume_control.await?;
|
||||||
|
let stats = sink_handle.await?;
|
||||||
|
|
||||||
|
println!("\nFinal statistics:");
|
||||||
|
stats.display();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
245
old_code/pmoaudio/nodes/buffer_node.rs
Normal file
245
old_code/pmoaudio/nodes/buffer_node.rs
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
use crate::{
|
||||||
|
nodes::{AudioError, MultiSubscriberNode},
|
||||||
|
AudioSegment,
|
||||||
|
};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{mpsc, RwLock};
|
||||||
|
|
||||||
|
/// Subscriber avec son propre offset dans le buffer
|
||||||
|
struct BufferSubscriber {
|
||||||
|
tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||||
|
offset: usize, // Position dans le buffer circulaire
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BufferNode avec buffer circulaire pour support multiroom
|
||||||
|
///
|
||||||
|
/// Ce node maintient un buffer circulaire de chunks et permet à plusieurs
|
||||||
|
/// abonnés de lire avec des offsets différents, ce qui est idéal pour des
|
||||||
|
/// configurations multiroom où différentes pièces peuvent avoir un léger
|
||||||
|
/// délai de synchronisation.
|
||||||
|
///
|
||||||
|
/// # Fonctionnement
|
||||||
|
///
|
||||||
|
/// - Le buffer est implémenté avec un `VecDeque` de taille fixe
|
||||||
|
/// - Chaque abonné peut avoir un offset indépendant (en nombre de chunks)
|
||||||
|
/// - Utilise `try_send` pour éviter de bloquer si un abonné est saturé
|
||||||
|
///
|
||||||
|
/// # Exemples
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use pmoaudio::{BufferNode, SinkNode};
|
||||||
|
///
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() {
|
||||||
|
/// let (buffer, buffer_tx) = BufferNode::new(50, 10);
|
||||||
|
///
|
||||||
|
/// let (sink1, sink1_tx) = SinkNode::new("Room 1".to_string(), 10);
|
||||||
|
/// let (sink2, sink2_tx) = SinkNode::new("Room 2".to_string(), 10);
|
||||||
|
///
|
||||||
|
/// // Room 1 sans délai
|
||||||
|
/// buffer.add_subscriber_with_offset(sink1_tx, 0).await;
|
||||||
|
///
|
||||||
|
/// // Room 2 avec 5 chunks de retard
|
||||||
|
/// buffer.add_subscriber_with_offset(sink2_tx, 5).await;
|
||||||
|
///
|
||||||
|
/// tokio::spawn(async move { buffer.run().await.unwrap() });
|
||||||
|
/// // ... spawn sinks et source
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub struct BufferNode {
|
||||||
|
buffer: Arc<RwLock<VecDeque<Arc<AudioSegment>>>>,
|
||||||
|
subscribers: Arc<RwLock<Vec<BufferSubscriber>>>,
|
||||||
|
buffer_size: usize,
|
||||||
|
rx: mpsc::Receiver<Arc<AudioSegment>>,
|
||||||
|
next_subscribers: MultiSubscriberNode, // Pour passer au node suivant
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BufferNode {
|
||||||
|
/// Crée un nouveau BufferNode
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `buffer_size` - Taille maximale du buffer circulaire
|
||||||
|
/// * `channel_size` - Taille du channel bounded pour backpressure
|
||||||
|
pub fn new(buffer_size: usize, channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioSegment>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let node = Self {
|
||||||
|
buffer: Arc::new(RwLock::new(VecDeque::with_capacity(buffer_size))),
|
||||||
|
subscribers: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
buffer_size,
|
||||||
|
rx,
|
||||||
|
next_subscribers: MultiSubscriberNode::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(node, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un abonné avec un offset spécifique (pour multiroom)
|
||||||
|
pub async fn add_subscriber_with_offset(
|
||||||
|
&self,
|
||||||
|
tx: mpsc::Sender<Arc<AudioSegment>>,
|
||||||
|
offset: usize,
|
||||||
|
) {
|
||||||
|
let mut subs = self.subscribers.write().await;
|
||||||
|
subs.push(BufferSubscriber { tx, offset });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un abonné sans offset (commence au chunk courant)
|
||||||
|
pub async fn add_subscriber(&self, tx: mpsc::Sender<Arc<AudioSegment>>) {
|
||||||
|
self.add_subscriber_with_offset(tx, 0).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un abonné pour le node suivant (sans buffer)
|
||||||
|
pub fn add_next_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioSegment>>) {
|
||||||
|
self.next_subscribers.add_subscriber(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre la boucle de traitement du BufferNode
|
||||||
|
pub async fn run(mut self) -> Result<(), AudioError> {
|
||||||
|
let mut chunk_index = 0usize;
|
||||||
|
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
// Ajouter au buffer circulaire
|
||||||
|
{
|
||||||
|
let mut buffer = self.buffer.write().await;
|
||||||
|
if buffer.len() >= self.buffer_size {
|
||||||
|
buffer.pop_front();
|
||||||
|
}
|
||||||
|
buffer.push_back(chunk.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envoyer aux abonnés avec offset
|
||||||
|
{
|
||||||
|
let buffer = self.buffer.read().await;
|
||||||
|
let mut subs = self.subscribers.write().await;
|
||||||
|
|
||||||
|
for sub in subs.iter_mut() {
|
||||||
|
// Calculer l'index dans le buffer en fonction de l'offset
|
||||||
|
let target_index = if chunk_index >= sub.offset {
|
||||||
|
chunk_index - sub.offset
|
||||||
|
} else {
|
||||||
|
continue; // Pas encore assez de données
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vérifier si le chunk est disponible dans le buffer
|
||||||
|
let buffer_age = chunk_index - target_index;
|
||||||
|
if buffer_age < buffer.len() {
|
||||||
|
let chunk_to_send = &buffer[buffer.len() - buffer_age - 1];
|
||||||
|
// try_send non-bloquant pour éviter de bloquer la source
|
||||||
|
let _ = sub.tx.try_send(chunk_to_send.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push vers les nodes suivants sans buffer
|
||||||
|
self.next_subscribers.try_push(chunk).await?;
|
||||||
|
|
||||||
|
chunk_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version avec push synchrone au lieu de try_push
|
||||||
|
pub async fn run_blocking(mut self) -> Result<(), AudioError> {
|
||||||
|
let mut chunk_index = 0usize;
|
||||||
|
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
// Ajouter au buffer circulaire
|
||||||
|
{
|
||||||
|
let mut buffer = self.buffer.write().await;
|
||||||
|
if buffer.len() >= self.buffer_size {
|
||||||
|
buffer.pop_front();
|
||||||
|
}
|
||||||
|
buffer.push_back(chunk.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envoyer aux abonnés avec offset
|
||||||
|
{
|
||||||
|
let buffer = self.buffer.read().await;
|
||||||
|
let subs = self.subscribers.read().await;
|
||||||
|
|
||||||
|
for sub in subs.iter() {
|
||||||
|
let target_index = if chunk_index >= sub.offset {
|
||||||
|
chunk_index - sub.offset
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let buffer_age = chunk_index - target_index;
|
||||||
|
if buffer_age < buffer.len() {
|
||||||
|
let chunk_to_send = &buffer[buffer.len() - buffer_age - 1];
|
||||||
|
let _ = sub.tx.send(chunk_to_send.clone()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push vers les nodes suivants
|
||||||
|
for _ in 0..self.next_subscribers.subscribers.len() {
|
||||||
|
self.next_subscribers.push(chunk.clone()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk_index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_buffer_node_basic() {
|
||||||
|
let (mut node, tx) = BufferNode::new(10, 5);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(5);
|
||||||
|
|
||||||
|
node.add_next_subscriber(out_tx);
|
||||||
|
|
||||||
|
// Spawn le node
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer des chunks
|
||||||
|
for i in 0..3 {
|
||||||
|
let chunk = AudioSegment::AudioChunk(AudioChunk::new(i, vec![[0i32; 2]; 100], 48000, BitDepth::B24));
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recevoir les chunks
|
||||||
|
for i in 0..3 {
|
||||||
|
let chunk = out_rx.recv().await.unwrap();
|
||||||
|
assert_eq!(chunk.order(), i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_buffer_node_with_offset() {
|
||||||
|
let (node, tx) = BufferNode::new(10, 10);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
// Ajouter un abonné avec offset de 2 chunks
|
||||||
|
node.add_subscriber_with_offset(out_tx, 2).await;
|
||||||
|
|
||||||
|
// Spawn le node
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer 5 chunks
|
||||||
|
for i in 0..5 {
|
||||||
|
let chunk = AudioSegment::new(i, vec![[0i32; 2]; 100], 48000, BitDepth::B24);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
// L'abonné devrait recevoir les chunks 0, 1, 2 (avec 2 chunks de retard)
|
||||||
|
let chunk = out_rx.try_recv().unwrap();
|
||||||
|
assert_eq!(chunk.order(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
290
old_code/pmoaudio/nodes/chromecast_sink.rs
Normal file
290
old_code/pmoaudio/nodes/chromecast_sink.rs
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
//! ChromecastSink - Diffuse le flux audio vers un périphérique Chromecast
|
||||||
|
//!
|
||||||
|
//! Ce module fournit un sink qui envoie le flux audio à un Chromecast.
|
||||||
|
//! Note: Cette implémentation est une version mock/skeleton. Une vraie implémentation
|
||||||
|
//! nécessiterait une bibliothèque comme `rust-cast` ou similaire.
|
||||||
|
|
||||||
|
use crate::{nodes::AudioError, AudioChunk};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// Configuration pour le ChromecastSink
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ChromecastConfig {
|
||||||
|
/// Nom ou adresse IP du Chromecast
|
||||||
|
pub device_address: String,
|
||||||
|
|
||||||
|
/// Nom amical du device
|
||||||
|
pub device_name: String,
|
||||||
|
|
||||||
|
/// Port de communication (défaut: 8009)
|
||||||
|
pub port: u16,
|
||||||
|
|
||||||
|
/// Taille du buffer de streaming
|
||||||
|
pub buffer_size: usize,
|
||||||
|
|
||||||
|
/// Format d'encodage pour le streaming
|
||||||
|
pub encoding: StreamEncoding,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ChromecastConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
device_address: "192.168.1.100".to_string(),
|
||||||
|
device_name: "Living Room".to_string(),
|
||||||
|
port: 8009,
|
||||||
|
buffer_size: 50,
|
||||||
|
encoding: StreamEncoding::Mp3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats d'encodage supportés pour le streaming
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum StreamEncoding {
|
||||||
|
/// MP3 (compatible avec la plupart des Chromecasts)
|
||||||
|
Mp3,
|
||||||
|
/// AAC
|
||||||
|
Aac,
|
||||||
|
/// Opus
|
||||||
|
Opus,
|
||||||
|
/// PCM non compressé (haute qualité, bande passante élevée)
|
||||||
|
Pcm,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ChromecastSink - Diffuse vers un périphérique Chromecast
|
||||||
|
///
|
||||||
|
/// Ce sink encode le flux audio et le streame vers un Chromecast.
|
||||||
|
/// La connexion est établie lors de l'initialisation et maintenue pendant toute la durée.
|
||||||
|
///
|
||||||
|
/// # Implémentation actuelle
|
||||||
|
///
|
||||||
|
/// Cette version est un mock qui simule l'envoi au Chromecast.
|
||||||
|
/// Pour une vraie implémentation, il faudrait:
|
||||||
|
/// - Utiliser une bibliothèque comme `rust-cast`
|
||||||
|
/// - Établir une connexion TLS avec le device
|
||||||
|
/// - Lancer une application de récepteur sur le Chromecast
|
||||||
|
/// - Encoder l'audio dans le format approprié
|
||||||
|
/// - Streamer via HTTP ou WebSocket
|
||||||
|
///
|
||||||
|
/// # Exemples
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use pmoaudio::{ChromecastSink, ChromecastConfig};
|
||||||
|
///
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() {
|
||||||
|
/// let config = ChromecastConfig {
|
||||||
|
/// device_address: "192.168.1.100".to_string(),
|
||||||
|
/// device_name: "Living Room".to_string(),
|
||||||
|
/// ..Default::default()
|
||||||
|
/// };
|
||||||
|
///
|
||||||
|
/// let (sink, sink_tx) = ChromecastSink::new("chromecast1".to_string(), config, 10);
|
||||||
|
///
|
||||||
|
/// tokio::spawn(async move {
|
||||||
|
/// sink.run().await.unwrap()
|
||||||
|
/// });
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub struct ChromecastSink {
|
||||||
|
/// Identifiant du sink
|
||||||
|
node_id: String,
|
||||||
|
|
||||||
|
/// Channel pour recevoir les chunks audio
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
|
||||||
|
/// Configuration
|
||||||
|
config: ChromecastConfig,
|
||||||
|
|
||||||
|
/// État de la connexion (mock)
|
||||||
|
connected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChromecastSink {
|
||||||
|
/// Crée un nouveau ChromecastSink
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `node_id` - Identifiant unique du sink
|
||||||
|
/// * `config` - Configuration du Chromecast
|
||||||
|
/// * `channel_size` - Taille du buffer du channel
|
||||||
|
pub fn new(
|
||||||
|
node_id: String,
|
||||||
|
config: ChromecastConfig,
|
||||||
|
channel_size: usize,
|
||||||
|
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let sink = Self {
|
||||||
|
node_id,
|
||||||
|
rx,
|
||||||
|
config,
|
||||||
|
connected: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
(sink, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Établit la connexion avec le Chromecast (mock)
|
||||||
|
async fn connect(&mut self) -> Result<(), AudioError> {
|
||||||
|
println!(
|
||||||
|
"[{}] Connecting to Chromecast '{}' at {}:{}...",
|
||||||
|
self.node_id, self.config.device_name, self.config.device_address, self.config.port
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simuler une connexion
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||||
|
|
||||||
|
self.connected = true;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"[{}] Connected to Chromecast '{}' successfully",
|
||||||
|
self.node_id, self.config.device_name
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envoie un chunk au Chromecast (mock)
|
||||||
|
async fn send_chunk(&self, _chunk: &AudioChunk) -> Result<(), AudioError> {
|
||||||
|
if !self.connected {
|
||||||
|
return Err(AudioError::ProcessingError(
|
||||||
|
"Not connected to Chromecast".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dans une vraie implémentation:
|
||||||
|
// 1. Appliquer le gain
|
||||||
|
// 2. Encoder dans le format approprié (MP3, AAC, etc.)
|
||||||
|
// 3. Envoyer via le protocole Chromecast
|
||||||
|
|
||||||
|
// Pour l'instant, simplement simuler un délai d'envoi
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_micros(50)).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Déconnecte proprement du Chromecast (mock)
|
||||||
|
async fn disconnect(&mut self) -> Result<(), AudioError> {
|
||||||
|
if self.connected {
|
||||||
|
println!(
|
||||||
|
"[{}] Disconnecting from Chromecast '{}'...",
|
||||||
|
self.node_id, self.config.device_name
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simuler la déconnexion
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
|
||||||
|
self.connected = false;
|
||||||
|
|
||||||
|
println!("[{}] Disconnected successfully", self.node_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre la boucle de traitement du ChromecastSink
|
||||||
|
pub async fn run(mut self) -> Result<ChromecastStats, AudioError> {
|
||||||
|
// Établir la connexion
|
||||||
|
self.connect().await?;
|
||||||
|
|
||||||
|
let mut stats = ChromecastStats::new(self.node_id.clone(), self.config.device_name.clone());
|
||||||
|
|
||||||
|
// Boucle principale
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
// Appliquer le gain si nécessaire
|
||||||
|
let chunk_to_send = if chunk.gain_db().abs() > f64::EPSILON {
|
||||||
|
Arc::clone(&chunk).apply_gain()
|
||||||
|
} else {
|
||||||
|
Arc::clone(&chunk)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Envoyer au Chromecast
|
||||||
|
self.send_chunk(&chunk_to_send).await?;
|
||||||
|
|
||||||
|
stats.record_chunk(&chunk_to_send);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déconnexion propre
|
||||||
|
self.disconnect().await?;
|
||||||
|
|
||||||
|
stats.finalize();
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistiques du ChromecastSink
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ChromecastStats {
|
||||||
|
pub node_id: String,
|
||||||
|
pub device_name: String,
|
||||||
|
pub chunks_sent: u64,
|
||||||
|
pub total_samples: u64,
|
||||||
|
pub total_duration_sec: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChromecastStats {
|
||||||
|
pub fn new(node_id: String, device_name: String) -> Self {
|
||||||
|
Self {
|
||||||
|
node_id,
|
||||||
|
device_name,
|
||||||
|
chunks_sent: 0,
|
||||||
|
total_samples: 0,
|
||||||
|
total_duration_sec: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_chunk(&mut self, chunk: &AudioChunk) {
|
||||||
|
self.chunks_sent += 1;
|
||||||
|
self.total_samples += chunk.len() as u64;
|
||||||
|
self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate() as f64;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finalize(&mut self) {
|
||||||
|
// Calculs finaux si nécessaire
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display(&self) {
|
||||||
|
println!("\n=== Chromecast Statistics: {} ===", self.node_id);
|
||||||
|
println!("Device: {}", self.device_name);
|
||||||
|
println!("Chunks sent: {}", self.chunks_sent);
|
||||||
|
println!("Total samples: {}", self.total_samples);
|
||||||
|
println!("Total duration: {:.3} sec", self.total_duration_sec);
|
||||||
|
println!("==================================\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::i32;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_chromecast_sink_basic() {
|
||||||
|
let config = ChromecastConfig {
|
||||||
|
device_address: "127.0.0.1".to_string(),
|
||||||
|
device_name: "Test Device".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (sink, tx) = ChromecastSink::new("test".to_string(), config, 10);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move { sink.run().await });
|
||||||
|
|
||||||
|
// Envoyer quelques chunks
|
||||||
|
for i in 0..5 {
|
||||||
|
let stereo = vec![[i32::MAX / 2; 2]; 1000];
|
||||||
|
let chunk = AudioChunk::new(i, stereo, 48000, BitDepth::B24);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
let stats = handle.await.unwrap().unwrap();
|
||||||
|
assert_eq!(stats.chunks_sent, 5);
|
||||||
|
assert_eq!(stats.device_name, "Test Device");
|
||||||
|
}
|
||||||
|
}
|
||||||
162
old_code/pmoaudio/nodes/decoder_node.rs
Normal file
162
old_code/pmoaudio/nodes/decoder_node.rs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
use crate::{
|
||||||
|
nodes::{AudioError, MultiSubscriberNode},
|
||||||
|
AudioChunk,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// DecoderNode - Décode des chunks audio
|
||||||
|
///
|
||||||
|
/// Version mock qui passe simplement les chunks (ou simule un décodage simple)
|
||||||
|
pub struct DecoderNode {
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
subscribers: MultiSubscriberNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DecoderNode {
|
||||||
|
pub fn new(channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let node = Self {
|
||||||
|
rx,
|
||||||
|
subscribers: MultiSubscriberNode::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
(node, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
self.subscribers.add_subscriber(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mode passthrough - passe les chunks sans modification
|
||||||
|
pub async fn run_passthrough(mut self) -> Result<(), AudioError> {
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
self.subscribers.push(chunk).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mode mock décodage - simule un changement de sample rate
|
||||||
|
pub async fn run_with_resampling(mut self, target_sample_rate: u32) -> Result<(), AudioError> {
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
if chunk.sample_rate() == target_sample_rate {
|
||||||
|
// Pas besoin de resampling
|
||||||
|
self.subscribers.push(chunk).await?;
|
||||||
|
} else {
|
||||||
|
// Simuler un resampling (mock simple)
|
||||||
|
let ratio = target_sample_rate as f64 / chunk.sample_rate() as f64;
|
||||||
|
let new_len = (chunk.len() as f64 * ratio) as usize;
|
||||||
|
|
||||||
|
let pairs = chunk.to_pairs_f32();
|
||||||
|
let mut resampled = Vec::with_capacity(new_len);
|
||||||
|
|
||||||
|
// Resampling linéaire simple (mock)
|
||||||
|
for i in 0..new_len {
|
||||||
|
let src_pos = i as f64 / ratio;
|
||||||
|
let src_idx = src_pos as usize;
|
||||||
|
|
||||||
|
if src_idx + 1 < pairs.len() {
|
||||||
|
let frac = src_pos - src_idx as f64;
|
||||||
|
let alpha = (1.0 - frac) as f32;
|
||||||
|
let beta = frac as f32;
|
||||||
|
let left_sample = pairs[src_idx][0] * alpha + pairs[src_idx + 1][0] * beta;
|
||||||
|
let right_sample = pairs[src_idx][1] * alpha + pairs[src_idx + 1][1] * beta;
|
||||||
|
|
||||||
|
resampled.push([left_sample, right_sample]);
|
||||||
|
} else if src_idx < pairs.len() {
|
||||||
|
resampled.push(pairs[src_idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut new_chunk = AudioChunk::from_pairs_f32(
|
||||||
|
chunk.order(),
|
||||||
|
resampled,
|
||||||
|
target_sample_rate,
|
||||||
|
chunk.bit_depth(),
|
||||||
|
);
|
||||||
|
if chunk.gain_db().abs() > f64::EPSILON {
|
||||||
|
new_chunk = new_chunk.set_gain_db(chunk.gain_db());
|
||||||
|
}
|
||||||
|
self.subscribers.push(new_chunk).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_decoder_passthrough() {
|
||||||
|
let (mut node, tx) = DecoderNode::new(10);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run_passthrough().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer un chunk
|
||||||
|
let chunk = AudioChunk::from_channels_f32(
|
||||||
|
0,
|
||||||
|
vec![1.0, 2.0, 3.0],
|
||||||
|
vec![4.0, 5.0, 6.0],
|
||||||
|
48000,
|
||||||
|
BitDepth::B24,
|
||||||
|
);
|
||||||
|
tx.send(chunk.clone()).await.unwrap();
|
||||||
|
|
||||||
|
// Recevoir le chunk
|
||||||
|
let received = out_rx.recv().await.unwrap();
|
||||||
|
assert!(Arc::ptr_eq(&chunk, &received));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_decoder_resampling() {
|
||||||
|
let (mut node, tx) = DecoderNode::new(10);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run_with_resampling(96000).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer un chunk à 48000 Hz
|
||||||
|
let chunk =
|
||||||
|
AudioChunk::from_channels_f32(0, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
|
||||||
|
// Recevoir le chunk resampleé
|
||||||
|
let received = out_rx.recv().await.unwrap();
|
||||||
|
assert_eq!(received.sample_rate(), 96000);
|
||||||
|
// Le chunk devrait être environ 2x plus grand
|
||||||
|
assert!(received.len() > 150 && received.len() < 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_decoder_no_resampling_needed() {
|
||||||
|
let (mut node, tx) = DecoderNode::new(10);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run_with_resampling(48000).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer un chunk déjà au bon sample rate
|
||||||
|
let chunk =
|
||||||
|
AudioChunk::from_channels_f32(0, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24);
|
||||||
|
tx.send(chunk.clone()).await.unwrap();
|
||||||
|
|
||||||
|
// Le chunk devrait être passé sans modification
|
||||||
|
let received = out_rx.recv().await.unwrap();
|
||||||
|
assert!(Arc::ptr_eq(&chunk, &received));
|
||||||
|
}
|
||||||
|
}
|
||||||
500
old_code/pmoaudio/nodes/disk_sink.rs
Normal file
500
old_code/pmoaudio/nodes/disk_sink.rs
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
//! DiskSink - Écrit le flux audio dans un fichier
|
||||||
|
//!
|
||||||
|
//! Ce module fournit un sink qui écrit les chunks audio sur disque,
|
||||||
|
//! avec support de la dérivation automatique du nom de fichier depuis la source.
|
||||||
|
|
||||||
|
use crate::{events::SourceNameUpdateEvent, nodes::AudioError, AudioChunk};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::fs::File;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tokio::sync::{mpsc, RwLock};
|
||||||
|
|
||||||
|
/// Configuration pour le DiskSink
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DiskSinkConfig {
|
||||||
|
/// Chemin racine où écrire les fichiers
|
||||||
|
pub output_dir: PathBuf,
|
||||||
|
|
||||||
|
/// Nom de fichier explicite (optionnel)
|
||||||
|
/// Si None, sera dérivé du nom de la source
|
||||||
|
pub filename: Option<String>,
|
||||||
|
|
||||||
|
/// Format d'écriture
|
||||||
|
pub format: AudioFileFormat,
|
||||||
|
|
||||||
|
/// Taille du buffer d'écriture (en chunks)
|
||||||
|
pub buffer_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DiskSinkConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
output_dir: PathBuf::from("."),
|
||||||
|
filename: None,
|
||||||
|
format: AudioFileFormat::Wav,
|
||||||
|
buffer_size: 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats de fichiers audio supportés
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum AudioFileFormat {
|
||||||
|
/// Format WAV (non compressé)
|
||||||
|
Wav,
|
||||||
|
/// Format FLAC (compressé sans perte)
|
||||||
|
Flac,
|
||||||
|
/// Format brut PCM
|
||||||
|
Raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioFileFormat {
|
||||||
|
/// Retourne l'extension de fichier appropriée
|
||||||
|
pub fn extension(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
AudioFileFormat::Wav => "wav",
|
||||||
|
AudioFileFormat::Flac => "flac",
|
||||||
|
AudioFileFormat::Raw => "pcm",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DiskSink - Écrit le flux audio dans un fichier sur disque
|
||||||
|
///
|
||||||
|
/// Ce sink consomme les chunks audio et les écrit dans un fichier.
|
||||||
|
/// Le nom du fichier peut être dérivé automatiquement du nom de la source
|
||||||
|
/// via les événements `SourceNameUpdateEvent`.
|
||||||
|
///
|
||||||
|
/// # Caractéristiques
|
||||||
|
///
|
||||||
|
/// - Écriture asynchrone avec buffer
|
||||||
|
/// - Dérivation automatique du nom de fichier depuis la source
|
||||||
|
/// - Support de plusieurs formats (WAV, FLAC, PCM brut)
|
||||||
|
/// - Gestion du gain : applique le gain avant l'écriture
|
||||||
|
///
|
||||||
|
/// # Exemples
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use pmoaudio::{DiskSink, DiskSinkConfig};
|
||||||
|
/// use std::path::PathBuf;
|
||||||
|
///
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() {
|
||||||
|
/// let config = DiskSinkConfig {
|
||||||
|
/// output_dir: PathBuf::from("/tmp/audio"),
|
||||||
|
/// filename: Some("output.wav".to_string()),
|
||||||
|
/// ..Default::default()
|
||||||
|
/// };
|
||||||
|
///
|
||||||
|
/// let (sink, sink_tx) = DiskSink::new("disk1".to_string(), config, 10);
|
||||||
|
///
|
||||||
|
/// tokio::spawn(async move {
|
||||||
|
/// sink.run().await.unwrap()
|
||||||
|
/// });
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub struct DiskSink {
|
||||||
|
/// Identifiant du sink
|
||||||
|
node_id: String,
|
||||||
|
|
||||||
|
/// Channel pour recevoir les chunks audio
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
|
||||||
|
/// Configuration
|
||||||
|
config: DiskSinkConfig,
|
||||||
|
|
||||||
|
/// Nom de fichier résolu (partagé)
|
||||||
|
resolved_filename: Arc<RwLock<Option<PathBuf>>>,
|
||||||
|
|
||||||
|
/// Receiver pour les événements de nom de source (optionnel)
|
||||||
|
source_name_rx: Option<mpsc::Receiver<SourceNameUpdateEvent>>,
|
||||||
|
|
||||||
|
/// Writer pour le fichier
|
||||||
|
writer: Option<AudioFileWriter>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiskSink {
|
||||||
|
/// Crée un nouveau DiskSink
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `node_id` - Identifiant unique du sink
|
||||||
|
/// * `config` - Configuration du sink
|
||||||
|
/// * `channel_size` - Taille du buffer du channel
|
||||||
|
pub fn new(
|
||||||
|
node_id: String,
|
||||||
|
config: DiskSinkConfig,
|
||||||
|
channel_size: usize,
|
||||||
|
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let sink = Self {
|
||||||
|
node_id,
|
||||||
|
rx,
|
||||||
|
config,
|
||||||
|
resolved_filename: Arc::new(RwLock::new(None)),
|
||||||
|
source_name_rx: None,
|
||||||
|
writer: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
(sink, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure la source des événements de nom de source
|
||||||
|
pub fn set_source_name_source(&mut self, rx: mpsc::Receiver<SourceNameUpdateEvent>) {
|
||||||
|
self.source_name_rx = Some(rx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Résout le nom du fichier de sortie
|
||||||
|
///
|
||||||
|
/// Si un filename explicite est fourni dans la config, l'utilise.
|
||||||
|
/// Sinon, utilise le source_name avec l'extension appropriée.
|
||||||
|
fn resolve_filename(&self, source_name: Option<&str>) -> PathBuf {
|
||||||
|
let filename = if let Some(ref explicit_name) = self.config.filename {
|
||||||
|
explicit_name.clone()
|
||||||
|
} else if let Some(name) = source_name {
|
||||||
|
// Nettoyer le nom de la source pour en faire un nom de fichier valide
|
||||||
|
let clean_name = name
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_alphanumeric() || c == '_' || c == '-' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
format!("{}.{}", clean_name, self.config.format.extension())
|
||||||
|
} else {
|
||||||
|
// Fallback sur un nom par défaut
|
||||||
|
format!("{}.{}", self.node_id, self.config.format.extension())
|
||||||
|
};
|
||||||
|
|
||||||
|
self.config.output_dir.join(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialise le writer pour le fichier de sortie
|
||||||
|
async fn initialize_writer(&mut self, source_name: Option<&str>) -> Result<(), AudioError> {
|
||||||
|
let path = self.resolve_filename(source_name);
|
||||||
|
*self.resolved_filename.write().await = Some(path.clone());
|
||||||
|
|
||||||
|
// Créer le répertoire parent si nécessaire
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to create directory: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer le writer approprié selon le format
|
||||||
|
let writer = match self.config.format {
|
||||||
|
AudioFileFormat::Wav => AudioFileWriter::new_wav(path).await?,
|
||||||
|
AudioFileFormat::Flac => {
|
||||||
|
// FLAC nécessiterait une bibliothèque externe, pour l'instant utiliser WAV
|
||||||
|
AudioFileWriter::new_wav(path).await?
|
||||||
|
}
|
||||||
|
AudioFileFormat::Raw => AudioFileWriter::new_raw(path).await?,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.writer = Some(writer);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre la boucle de traitement du DiskSink
|
||||||
|
pub async fn run(mut self) -> Result<DiskSinkStats, AudioError> {
|
||||||
|
let mut stats = DiskSinkStats::new(self.node_id.clone());
|
||||||
|
let mut source_name: Option<String> = None;
|
||||||
|
let mut initialized = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
// Recevoir les chunks audio
|
||||||
|
chunk_opt = self.rx.recv() => {
|
||||||
|
match chunk_opt {
|
||||||
|
Some(chunk) => {
|
||||||
|
// Initialiser le writer à la réception du premier chunk
|
||||||
|
if !initialized {
|
||||||
|
self.initialize_writer(source_name.as_deref()).await?;
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appliquer le gain avant l'écriture
|
||||||
|
let chunk_with_gain = if chunk.gain_db().abs() > f64::EPSILON {
|
||||||
|
Arc::clone(&chunk).apply_gain()
|
||||||
|
} else {
|
||||||
|
Arc::clone(&chunk)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Écrire le chunk
|
||||||
|
if let Some(ref mut writer) = self.writer {
|
||||||
|
writer.write_chunk(&chunk_with_gain).await?;
|
||||||
|
stats.record_chunk(&chunk_with_gain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Channel fermé, terminer
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recevoir les mises à jour du nom de source
|
||||||
|
source_event_opt = async {
|
||||||
|
if let Some(ref mut rx) = self.source_name_rx {
|
||||||
|
rx.recv().await
|
||||||
|
} else {
|
||||||
|
std::future::pending().await
|
||||||
|
}
|
||||||
|
} => {
|
||||||
|
if let Some(event) = source_event_opt {
|
||||||
|
source_name = Some(event.source_name.clone());
|
||||||
|
|
||||||
|
// Si on n'a pas encore initialisé, le nom sera utilisé plus tard
|
||||||
|
// Sinon, on pourrait décider de fermer le fichier actuel et d'en créer un nouveau
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fermer le fichier proprement
|
||||||
|
if let Some(writer) = self.writer {
|
||||||
|
writer.close().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.finalize();
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writer pour fichiers audio
|
||||||
|
struct AudioFileWriter {
|
||||||
|
file: File,
|
||||||
|
format: AudioFileFormat,
|
||||||
|
sample_rate: Option<u32>,
|
||||||
|
total_samples: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioFileWriter {
|
||||||
|
/// Crée un writer WAV
|
||||||
|
async fn new_wav(path: PathBuf) -> Result<Self, AudioError> {
|
||||||
|
let file = File::create(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AudioError::ProcessingError(format!("Failed to create file: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
file,
|
||||||
|
format: AudioFileFormat::Wav,
|
||||||
|
sample_rate: None,
|
||||||
|
total_samples: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée un writer pour PCM brut
|
||||||
|
async fn new_raw(path: PathBuf) -> Result<Self, AudioError> {
|
||||||
|
let file = File::create(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AudioError::ProcessingError(format!("Failed to create file: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
file,
|
||||||
|
format: AudioFileFormat::Raw,
|
||||||
|
sample_rate: None,
|
||||||
|
total_samples: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Écrit un chunk audio
|
||||||
|
async fn write_chunk(&mut self, chunk: &AudioChunk) -> Result<(), AudioError> {
|
||||||
|
// Enregistrer le sample rate du premier chunk
|
||||||
|
if self.sample_rate.is_none() {
|
||||||
|
let sr = chunk.sample_rate();
|
||||||
|
self.sample_rate = Some(sr);
|
||||||
|
|
||||||
|
// Pour WAV, écrire l'en-tête (simplifié)
|
||||||
|
if matches!(self.format, AudioFileFormat::Wav) {
|
||||||
|
self.write_wav_header(sr).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir en bytes (little-endian 16-bit PCM)
|
||||||
|
let mut bytes = Vec::with_capacity(chunk.len() * 4);
|
||||||
|
let max_val = chunk.bit_depth().max_value();
|
||||||
|
for frame in chunk.frames() {
|
||||||
|
let left = (frame[0] as f32 / max_val).clamp(-1.0, 1.0);
|
||||||
|
let right = (frame[1] as f32 / max_val).clamp(-1.0, 1.0);
|
||||||
|
let sample_i16 = (left * 32767.0) as i16;
|
||||||
|
bytes.extend_from_slice(&sample_i16.to_le_bytes());
|
||||||
|
let sample_r16 = (right * 32767.0) as i16;
|
||||||
|
bytes.extend_from_slice(&sample_r16.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.file.write_all(&bytes).await.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to write audio data: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.total_samples += chunk.len();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Écrit un en-tête WAV simplifié
|
||||||
|
async fn write_wav_header(&mut self, sample_rate: u32) -> Result<(), AudioError> {
|
||||||
|
// En-tête WAV basique (sera mis à jour à la fermeture)
|
||||||
|
let mut header = Vec::new();
|
||||||
|
|
||||||
|
// RIFF chunk
|
||||||
|
header.extend_from_slice(b"RIFF");
|
||||||
|
header.extend_from_slice(&0u32.to_le_bytes()); // Taille (à mettre à jour)
|
||||||
|
header.extend_from_slice(b"WAVE");
|
||||||
|
|
||||||
|
// fmt chunk
|
||||||
|
header.extend_from_slice(b"fmt ");
|
||||||
|
header.extend_from_slice(&16u32.to_le_bytes()); // Taille du fmt chunk
|
||||||
|
header.extend_from_slice(&1u16.to_le_bytes()); // Format PCM
|
||||||
|
header.extend_from_slice(&2u16.to_le_bytes()); // 2 canaux (stéréo)
|
||||||
|
header.extend_from_slice(&sample_rate.to_le_bytes());
|
||||||
|
header.extend_from_slice(&(sample_rate * 4).to_le_bytes()); // Byte rate
|
||||||
|
header.extend_from_slice(&4u16.to_le_bytes()); // Block align
|
||||||
|
header.extend_from_slice(&16u16.to_le_bytes()); // Bits per sample
|
||||||
|
|
||||||
|
// data chunk header
|
||||||
|
header.extend_from_slice(b"data");
|
||||||
|
header.extend_from_slice(&0u32.to_le_bytes()); // Taille des données (à mettre à jour)
|
||||||
|
|
||||||
|
self.file.write_all(&header).await.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to write WAV header: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ferme le fichier et met à jour l'en-tête si nécessaire
|
||||||
|
async fn close(mut self) -> Result<(), AudioError> {
|
||||||
|
if matches!(self.format, AudioFileFormat::Wav) {
|
||||||
|
// Mettre à jour les tailles dans l'en-tête WAV
|
||||||
|
let data_size = (self.total_samples * 4) as u32; // 2 bytes per sample * 2 channels
|
||||||
|
let file_size = data_size + 36;
|
||||||
|
|
||||||
|
// Positionner au début et réécrire les tailles
|
||||||
|
use tokio::io::AsyncSeekExt;
|
||||||
|
self.file
|
||||||
|
.seek(std::io::SeekFrom::Start(4))
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to seek in file: {}", e))
|
||||||
|
})?;
|
||||||
|
self.file
|
||||||
|
.write_all(&file_size.to_le_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to update file size: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.file
|
||||||
|
.seek(std::io::SeekFrom::Start(40))
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to seek in file: {}", e))
|
||||||
|
})?;
|
||||||
|
self.file
|
||||||
|
.write_all(&data_size.to_le_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AudioError::ProcessingError(format!("Failed to update data size: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.file
|
||||||
|
.flush()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AudioError::ProcessingError(format!("Failed to flush file: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistiques du DiskSink
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DiskSinkStats {
|
||||||
|
pub node_id: String,
|
||||||
|
pub chunks_written: u64,
|
||||||
|
pub total_samples: u64,
|
||||||
|
pub total_duration_sec: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiskSinkStats {
|
||||||
|
pub fn new(node_id: String) -> Self {
|
||||||
|
Self {
|
||||||
|
node_id,
|
||||||
|
chunks_written: 0,
|
||||||
|
total_samples: 0,
|
||||||
|
total_duration_sec: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_chunk(&mut self, chunk: &AudioChunk) {
|
||||||
|
self.chunks_written += 1;
|
||||||
|
self.total_samples += chunk.len() as u64;
|
||||||
|
self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate() as f64;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finalize(&mut self) {
|
||||||
|
// Pourrait effectuer des calculs finaux ici
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display(&self) {
|
||||||
|
println!("\n=== DiskSink Statistics: {} ===", self.node_id);
|
||||||
|
println!("Chunks written: {}", self.chunks_written);
|
||||||
|
println!("Total samples: {}", self.total_samples);
|
||||||
|
println!("Total duration: {:.3} sec", self.total_duration_sec);
|
||||||
|
println!("============================\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_disk_sink_basic() {
|
||||||
|
let temp_dir = std::env::temp_dir().join("pmoaudio_test");
|
||||||
|
tokio::fs::create_dir_all(&temp_dir).await.unwrap();
|
||||||
|
|
||||||
|
let config = DiskSinkConfig {
|
||||||
|
output_dir: temp_dir.clone(),
|
||||||
|
filename: Some("test_output.wav".to_string()),
|
||||||
|
format: AudioFileFormat::Wav,
|
||||||
|
buffer_size: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (sink, tx) = DiskSink::new("test".to_string(), config, 10);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move { sink.run().await });
|
||||||
|
|
||||||
|
// Envoyer quelques chunks
|
||||||
|
for i in 0..5 {
|
||||||
|
let chunk = AudioChunk::from_channels_f32(
|
||||||
|
i,
|
||||||
|
vec![0.5; 1000],
|
||||||
|
vec![0.5; 1000],
|
||||||
|
48000,
|
||||||
|
BitDepth::B24,
|
||||||
|
);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
let stats = handle.await.unwrap().unwrap();
|
||||||
|
assert_eq!(stats.chunks_written, 5);
|
||||||
|
|
||||||
|
// Vérifier que le fichier existe
|
||||||
|
let output_path = temp_dir.join("test_output.wav");
|
||||||
|
assert!(output_path.exists());
|
||||||
|
|
||||||
|
// Nettoyage
|
||||||
|
tokio::fs::remove_file(output_path).await.ok();
|
||||||
|
tokio::fs::remove_dir(temp_dir).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
254
old_code/pmoaudio/nodes/dsp_node.rs
Normal file
254
old_code/pmoaudio/nodes/dsp_node.rs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
use crate::{
|
||||||
|
nodes::{AudioError, MultiSubscriberNode},
|
||||||
|
AudioChunk,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// DspNode - Applique des transformations DSP aux chunks audio
|
||||||
|
///
|
||||||
|
/// Clone les données uniquement si elles doivent être modifiées
|
||||||
|
pub struct DspNode {
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
subscribers: MultiSubscriberNode,
|
||||||
|
gain_db: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DspNode {
|
||||||
|
pub fn new(channel_size: usize, gain_db: f32) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let node = Self {
|
||||||
|
rx,
|
||||||
|
subscribers: MultiSubscriberNode::new(),
|
||||||
|
gain_db,
|
||||||
|
};
|
||||||
|
|
||||||
|
(node, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
self.subscribers.add_subscriber(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applique le gain aux chunks
|
||||||
|
pub async fn run(mut self) -> Result<(), AudioError> {
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
if self.gain_db.abs() < f32::EPSILON {
|
||||||
|
// Gain = 0 dB, pas de transformation nécessaire
|
||||||
|
self.subscribers.push(chunk).await?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gain_linear = AudioChunk::gain_linear_from_db(self.gain_db as f64) as f32;
|
||||||
|
let mut pairs = chunk.to_pairs_f32();
|
||||||
|
for frame in &mut pairs {
|
||||||
|
frame[0] *= gain_linear;
|
||||||
|
frame[1] *= gain_linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut new_chunk = AudioChunk::from_pairs_f32(
|
||||||
|
chunk.order(),
|
||||||
|
pairs,
|
||||||
|
chunk.sample_rate(),
|
||||||
|
chunk.bit_depth(),
|
||||||
|
);
|
||||||
|
if chunk.gain_db().abs() > f64::EPSILON {
|
||||||
|
new_chunk = new_chunk.set_gain_db(chunk.gain_db());
|
||||||
|
}
|
||||||
|
self.subscribers.push(new_chunk).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Met à jour le gain dynamiquement (nécessite un `Arc<RwLock<f32>>` dans une version réelle)
|
||||||
|
pub fn set_gain_db(&mut self, gain_db: f32) {
|
||||||
|
self.gain_db = gain_db;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DspNode avec filtre passe-bas simple (mock)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct LowPassDspNode {
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
subscribers: MultiSubscriberNode,
|
||||||
|
alpha: f32, // Coefficient du filtre
|
||||||
|
prev_left: f32,
|
||||||
|
prev_right: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LowPassDspNode {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn new(channel_size: usize, cutoff_ratio: f32) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
// Filtre RC simple: alpha = dt / (RC + dt)
|
||||||
|
// cutoff_ratio entre 0 (tout couper) et 1 (tout passer)
|
||||||
|
let alpha = cutoff_ratio.clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
let node = Self {
|
||||||
|
rx,
|
||||||
|
subscribers: MultiSubscriberNode::new(),
|
||||||
|
alpha,
|
||||||
|
prev_left: 0.0,
|
||||||
|
prev_right: 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
(node, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
self.subscribers.add_subscriber(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn run(mut self) -> Result<(), AudioError> {
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
let pairs = chunk.to_pairs_f32();
|
||||||
|
let mut filtered = Vec::with_capacity(pairs.len());
|
||||||
|
|
||||||
|
for sample in pairs.iter() {
|
||||||
|
self.prev_left = self.prev_left + self.alpha * (sample[0] - self.prev_left);
|
||||||
|
self.prev_right = self.prev_right + self.alpha * (sample[1] - self.prev_right);
|
||||||
|
filtered.push([self.prev_left, self.prev_right]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut new_chunk = AudioChunk::from_pairs_f32(
|
||||||
|
chunk.order(),
|
||||||
|
filtered,
|
||||||
|
chunk.sample_rate(),
|
||||||
|
chunk.bit_depth(),
|
||||||
|
);
|
||||||
|
if chunk.gain_db().abs() > f64::EPSILON {
|
||||||
|
new_chunk = new_chunk.set_gain_db(chunk.gain_db());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.subscribers.push(new_chunk).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_dsp_node_unity_gain() {
|
||||||
|
let (mut node, tx) = DspNode::new(10, 0.0);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer un chunk
|
||||||
|
let chunk = AudioChunk::from_channels_f32(
|
||||||
|
0,
|
||||||
|
vec![0.25, 0.5, 0.75],
|
||||||
|
vec![0.1, 0.2, 0.3],
|
||||||
|
48000,
|
||||||
|
BitDepth::B24,
|
||||||
|
);
|
||||||
|
tx.send(chunk.clone()).await.unwrap();
|
||||||
|
|
||||||
|
// Avec gain = 1.0, le chunk ne devrait pas être cloné
|
||||||
|
let received = out_rx.recv().await.unwrap();
|
||||||
|
assert!(Arc::ptr_eq(&chunk, &received));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_dsp_node_gain() {
|
||||||
|
let gain_db = AudioChunk::gain_db_from_linear(2.0) as f32;
|
||||||
|
let (mut node, tx) = DspNode::new(10, gain_db);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer un chunk
|
||||||
|
let chunk = AudioChunk::from_channels_f32(
|
||||||
|
0,
|
||||||
|
vec![0.25, 0.5, 0.75],
|
||||||
|
vec![0.1, 0.2, 0.3],
|
||||||
|
48000,
|
||||||
|
BitDepth::B24,
|
||||||
|
);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
|
||||||
|
// Vérifier que le gain a été appliqué
|
||||||
|
let received = out_rx.recv().await.unwrap();
|
||||||
|
let frames = received.to_pairs_f32();
|
||||||
|
const EPS: f32 = 1e-3;
|
||||||
|
assert!((frames[0][0] - 0.5).abs() < EPS);
|
||||||
|
assert!((frames[1][0] - 1.0).abs() < EPS);
|
||||||
|
assert!((frames[2][0] - 1.0).abs() < EPS); // Clamp at full scale
|
||||||
|
assert!((frames[0][1] - 0.2).abs() < EPS);
|
||||||
|
assert!((frames[1][1] - 0.4).abs() < EPS);
|
||||||
|
assert!((frames[2][1] - 0.6).abs() < EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_lowpass_dsp_node() {
|
||||||
|
let (mut node, tx) = LowPassDspNode::new(10, 0.5);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer un chunk avec un signal carré
|
||||||
|
let chunk = AudioChunk::from_channels_f32(
|
||||||
|
0,
|
||||||
|
vec![1.0, 1.0, 1.0, -1.0, -1.0, -1.0],
|
||||||
|
vec![1.0, 1.0, 1.0, -1.0, -1.0, -1.0],
|
||||||
|
48000,
|
||||||
|
BitDepth::B24,
|
||||||
|
);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
|
||||||
|
// Le filtre devrait lisser le signal
|
||||||
|
let received = out_rx.recv().await.unwrap();
|
||||||
|
let frames = received.to_pairs_f32();
|
||||||
|
assert!(frames[0][0].abs() < 1.0); // Premier échantillon lissé
|
||||||
|
assert!(frames[2][0].abs() < 1.0); // Signal ne devrait pas atteindre 1.0 immédiatement
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_dsp_node_multiple_subscribers() {
|
||||||
|
let gain_db = AudioChunk::gain_db_from_linear(0.5) as f32;
|
||||||
|
let (mut node, tx) = DspNode::new(10, gain_db);
|
||||||
|
let (out_tx1, mut out_rx1) = mpsc::channel(10);
|
||||||
|
let (out_tx2, mut out_rx2) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx1);
|
||||||
|
node.add_subscriber(out_tx2);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let chunk =
|
||||||
|
AudioChunk::from_channels_f32(0, vec![0.8, 0.4], vec![0.8, 0.4], 48000, BitDepth::B24);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
|
||||||
|
// Les deux abonnés devraient recevoir le même Arc
|
||||||
|
let received1 = out_rx1.recv().await.unwrap();
|
||||||
|
let received2 = out_rx2.recv().await.unwrap();
|
||||||
|
|
||||||
|
assert!(Arc::ptr_eq(&received1, &received2));
|
||||||
|
let frames = received1.to_pairs_f32();
|
||||||
|
const EPS: f32 = 1e-3;
|
||||||
|
assert!((frames[0][0] - 0.4).abs() < EPS); // 0.8 * 0.5
|
||||||
|
assert!((frames[1][0] - 0.2).abs() < EPS); // 0.4 * 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
398
old_code/pmoaudio/nodes/mpd_sink.rs
Normal file
398
old_code/pmoaudio/nodes/mpd_sink.rs
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
//! MpdSink - Envoie le flux audio à un démon MPD (Music Player Daemon)
|
||||||
|
//!
|
||||||
|
//! Ce module fournit un sink qui streame l'audio vers un démon MPD distant ou local.
|
||||||
|
//! Note: Cette implémentation est une version mock/skeleton. Une vraie implémentation
|
||||||
|
//! nécessiterait le protocole MPD complet et l'utilisation de bibliothèques comme `mpd`.
|
||||||
|
|
||||||
|
use crate::{nodes::AudioError, AudioChunk};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// Configuration pour le MpdSink
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MpdConfig {
|
||||||
|
/// Adresse du serveur MPD
|
||||||
|
pub host: String,
|
||||||
|
|
||||||
|
/// Port du serveur MPD (défaut: 6600)
|
||||||
|
pub port: u16,
|
||||||
|
|
||||||
|
/// Mot de passe optionnel
|
||||||
|
pub password: Option<String>,
|
||||||
|
|
||||||
|
/// Nom de l'output MPD à utiliser (optionnel)
|
||||||
|
pub output_name: Option<String>,
|
||||||
|
|
||||||
|
/// Taille du buffer
|
||||||
|
pub buffer_size: usize,
|
||||||
|
|
||||||
|
/// Format d'envoi
|
||||||
|
pub format: MpdAudioFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MpdConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
host: "localhost".to_string(),
|
||||||
|
port: 6600,
|
||||||
|
password: None,
|
||||||
|
output_name: None,
|
||||||
|
buffer_size: 50,
|
||||||
|
format: MpdAudioFormat::S16Le,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats audio supportés par MPD
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum MpdAudioFormat {
|
||||||
|
/// Signed 16-bit Little Endian
|
||||||
|
S16Le,
|
||||||
|
/// Signed 24-bit Little Endian
|
||||||
|
S24Le,
|
||||||
|
/// Signed 32-bit Little Endian
|
||||||
|
S32Le,
|
||||||
|
/// Float 32-bit
|
||||||
|
F32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MpdAudioFormat {
|
||||||
|
/// Retourne le nom du format pour le protocole MPD
|
||||||
|
pub fn as_mpd_string(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
MpdAudioFormat::S16Le => "16:16:2",
|
||||||
|
MpdAudioFormat::S24Le => "24:24:2",
|
||||||
|
MpdAudioFormat::S32Le => "32:32:2",
|
||||||
|
MpdAudioFormat::F32 => "f:32:2",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MpdSink - Streame vers un démon MPD
|
||||||
|
///
|
||||||
|
/// Ce sink se connecte à un serveur MPD et lui envoie le flux audio.
|
||||||
|
/// MPD peut ensuite router l'audio vers différents outputs (ALSA, PulseAudio, HTTP, etc.).
|
||||||
|
///
|
||||||
|
/// # Implémentation actuelle
|
||||||
|
///
|
||||||
|
/// Cette version est un mock qui simule la communication avec MPD.
|
||||||
|
/// Pour une vraie implémentation, il faudrait:
|
||||||
|
/// - Implémenter le protocole MPD (commandes textuelles sur TCP)
|
||||||
|
/// - S'authentifier si nécessaire
|
||||||
|
/// - Configurer le format audio
|
||||||
|
/// - Envoyer les données PCM via le protocole approprié
|
||||||
|
/// - Gérer les commandes de contrôle (play, pause, stop)
|
||||||
|
///
|
||||||
|
/// # Exemples
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use pmoaudio::{MpdSink, MpdConfig};
|
||||||
|
///
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() {
|
||||||
|
/// let config = MpdConfig {
|
||||||
|
/// host: "localhost".to_string(),
|
||||||
|
/// port: 6600,
|
||||||
|
/// password: None,
|
||||||
|
/// ..Default::default()
|
||||||
|
/// };
|
||||||
|
///
|
||||||
|
/// let (sink, sink_tx) = MpdSink::new("mpd1".to_string(), config, 10);
|
||||||
|
///
|
||||||
|
/// tokio::spawn(async move {
|
||||||
|
/// sink.run().await.unwrap()
|
||||||
|
/// });
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub struct MpdSink {
|
||||||
|
/// Identifiant du sink
|
||||||
|
node_id: String,
|
||||||
|
|
||||||
|
/// Channel pour recevoir les chunks audio
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
|
||||||
|
/// Configuration
|
||||||
|
config: MpdConfig,
|
||||||
|
|
||||||
|
/// État de la connexion (mock)
|
||||||
|
connected: bool,
|
||||||
|
|
||||||
|
/// Version du serveur MPD (mock)
|
||||||
|
mpd_version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MpdSink {
|
||||||
|
/// Crée un nouveau MpdSink
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `node_id` - Identifiant unique du sink
|
||||||
|
/// * `config` - Configuration MPD
|
||||||
|
/// * `channel_size` - Taille du buffer du channel
|
||||||
|
pub fn new(
|
||||||
|
node_id: String,
|
||||||
|
config: MpdConfig,
|
||||||
|
channel_size: usize,
|
||||||
|
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let sink = Self {
|
||||||
|
node_id,
|
||||||
|
rx,
|
||||||
|
config,
|
||||||
|
connected: false,
|
||||||
|
mpd_version: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
(sink, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Établit la connexion avec le serveur MPD (mock)
|
||||||
|
async fn connect(&mut self) -> Result<(), AudioError> {
|
||||||
|
println!(
|
||||||
|
"[{}] Connecting to MPD at {}:{}...",
|
||||||
|
self.node_id, self.config.host, self.config.port
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simuler une connexion TCP
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
|
||||||
|
|
||||||
|
// Dans une vraie implémentation:
|
||||||
|
// 1. Établir connexion TCP
|
||||||
|
// 2. Lire la bannière de version
|
||||||
|
// 3. S'authentifier si password fourni
|
||||||
|
// 4. Configurer le format audio
|
||||||
|
|
||||||
|
self.mpd_version = Some("0.23.0".to_string());
|
||||||
|
self.connected = true;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"[{}] Connected to MPD v{} successfully",
|
||||||
|
self.node_id,
|
||||||
|
self.mpd_version.as_ref().unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Configurer le format audio
|
||||||
|
self.configure_audio_format().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure le format audio sur MPD (mock)
|
||||||
|
async fn configure_audio_format(&self) -> Result<(), AudioError> {
|
||||||
|
println!(
|
||||||
|
"[{}] Configuring audio format: {}",
|
||||||
|
self.node_id,
|
||||||
|
self.config.format.as_mpd_string()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dans une vraie implémentation:
|
||||||
|
// Envoyer une commande MPD pour configurer le format
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envoie un chunk au serveur MPD (mock)
|
||||||
|
async fn send_chunk(&self, _chunk: &AudioChunk) -> Result<(), AudioError> {
|
||||||
|
if !self.connected {
|
||||||
|
return Err(AudioError::ProcessingError(
|
||||||
|
"Not connected to MPD".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dans une vraie implémentation:
|
||||||
|
// 1. Appliquer le gain
|
||||||
|
// 2. Convertir dans le format approprié (S16LE, etc.)
|
||||||
|
// 3. Envoyer via le protocole MPD (probablement via une commande `sendmessage` ou pipe)
|
||||||
|
|
||||||
|
// Simuler un délai d'envoi
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_micros(50)).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Déconnecte proprement du serveur MPD (mock)
|
||||||
|
async fn disconnect(&mut self) -> Result<(), AudioError> {
|
||||||
|
if self.connected {
|
||||||
|
println!("[{}] Disconnecting from MPD...", self.node_id);
|
||||||
|
|
||||||
|
// Dans une vraie implémentation:
|
||||||
|
// Envoyer la commande "close"
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
self.connected = false;
|
||||||
|
|
||||||
|
println!("[{}] Disconnected successfully", self.node_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre la boucle de traitement du MpdSink
|
||||||
|
pub async fn run(mut self) -> Result<MpdStats, AudioError> {
|
||||||
|
// Établir la connexion
|
||||||
|
self.connect().await?;
|
||||||
|
|
||||||
|
let mut stats = MpdStats::new(
|
||||||
|
self.node_id.clone(),
|
||||||
|
format!("{}:{}", self.config.host, self.config.port),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Boucle principale
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
// Appliquer le gain si nécessaire
|
||||||
|
let chunk_to_send = if chunk.gain_db().abs() > f64::EPSILON {
|
||||||
|
Arc::clone(&chunk).apply_gain()
|
||||||
|
} else {
|
||||||
|
Arc::clone(&chunk)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Envoyer au serveur MPD
|
||||||
|
self.send_chunk(&chunk_to_send).await?;
|
||||||
|
|
||||||
|
stats.record_chunk(&chunk_to_send);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déconnexion propre
|
||||||
|
self.disconnect().await?;
|
||||||
|
|
||||||
|
stats.finalize();
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne un handle pour contrôler le sink (mock)
|
||||||
|
pub fn get_handle(&self) -> MpdHandle {
|
||||||
|
MpdHandle {
|
||||||
|
node_id: self.node_id.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle pour contrôler le MpdSink
|
||||||
|
///
|
||||||
|
/// Permet d'envoyer des commandes de contrôle au serveur MPD
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MpdHandle {
|
||||||
|
node_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MpdHandle {
|
||||||
|
/// Commande play (mock)
|
||||||
|
pub async fn play(&self) -> Result<(), AudioError> {
|
||||||
|
println!("[{}] MPD command: play", self.node_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commande pause (mock)
|
||||||
|
pub async fn pause(&self) -> Result<(), AudioError> {
|
||||||
|
println!("[{}] MPD command: pause", self.node_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commande stop (mock)
|
||||||
|
pub async fn stop(&self) -> Result<(), AudioError> {
|
||||||
|
println!("[{}] MPD command: stop", self.node_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change le volume MPD (0-100) (mock)
|
||||||
|
pub async fn set_volume(&self, volume: u8) -> Result<(), AudioError> {
|
||||||
|
let clamped = volume.min(100);
|
||||||
|
println!("[{}] MPD command: setvol {}", self.node_id, clamped);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistiques du MpdSink
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MpdStats {
|
||||||
|
pub node_id: String,
|
||||||
|
pub server_address: String,
|
||||||
|
pub chunks_sent: u64,
|
||||||
|
pub total_samples: u64,
|
||||||
|
pub total_duration_sec: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MpdStats {
|
||||||
|
pub fn new(node_id: String, server_address: String) -> Self {
|
||||||
|
Self {
|
||||||
|
node_id,
|
||||||
|
server_address,
|
||||||
|
chunks_sent: 0,
|
||||||
|
total_samples: 0,
|
||||||
|
total_duration_sec: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_chunk(&mut self, chunk: &AudioChunk) {
|
||||||
|
self.chunks_sent += 1;
|
||||||
|
self.total_samples += chunk.len() as u64;
|
||||||
|
self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate() as f64;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finalize(&mut self) {
|
||||||
|
// Calculs finaux si nécessaire
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display(&self) {
|
||||||
|
println!("\n=== MPD Sink Statistics: {} ===", self.node_id);
|
||||||
|
println!("Server: {}", self.server_address);
|
||||||
|
println!("Chunks sent: {}", self.chunks_sent);
|
||||||
|
println!("Total samples: {}", self.total_samples);
|
||||||
|
println!("Total duration: {:.3} sec", self.total_duration_sec);
|
||||||
|
println!("===============================\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mpd_sink_basic() {
|
||||||
|
let config = MpdConfig {
|
||||||
|
host: "localhost".to_string(),
|
||||||
|
port: 6600,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (sink, tx) = MpdSink::new("test".to_string(), config, 10);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move { sink.run().await });
|
||||||
|
|
||||||
|
// Envoyer quelques chunks
|
||||||
|
for i in 0..5 {
|
||||||
|
let chunk = AudioChunk::from_channels_f32(
|
||||||
|
i,
|
||||||
|
vec![0.5; 1000],
|
||||||
|
vec![0.5; 1000],
|
||||||
|
48000,
|
||||||
|
BitDepth::B24,
|
||||||
|
);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
let stats = handle.await.unwrap().unwrap();
|
||||||
|
assert_eq!(stats.chunks_sent, 5);
|
||||||
|
assert_eq!(stats.server_address, "localhost:6600");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_mpd_handle() {
|
||||||
|
let config = MpdConfig::default();
|
||||||
|
let (sink, _tx) = MpdSink::new("test".to_string(), config, 10);
|
||||||
|
|
||||||
|
let handle = sink.get_handle();
|
||||||
|
|
||||||
|
// Tester les commandes (mock)
|
||||||
|
handle.play().await.unwrap();
|
||||||
|
handle.pause().await.unwrap();
|
||||||
|
handle.set_volume(75).await.unwrap();
|
||||||
|
handle.stop().await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
212
old_code/pmoaudio/nodes/sink_node.rs
Normal file
212
old_code/pmoaudio/nodes/sink_node.rs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
use crate::{nodes::AudioError, AudioChunk};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// SinkNode - Node terminal qui consomme les chunks audio
|
||||||
|
///
|
||||||
|
/// Version mock pour tests et logging
|
||||||
|
pub struct SinkNode {
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SinkNode {
|
||||||
|
pub fn new(name: String, channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let node = Self { rx, name };
|
||||||
|
|
||||||
|
(node, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version silencieuse - consomme les chunks sans action
|
||||||
|
pub async fn run_silent(mut self) -> Result<(), AudioError> {
|
||||||
|
while let Some(_chunk) = self.rx.recv().await {
|
||||||
|
// Ne rien faire, juste consommer
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version avec logging
|
||||||
|
pub async fn run_with_logging(mut self) -> Result<(), AudioError> {
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
println!(
|
||||||
|
"[{}] Received chunk #{} - {} samples @ {} Hz",
|
||||||
|
self.name,
|
||||||
|
chunk.order(),
|
||||||
|
chunk.len(),
|
||||||
|
chunk.sample_rate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version avec statistiques
|
||||||
|
pub async fn run_with_stats(mut self) -> Result<SinkStats, AudioError> {
|
||||||
|
let mut stats = SinkStats::new(self.name.clone());
|
||||||
|
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
stats.process_chunk(&chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version mock pour écriture dans un fichier (simule l'écriture)
|
||||||
|
pub async fn run_mock_file_writer(mut self) -> Result<usize, AudioError> {
|
||||||
|
let mut total_samples = 0;
|
||||||
|
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
total_samples += chunk.len();
|
||||||
|
// Simuler l'écriture avec un petit délai
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_micros(10)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(total_samples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statistiques collectées par un SinkNode
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SinkStats {
|
||||||
|
pub name: String,
|
||||||
|
pub chunks_received: u64,
|
||||||
|
pub total_samples: u64,
|
||||||
|
pub total_duration_sec: f64,
|
||||||
|
pub peak_left: f32,
|
||||||
|
pub peak_right: f32,
|
||||||
|
pub rms_left: f64,
|
||||||
|
pub rms_right: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SinkStats {
|
||||||
|
pub fn new(name: String) -> Self {
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
chunks_received: 0,
|
||||||
|
total_samples: 0,
|
||||||
|
total_duration_sec: 0.0,
|
||||||
|
peak_left: 0.0,
|
||||||
|
peak_right: 0.0,
|
||||||
|
rms_left: 0.0,
|
||||||
|
rms_right: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_chunk(&mut self, chunk: &AudioChunk) {
|
||||||
|
self.chunks_received += 1;
|
||||||
|
let len = chunk.len() as u64;
|
||||||
|
self.total_samples += len;
|
||||||
|
self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate() as f64;
|
||||||
|
|
||||||
|
let inv_max = 1.0f32 / chunk.bit_depth().max_value();
|
||||||
|
let mut peak_left = self.peak_left;
|
||||||
|
let mut peak_right = self.peak_right;
|
||||||
|
let mut sum_squares_left = 0.0f64;
|
||||||
|
let mut sum_squares_right = 0.0f64;
|
||||||
|
|
||||||
|
for frame in chunk.frames() {
|
||||||
|
let left = frame[0] as f32 * inv_max;
|
||||||
|
let right = frame[1] as f32 * inv_max;
|
||||||
|
let left_abs = left.abs();
|
||||||
|
let right_abs = right.abs();
|
||||||
|
if left_abs > peak_left {
|
||||||
|
peak_left = left_abs;
|
||||||
|
}
|
||||||
|
if right_abs > peak_right {
|
||||||
|
peak_right = right_abs;
|
||||||
|
}
|
||||||
|
let l64 = left as f64;
|
||||||
|
let r64 = right as f64;
|
||||||
|
sum_squares_left += l64 * l64;
|
||||||
|
sum_squares_right += r64 * r64;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.peak_left = peak_left;
|
||||||
|
self.peak_right = peak_right;
|
||||||
|
|
||||||
|
let prev_samples = self.total_samples - len;
|
||||||
|
self.rms_left = ((self.rms_left.powi(2) * prev_samples as f64 + sum_squares_left)
|
||||||
|
/ self.total_samples as f64)
|
||||||
|
.sqrt();
|
||||||
|
self.rms_right = ((self.rms_right.powi(2) * prev_samples as f64 + sum_squares_right)
|
||||||
|
/ self.total_samples as f64)
|
||||||
|
.sqrt();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display(&self) {
|
||||||
|
println!("\n=== Sink Statistics: {} ===", self.name);
|
||||||
|
println!("Chunks received: {}", self.chunks_received);
|
||||||
|
println!("Total samples: {}", self.total_samples);
|
||||||
|
println!("Total duration: {:.3} sec", self.total_duration_sec);
|
||||||
|
println!("Peak L/R: {:.3} / {:.3}", self.peak_left, self.peak_right);
|
||||||
|
println!("RMS L/R: {:.3} / {:.3}", self.rms_left, self.rms_right);
|
||||||
|
println!("========================\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
const BD: BitDepth = BitDepth::B24;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sink_node_silent() {
|
||||||
|
let (node, tx) = SinkNode::new("test".to_string(), 10);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move { node.run_silent().await });
|
||||||
|
|
||||||
|
// Envoyer quelques chunks
|
||||||
|
for i in 0..3 {
|
||||||
|
let chunk = AudioChunk::from_channels_f32(i, vec![0.0; 100], vec![0.0; 100], 48000, BD);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
handle.await.unwrap().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sink_node_stats() {
|
||||||
|
let (node, tx) = SinkNode::new("test".to_string(), 10);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move { node.run_with_stats().await });
|
||||||
|
|
||||||
|
// Envoyer des chunks avec signal connu
|
||||||
|
for i in 0..3 {
|
||||||
|
let chunk =
|
||||||
|
AudioChunk::from_channels_f32(i, vec![1.0; 1000], vec![0.5; 1000], 48000, BD);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
let stats = handle.await.unwrap().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(stats.chunks_received, 3);
|
||||||
|
assert_eq!(stats.total_samples, 3000);
|
||||||
|
assert!((stats.peak_left - 1.0).abs() < 1e-6);
|
||||||
|
assert!((stats.peak_right - 0.5).abs() < 1e-6);
|
||||||
|
assert!((stats.rms_left - 1.0).abs() < 0.001);
|
||||||
|
assert!((stats.rms_right - 0.5).abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sink_node_file_writer() {
|
||||||
|
let (node, tx) = SinkNode::new("writer".to_string(), 10);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move { node.run_mock_file_writer().await });
|
||||||
|
|
||||||
|
// Envoyer des chunks
|
||||||
|
for i in 0..5 {
|
||||||
|
let chunk = AudioChunk::from_channels_f32(i, vec![0.0; 100], vec![0.0; 100], 48000, BD);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
let total_samples = handle.await.unwrap().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(total_samples, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
old_code/pmoaudio/nodes/source_node.rs
Normal file
171
old_code/pmoaudio/nodes/source_node.rs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
use crate::{
|
||||||
|
nodes::{AudioError, MultiSubscriberNode},
|
||||||
|
AudioChunk, BitDepth,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// SourceNode - Génère ou lit des chunks audio depuis une source
|
||||||
|
///
|
||||||
|
/// Ce node est la source du pipeline. Version mock pour tests.
|
||||||
|
pub struct SourceNode {
|
||||||
|
subscribers: MultiSubscriberNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BIT_DEPTH: BitDepth = BitDepth::B24;
|
||||||
|
|
||||||
|
impl SourceNode {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
subscribers: MultiSubscriberNode::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
self.subscribers.add_subscriber(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Génère un chunk de test avec une forme d'onde sinusoïdale
|
||||||
|
pub fn generate_test_chunk(
|
||||||
|
order: u64,
|
||||||
|
size: usize,
|
||||||
|
sample_rate: u32,
|
||||||
|
frequency: f32,
|
||||||
|
) -> Arc<AudioChunk> {
|
||||||
|
let mut left = Vec::with_capacity(size);
|
||||||
|
let mut right = Vec::with_capacity(size);
|
||||||
|
|
||||||
|
for i in 0..size {
|
||||||
|
let t = (order * size as u64 + i as u64) as f32 / sample_rate as f32;
|
||||||
|
let sample = (2.0 * std::f32::consts::PI * frequency * t).sin();
|
||||||
|
left.push(sample);
|
||||||
|
right.push(sample * 0.8); // Légèrement différent pour la stéréo
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioChunk::from_channels_f32(order, left, right, sample_rate, DEFAULT_BIT_DEPTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Génère et envoie des chunks de test
|
||||||
|
pub async fn generate_chunks(
|
||||||
|
&self,
|
||||||
|
count: u64,
|
||||||
|
chunk_size: usize,
|
||||||
|
sample_rate: u32,
|
||||||
|
frequency: f32,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
for i in 0..count {
|
||||||
|
let chunk = Self::generate_test_chunk(i, chunk_size, sample_rate, frequency);
|
||||||
|
self.subscribers.push(chunk).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Génère des chunks silencieux
|
||||||
|
pub async fn generate_silence(
|
||||||
|
&self,
|
||||||
|
count: u64,
|
||||||
|
chunk_size: usize,
|
||||||
|
sample_rate: u32,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
for i in 0..count {
|
||||||
|
let stereo = vec![[0i32; 2]; chunk_size];
|
||||||
|
let chunk = AudioChunk::new(i, stereo, sample_rate, DEFAULT_BIT_DEPTH);
|
||||||
|
self.subscribers.push(chunk).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version streaming : génère des chunks continuellement avec délai
|
||||||
|
pub async fn stream_chunks(
|
||||||
|
&self,
|
||||||
|
chunk_size: usize,
|
||||||
|
sample_rate: u32,
|
||||||
|
frequency: f32,
|
||||||
|
duration_ms: u64,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
let chunk_duration_ms = (chunk_size as f64 / sample_rate as f64 * 1000.0) as u64;
|
||||||
|
let mut order = 0u64;
|
||||||
|
|
||||||
|
let start = tokio::time::Instant::now();
|
||||||
|
let duration = tokio::time::Duration::from_millis(duration_ms);
|
||||||
|
|
||||||
|
while start.elapsed() < duration {
|
||||||
|
let chunk = Self::generate_test_chunk(order, chunk_size, sample_rate, frequency);
|
||||||
|
self.subscribers.push(chunk).await?;
|
||||||
|
|
||||||
|
order += 1;
|
||||||
|
|
||||||
|
// Attendre pour simuler le timing réel
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(chunk_duration_ms)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SourceNode {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_source_node_generation() {
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
let (tx, mut rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
source.add_subscriber(tx);
|
||||||
|
|
||||||
|
// Générer 3 chunks
|
||||||
|
source.generate_chunks(3, 100, 48000, 440.0).await.unwrap();
|
||||||
|
|
||||||
|
// Vérifier la réception
|
||||||
|
for i in 0..3 {
|
||||||
|
let chunk = rx.recv().await.unwrap();
|
||||||
|
assert_eq!(chunk.order(), i);
|
||||||
|
assert_eq!(chunk.len(), 100);
|
||||||
|
assert_eq!(chunk.sample_rate(), 48000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sine_wave_generation() {
|
||||||
|
let chunk = SourceNode::generate_test_chunk(0, 48000, 48000, 440.0);
|
||||||
|
|
||||||
|
// Vérifier qu'on a bien une sinusoïde
|
||||||
|
// À 440 Hz avec 48000 samples/s, on devrait avoir 440 cycles
|
||||||
|
let pairs = chunk.to_pairs_f32();
|
||||||
|
let left: Vec<f32> = pairs.iter().map(|frame| frame[0]).collect();
|
||||||
|
|
||||||
|
// Trouver les passages par zéro
|
||||||
|
let mut zero_crossings = 0;
|
||||||
|
for i in 1..left.len() {
|
||||||
|
if (left[i - 1] < 0.0 && left[i] >= 0.0) || (left[i - 1] >= 0.0 && left[i] < 0.0) {
|
||||||
|
zero_crossings += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 440 cycles = 880 passages par zéro (approximativement)
|
||||||
|
assert!(zero_crossings > 850 && zero_crossings < 910);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_source_node_silence() {
|
||||||
|
let mut source = SourceNode::new();
|
||||||
|
let (tx, mut rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
source.add_subscriber(tx);
|
||||||
|
|
||||||
|
source.generate_silence(2, 100, 48000).await.unwrap();
|
||||||
|
|
||||||
|
for _ in 0..2 {
|
||||||
|
let chunk = rx.recv().await.unwrap();
|
||||||
|
assert!(chunk.frames().iter().all(|frame| frame[0] == 0));
|
||||||
|
assert!(chunk.frames().iter().all(|frame| frame[1] == 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
291
old_code/pmoaudio/nodes/timer_node.rs
Normal file
291
old_code/pmoaudio/nodes/timer_node.rs
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
use crate::{
|
||||||
|
nodes::{AudioError, MultiSubscriberNode},
|
||||||
|
AudioChunk,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{mpsc, RwLock};
|
||||||
|
|
||||||
|
/// TimerNode - Node passthrough qui calcule la position temporelle
|
||||||
|
///
|
||||||
|
/// Ce node ne modifie pas les données audio, il les passe directement
|
||||||
|
/// aux abonnés tout en maintenant un compteur de samples pour calculer
|
||||||
|
/// la position en secondes.
|
||||||
|
///
|
||||||
|
/// # Fonctionnement
|
||||||
|
///
|
||||||
|
/// Pour chaque chunk reçu:
|
||||||
|
/// 1. Incrémente `elapsed_samples += chunk.len()`
|
||||||
|
/// 2. Calcule `position_sec = elapsed_samples / sample_rate`
|
||||||
|
/// 3. Push le chunk (sans modification) vers les abonnés
|
||||||
|
///
|
||||||
|
/// # Utilisation
|
||||||
|
///
|
||||||
|
/// Le TimerNode fournit un [`TimerHandle`] qui permet de lire la position
|
||||||
|
/// depuis d'autres threads/tasks sans bloquer le pipeline.
|
||||||
|
///
|
||||||
|
/// # Exemples
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use pmoaudio::TimerNode;
|
||||||
|
///
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() {
|
||||||
|
/// let (mut timer, timer_tx) = TimerNode::new(10);
|
||||||
|
/// let handle = timer.get_position_handle();
|
||||||
|
///
|
||||||
|
/// tokio::spawn(async move {
|
||||||
|
/// timer.run().await.unwrap();
|
||||||
|
/// });
|
||||||
|
///
|
||||||
|
/// // Lire la position depuis un autre thread
|
||||||
|
/// let position = handle.position_sec().await;
|
||||||
|
/// println!("Position: {:.2} sec", position);
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub struct TimerNode {
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
subscribers: MultiSubscriberNode,
|
||||||
|
elapsed_samples: Arc<RwLock<u64>>,
|
||||||
|
current_sample_rate: Arc<RwLock<u32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimerNode {
|
||||||
|
/// Crée un nouveau TimerNode
|
||||||
|
pub fn new(channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let node = Self {
|
||||||
|
rx,
|
||||||
|
subscribers: MultiSubscriberNode::new(),
|
||||||
|
elapsed_samples: Arc::new(RwLock::new(0)),
|
||||||
|
current_sample_rate: Arc::new(RwLock::new(48000)), // Default
|
||||||
|
};
|
||||||
|
|
||||||
|
(node, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un abonné
|
||||||
|
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
self.subscribers.add_subscriber(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne la position actuelle en secondes
|
||||||
|
pub async fn position_sec(&self) -> f64 {
|
||||||
|
let elapsed = *self.elapsed_samples.read().await;
|
||||||
|
let sample_rate = *self.current_sample_rate.read().await;
|
||||||
|
elapsed as f64 / sample_rate as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le nombre total d'échantillons écoulés
|
||||||
|
pub async fn elapsed_samples(&self) -> u64 {
|
||||||
|
*self.elapsed_samples.read().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset le compteur
|
||||||
|
pub async fn reset(&self) {
|
||||||
|
let mut elapsed = self.elapsed_samples.write().await;
|
||||||
|
*elapsed = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre la boucle de traitement du TimerNode
|
||||||
|
pub async fn run(mut self) -> Result<(), AudioError> {
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
// Mettre à jour le sample rate si nécessaire
|
||||||
|
{
|
||||||
|
let mut sr = self.current_sample_rate.write().await;
|
||||||
|
if *sr != chunk.sample_rate() {
|
||||||
|
*sr = chunk.sample_rate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incrémenter le compteur d'échantillons
|
||||||
|
{
|
||||||
|
let mut elapsed = self.elapsed_samples.write().await;
|
||||||
|
*elapsed += chunk.len() as u64;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push immédiatement le même chunk vers les abonnés (passthrough)
|
||||||
|
self.subscribers.push(chunk).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Version non-bloquante avec try_push
|
||||||
|
pub async fn run_nonblocking(mut self) -> Result<(), AudioError> {
|
||||||
|
while let Some(chunk) = self.rx.recv().await {
|
||||||
|
{
|
||||||
|
let mut sr = self.current_sample_rate.write().await;
|
||||||
|
if *sr != chunk.sample_rate() {
|
||||||
|
*sr = chunk.sample_rate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut elapsed = self.elapsed_samples.write().await;
|
||||||
|
*elapsed += chunk.len() as u64;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.subscribers.try_push(chunk).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne un handle pour lire la position depuis d'autres threads
|
||||||
|
pub fn get_position_handle(&self) -> TimerHandle {
|
||||||
|
TimerHandle {
|
||||||
|
elapsed_samples: self.elapsed_samples.clone(),
|
||||||
|
current_sample_rate: self.current_sample_rate.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle pour lire la position du TimerNode depuis d'autres threads
|
||||||
|
///
|
||||||
|
/// Ce handle peut être cloné et utilisé depuis plusieurs threads/tasks
|
||||||
|
/// pour monitorer la position de lecture sans bloquer le pipeline.
|
||||||
|
///
|
||||||
|
/// # Exemples
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use pmoaudio::TimerNode;
|
||||||
|
///
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() {
|
||||||
|
/// let (mut timer, _tx) = TimerNode::new(10);
|
||||||
|
/// let handle = timer.get_position_handle();
|
||||||
|
/// let handle_clone = handle.clone();
|
||||||
|
///
|
||||||
|
/// // Utiliser depuis plusieurs tasks
|
||||||
|
/// tokio::spawn(async move {
|
||||||
|
/// loop {
|
||||||
|
/// let pos = handle_clone.position_sec().await;
|
||||||
|
/// println!("Position: {:.2}s", pos);
|
||||||
|
/// tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||||
|
/// }
|
||||||
|
/// });
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TimerHandle {
|
||||||
|
elapsed_samples: Arc<RwLock<u64>>,
|
||||||
|
current_sample_rate: Arc<RwLock<u32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimerHandle {
|
||||||
|
/// Retourne la position actuelle en secondes
|
||||||
|
pub async fn position_sec(&self) -> f64 {
|
||||||
|
let elapsed = *self.elapsed_samples.read().await;
|
||||||
|
let sample_rate = *self.current_sample_rate.read().await;
|
||||||
|
elapsed as f64 / sample_rate as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le nombre total d'échantillons écoulés
|
||||||
|
pub async fn elapsed_samples(&self) -> u64 {
|
||||||
|
*self.elapsed_samples.read().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_timer_node_position_calculation() {
|
||||||
|
let (mut node, tx) = TimerNode::new(10);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
let handle = node.get_position_handle();
|
||||||
|
|
||||||
|
// Spawn le node
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer 3 chunks de 1000 samples à 48000 Hz
|
||||||
|
for i in 0..3 {
|
||||||
|
let stereo = vec![[0i32; 2]; 1000];
|
||||||
|
let chunk = AudioChunk::new(i, stereo, 48000, BitDepth::B24);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attendre que les chunks soient traités
|
||||||
|
for _ in 0..3 {
|
||||||
|
out_rx.recv().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier la position
|
||||||
|
let position = handle.position_sec().await;
|
||||||
|
let expected = 3000.0 / 48000.0; // 3 chunks * 1000 samples / 48000 Hz
|
||||||
|
assert!((position - expected).abs() < 0.0001);
|
||||||
|
|
||||||
|
let elapsed = handle.elapsed_samples().await;
|
||||||
|
assert_eq!(elapsed, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_timer_node_passthrough() {
|
||||||
|
let (mut node, tx) = TimerNode::new(10);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Envoyer un chunk
|
||||||
|
let chunk = AudioChunk::from_channels_i32(
|
||||||
|
42,
|
||||||
|
vec![100, 200, 300],
|
||||||
|
vec![400, 500, 600],
|
||||||
|
48000,
|
||||||
|
BitDepth::B24,
|
||||||
|
);
|
||||||
|
tx.send(chunk.clone()).await.unwrap();
|
||||||
|
|
||||||
|
// Recevoir le chunk
|
||||||
|
let received = out_rx.recv().await.unwrap();
|
||||||
|
|
||||||
|
// Vérifier que c'est le même Arc (pas de clone des données)
|
||||||
|
assert!(Arc::ptr_eq(&chunk, &received));
|
||||||
|
assert_eq!(received.order(), 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_timer_node_sample_rate_change() {
|
||||||
|
let (mut node, tx) = TimerNode::new(10);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
let handle = node.get_position_handle();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
node.run().await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chunk à 48000 Hz
|
||||||
|
let chunk1 = AudioChunk::new(0, vec![[0i32; 2]; 48000], 48000, BitDepth::B24);
|
||||||
|
tx.send(chunk1).await.unwrap();
|
||||||
|
out_rx.recv().await.unwrap();
|
||||||
|
|
||||||
|
// Après 48000 samples à 48000 Hz = 1 seconde
|
||||||
|
let pos1 = handle.position_sec().await;
|
||||||
|
assert!((pos1 - 1.0).abs() < 0.0001);
|
||||||
|
|
||||||
|
// Chunk à 96000 Hz
|
||||||
|
let chunk2 = AudioChunk::new(1, vec![[0i32; 2]; 96000], 96000, BitDepth::B24);
|
||||||
|
tx.send(chunk2).await.unwrap();
|
||||||
|
out_rx.recv().await.unwrap();
|
||||||
|
|
||||||
|
// Position calculée avec le nouveau sample rate
|
||||||
|
let pos2 = handle.position_sec().await;
|
||||||
|
let expected = (48000.0 + 96000.0) / 96000.0;
|
||||||
|
assert!((pos2 - expected).abs() < 0.0001);
|
||||||
|
}
|
||||||
|
}
|
||||||
363
old_code/pmoaudio/nodes/volume_node.rs
Normal file
363
old_code/pmoaudio/nodes/volume_node.rs
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
//! Volume nodes - Contrôle du volume audio
|
||||||
|
//!
|
||||||
|
//! Ce module fournit des nodes pour ajuster le volume du flux audio,
|
||||||
|
//! avec support du volume master/secondaire et notification des changements.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
events::{EventPublisher, VolumeChangeEvent},
|
||||||
|
nodes::{AudioError, MultiSubscriberNode},
|
||||||
|
AudioChunk,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{mpsc, RwLock};
|
||||||
|
|
||||||
|
/// VolumeNode - Applique un gain au flux audio (contrôle software)
|
||||||
|
///
|
||||||
|
/// Ce node modifie le champ `gain` de chaque `AudioChunk` qui le traverse.
|
||||||
|
/// Le gain est multiplié avec le gain existant du chunk, permettant ainsi
|
||||||
|
/// une chaîne de contrôles de volume.
|
||||||
|
///
|
||||||
|
/// # Caractéristiques
|
||||||
|
///
|
||||||
|
/// - Thread-safe : le volume peut être modifié pendant l'exécution via `set_volume`
|
||||||
|
/// - Notification : émet des événements `VolumeChangeEvent` lors des changements
|
||||||
|
/// - Master/Slave : peut s'abonner à un volume master pour synchronisation
|
||||||
|
///
|
||||||
|
/// # Exemples
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use pmoaudio::VolumeNode;
|
||||||
|
///
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() {
|
||||||
|
/// let (volume_node, volume_tx) = VolumeNode::new("Room 1".to_string(), 0.8, 10);
|
||||||
|
///
|
||||||
|
/// // Modifier le volume pendant l'exécution
|
||||||
|
/// let handle = volume_node.get_handle();
|
||||||
|
/// tokio::spawn(async move {
|
||||||
|
/// tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||||
|
/// handle.set_volume(0.5).await;
|
||||||
|
/// });
|
||||||
|
///
|
||||||
|
/// tokio::spawn(async move { volume_node.run().await.unwrap() });
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub struct VolumeNode {
|
||||||
|
/// Channel pour recevoir les chunks audio
|
||||||
|
rx: mpsc::Receiver<Arc<AudioChunk>>,
|
||||||
|
|
||||||
|
/// Subscribers pour les chunks modifiés
|
||||||
|
subscribers: MultiSubscriberNode,
|
||||||
|
|
||||||
|
/// Volume courant (partagé via RwLock pour lecture/écriture thread-safe)
|
||||||
|
volume: Arc<RwLock<f32>>,
|
||||||
|
|
||||||
|
/// Publisher pour les événements de changement de volume
|
||||||
|
volume_publisher: EventPublisher<VolumeChangeEvent>,
|
||||||
|
|
||||||
|
/// Identifiant unique du node (pour traçabilité)
|
||||||
|
node_id: String,
|
||||||
|
|
||||||
|
/// Receiver pour les événements de volume master (optionnel)
|
||||||
|
master_volume_rx: Option<mpsc::Receiver<VolumeChangeEvent>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolumeNode {
|
||||||
|
/// Crée un nouveau VolumeNode
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `node_id` - Identifiant unique du node
|
||||||
|
/// * `initial_volume` - Volume initial (0.0 à 1.0)
|
||||||
|
/// * `channel_size` - Taille du buffer du channel
|
||||||
|
pub fn new(
|
||||||
|
node_id: String,
|
||||||
|
initial_volume: f32,
|
||||||
|
channel_size: usize,
|
||||||
|
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (tx, rx) = mpsc::channel(channel_size);
|
||||||
|
|
||||||
|
let node = Self {
|
||||||
|
rx,
|
||||||
|
subscribers: MultiSubscriberNode::new(),
|
||||||
|
volume: Arc::new(RwLock::new(initial_volume)),
|
||||||
|
volume_publisher: EventPublisher::new(),
|
||||||
|
node_id,
|
||||||
|
master_volume_rx: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
(node, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un subscriber pour recevoir les chunks audio modifiés
|
||||||
|
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
self.subscribers.add_subscriber(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un subscriber pour les événements de changement de volume
|
||||||
|
pub fn subscribe_volume_events(&mut self, tx: mpsc::Sender<VolumeChangeEvent>) {
|
||||||
|
self.volume_publisher.subscribe(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure ce node pour écouter un volume master
|
||||||
|
///
|
||||||
|
/// Le node appliquera à la fois son volume local ET le volume master reçu.
|
||||||
|
pub fn set_master_volume_source(&mut self, rx: mpsc::Receiver<VolumeChangeEvent>) {
|
||||||
|
self.master_volume_rx = Some(rx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne un handle pour contrôler le volume depuis un autre contexte
|
||||||
|
pub fn get_handle(&self) -> VolumeHandle {
|
||||||
|
VolumeHandle {
|
||||||
|
volume: self.volume.clone(),
|
||||||
|
node_id: self.node_id.clone(),
|
||||||
|
publisher: Arc::new(RwLock::new(self.volume_publisher.clone())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre la boucle de traitement du VolumeNode
|
||||||
|
pub async fn run(mut self) -> Result<(), AudioError> {
|
||||||
|
let mut master_volume = 1.0f32;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
// Recevoir les chunks audio
|
||||||
|
chunk_opt = self.rx.recv() => {
|
||||||
|
match chunk_opt {
|
||||||
|
Some(chunk) => {
|
||||||
|
let local_volume = *self.volume.read().await;
|
||||||
|
let total_volume = (local_volume * master_volume).max(0.0);
|
||||||
|
|
||||||
|
// Créer un nouveau chunk avec le gain modifié (conversion vers dB)
|
||||||
|
let modified_chunk =
|
||||||
|
chunk.with_modified_gain_linear(total_volume as f64);
|
||||||
|
|
||||||
|
// Envoyer aux subscribers
|
||||||
|
self.subscribers.push(modified_chunk).await?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Channel fermé, terminer
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recevoir les mises à jour du volume master (si configuré)
|
||||||
|
master_event_opt = async {
|
||||||
|
if let Some(ref mut rx) = self.master_volume_rx {
|
||||||
|
rx.recv().await
|
||||||
|
} else {
|
||||||
|
// Bloquer indéfiniment si pas de master
|
||||||
|
std::future::pending().await
|
||||||
|
}
|
||||||
|
} => {
|
||||||
|
if let Some(event) = master_event_opt {
|
||||||
|
master_volume = event.volume;
|
||||||
|
|
||||||
|
// Optionnel : re-publier l'événement combiné
|
||||||
|
let local_volume = *self.volume.read().await;
|
||||||
|
let combined_event = VolumeChangeEvent {
|
||||||
|
volume: local_volume * master_volume,
|
||||||
|
source_node_id: self.node_id.clone(),
|
||||||
|
};
|
||||||
|
self.volume_publisher.publish(combined_event).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle pour contrôler un VolumeNode depuis un autre contexte
|
||||||
|
///
|
||||||
|
/// Ce handle permet de modifier le volume et de notifier les subscribers
|
||||||
|
/// sans avoir accès direct au node.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct VolumeHandle {
|
||||||
|
volume: Arc<RwLock<f32>>,
|
||||||
|
node_id: String,
|
||||||
|
publisher: Arc<RwLock<EventPublisher<VolumeChangeEvent>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VolumeHandle {
|
||||||
|
/// Modifie le volume
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `new_volume` - Nouveau volume (0.0 à 1.0)
|
||||||
|
pub async fn set_volume(&self, new_volume: f32) {
|
||||||
|
let clamped = new_volume.clamp(0.0, 1.0);
|
||||||
|
*self.volume.write().await = clamped;
|
||||||
|
|
||||||
|
// Publier l'événement de changement
|
||||||
|
let event = VolumeChangeEvent {
|
||||||
|
volume: clamped,
|
||||||
|
source_node_id: self.node_id.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
self.publisher.read().await.publish(event).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtient le volume courant
|
||||||
|
pub async fn get_volume(&self) -> f32 {
|
||||||
|
*self.volume.read().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Augmente le volume de manière relative
|
||||||
|
pub async fn adjust_volume(&self, delta: f32) {
|
||||||
|
let current = *self.volume.read().await;
|
||||||
|
self.set_volume(current + delta).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HardwareVolumeNode - Contrôle matériel du volume
|
||||||
|
///
|
||||||
|
/// Ce node simule un contrôle hardware du volume. Dans une implémentation réelle,
|
||||||
|
/// il communiquerait avec le driver audio pour ajuster le volume matériel.
|
||||||
|
///
|
||||||
|
/// Pour cette version, il agit de manière similaire à `VolumeNode` mais pourrait
|
||||||
|
/// être étendu pour utiliser des APIs système spécifiques.
|
||||||
|
pub struct HardwareVolumeNode {
|
||||||
|
inner: VolumeNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HardwareVolumeNode {
|
||||||
|
/// Crée un nouveau HardwareVolumeNode
|
||||||
|
pub fn new(
|
||||||
|
node_id: String,
|
||||||
|
initial_volume: f32,
|
||||||
|
channel_size: usize,
|
||||||
|
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
let (inner, tx) = VolumeNode::new(node_id, initial_volume, channel_size);
|
||||||
|
|
||||||
|
(Self { inner }, tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un subscriber
|
||||||
|
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
|
||||||
|
self.inner.add_subscriber(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Obtient un handle pour contrôler le volume
|
||||||
|
pub fn get_handle(&self) -> VolumeHandle {
|
||||||
|
self.inner.get_handle()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Démarre la boucle de traitement
|
||||||
|
pub async fn run(self) -> Result<(), AudioError> {
|
||||||
|
// Dans une vraie implémentation, on communiquerait avec le hardware ici
|
||||||
|
// Pour l'instant, délègue au VolumeNode standard
|
||||||
|
self.inner.run().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::BitDepth;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_volume_node_basic() {
|
||||||
|
let (mut node, tx) = VolumeNode::new("test".to_string(), 0.5, 10);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move { node.run().await });
|
||||||
|
|
||||||
|
// Envoyer un chunk avec gain 1.0
|
||||||
|
let chunk =
|
||||||
|
AudioChunk::from_channels_f32(0, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24);
|
||||||
|
tx.send(chunk).await.unwrap();
|
||||||
|
|
||||||
|
// Recevoir le chunk modifié
|
||||||
|
let modified = out_rx.recv().await.unwrap();
|
||||||
|
assert!((modified.gain_linear() - 0.5).abs() < 1e-6);
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
handle.await.unwrap().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_volume_handle() {
|
||||||
|
let (node, tx) = VolumeNode::new("test".to_string(), 1.0, 10);
|
||||||
|
let handle = node.get_handle();
|
||||||
|
|
||||||
|
tokio::spawn(async move { node.run().await });
|
||||||
|
|
||||||
|
// Modifier le volume via le handle
|
||||||
|
handle.set_volume(0.3).await;
|
||||||
|
|
||||||
|
let volume = handle.get_volume().await;
|
||||||
|
assert!((volume - 0.3).abs() < f32::EPSILON);
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_volume_events() {
|
||||||
|
let (mut node, tx) = VolumeNode::new("test".to_string(), 1.0, 10);
|
||||||
|
let (event_tx, mut event_rx) = mpsc::channel(10);
|
||||||
|
|
||||||
|
node.subscribe_volume_events(event_tx);
|
||||||
|
let handle = node.get_handle();
|
||||||
|
|
||||||
|
tokio::spawn(async move { node.run().await });
|
||||||
|
|
||||||
|
// Changer le volume
|
||||||
|
handle.set_volume(0.7).await;
|
||||||
|
|
||||||
|
// Vérifier l'événement
|
||||||
|
let event = event_rx.recv().await.unwrap();
|
||||||
|
assert!((event.volume - 0.7).abs() < f32::EPSILON);
|
||||||
|
assert_eq!(event.source_node_id, "test");
|
||||||
|
|
||||||
|
drop(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_master_slave_volume() {
|
||||||
|
// Créer le master
|
||||||
|
let (mut master, master_tx) = VolumeNode::new("master".to_string(), 1.0, 10);
|
||||||
|
let (master_event_tx, master_event_rx) = mpsc::channel(10);
|
||||||
|
master.subscribe_volume_events(master_event_tx);
|
||||||
|
let master_handle = master.get_handle();
|
||||||
|
|
||||||
|
// Créer le slave
|
||||||
|
let (mut slave, slave_tx) = VolumeNode::new("slave".to_string(), 0.8, 10);
|
||||||
|
slave.set_master_volume_source(master_event_rx);
|
||||||
|
let (out_tx, mut out_rx) = mpsc::channel(10);
|
||||||
|
slave.add_subscriber(out_tx);
|
||||||
|
|
||||||
|
tokio::spawn(async move { master.run().await });
|
||||||
|
tokio::spawn(async move { slave.run().await });
|
||||||
|
|
||||||
|
// Envoyer un chunk au slave
|
||||||
|
let chunk =
|
||||||
|
AudioChunk::from_channels_f32(0, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24);
|
||||||
|
slave_tx.send(chunk).await.unwrap();
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||||
|
|
||||||
|
// Modifier le volume master
|
||||||
|
master_handle.set_volume(0.5).await;
|
||||||
|
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||||
|
|
||||||
|
// Envoyer un autre chunk
|
||||||
|
let chunk2 =
|
||||||
|
AudioChunk::from_channels_f32(1, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24);
|
||||||
|
slave_tx.send(chunk2).await.unwrap();
|
||||||
|
|
||||||
|
// Le deuxième chunk devrait avoir un gain de 0.8 * 0.5 = 0.4 (≈ -7.96 dB)
|
||||||
|
let _first = out_rx.recv().await.unwrap(); // gain ≈ 0.8
|
||||||
|
let second = out_rx.recv().await.unwrap(); // gain ≈ 0.4
|
||||||
|
|
||||||
|
assert!((second.gain_linear() - 0.4).abs() < 0.01);
|
||||||
|
|
||||||
|
drop(master_tx);
|
||||||
|
drop(slave_tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
170
old_code/pmoplaylist/examples/basic_usage.rs
Normal file
170
old_code/pmoplaylist/examples/basic_usage.rs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
//! Exemple d'utilisation basique de pmoplaylist
|
||||||
|
//!
|
||||||
|
//! Pour exécuter cet exemple :
|
||||||
|
//! ```bash
|
||||||
|
//! cargo run -p pmoplaylist --example basic_usage
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
println!("=== Exemple pmoplaylist ===\n");
|
||||||
|
|
||||||
|
// 1. Créer une playlist FIFO
|
||||||
|
println!("1. Création d'une playlist avec capacité de 5 tracks...");
|
||||||
|
let playlist = FifoPlaylist::new(
|
||||||
|
"my-radio".to_string(),
|
||||||
|
"Ma Radio Préférée".to_string(),
|
||||||
|
5,
|
||||||
|
DEFAULT_IMAGE,
|
||||||
|
);
|
||||||
|
println!(" ✓ Playlist créée: {}", playlist.title().await);
|
||||||
|
println!(" ✓ ID: {}", playlist.id().await);
|
||||||
|
println!(" ✓ Capacité: 5 tracks");
|
||||||
|
println!(" ✓ Update ID initial: {}\n", playlist.update_id().await);
|
||||||
|
|
||||||
|
// 2. Ajouter des tracks
|
||||||
|
println!("2. Ajout de 3 tracks...");
|
||||||
|
let tracks = vec![
|
||||||
|
Track::new(
|
||||||
|
"track-1",
|
||||||
|
"Bohemian Rhapsody",
|
||||||
|
"http://example.com/queen/bohemian.flac",
|
||||||
|
)
|
||||||
|
.with_artist("Queen")
|
||||||
|
.with_album("A Night at the Opera")
|
||||||
|
.with_duration(354)
|
||||||
|
.with_image("http://example.com/covers/queen-anato.jpg"),
|
||||||
|
Track::new(
|
||||||
|
"track-2",
|
||||||
|
"Stairway to Heaven",
|
||||||
|
"http://example.com/zeppelin/stairway.mp3",
|
||||||
|
)
|
||||||
|
.with_artist("Led Zeppelin")
|
||||||
|
.with_album("Led Zeppelin IV")
|
||||||
|
.with_duration(482),
|
||||||
|
Track::new(
|
||||||
|
"track-3",
|
||||||
|
"Hotel California",
|
||||||
|
"http://example.com/eagles/hotel.flac",
|
||||||
|
)
|
||||||
|
.with_artist("Eagles")
|
||||||
|
.with_album("Hotel California")
|
||||||
|
.with_duration(391),
|
||||||
|
];
|
||||||
|
|
||||||
|
for track in tracks {
|
||||||
|
playlist.append_track(track.clone()).await;
|
||||||
|
println!(
|
||||||
|
" ✓ Ajouté: {} - {}",
|
||||||
|
track.title,
|
||||||
|
track.artist.unwrap_or_default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n Total tracks: {}", playlist.len().await);
|
||||||
|
println!(" Update ID: {}\n", playlist.update_id().await);
|
||||||
|
|
||||||
|
// 3. Tester le comportement FIFO
|
||||||
|
println!("3. Test du comportement FIFO (capacité = 5)...");
|
||||||
|
println!(" Ajout de 4 tracks supplémentaires...");
|
||||||
|
|
||||||
|
for i in 4..=7 {
|
||||||
|
let track = Track::new(
|
||||||
|
format!("track-{}", i),
|
||||||
|
format!("Song Number {}", i),
|
||||||
|
format!("http://example.com/songs/{}.mp3", i),
|
||||||
|
);
|
||||||
|
playlist.append_track(track).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" ✓ Total tracks (limité par capacité): {}",
|
||||||
|
playlist.len().await
|
||||||
|
);
|
||||||
|
|
||||||
|
// Afficher les tracks actuels
|
||||||
|
let items = playlist.get_items(0, 10).await;
|
||||||
|
println!("\n Tracks actuels dans la FIFO:");
|
||||||
|
for (idx, track) in items.iter().enumerate() {
|
||||||
|
println!(" {}. {} ({})", idx + 1, track.title, track.id);
|
||||||
|
}
|
||||||
|
println!(" (Les tracks 1 et 2 ont été supprimés automatiquement)\n");
|
||||||
|
|
||||||
|
// 4. Supprimer le plus ancien
|
||||||
|
println!("4. Suppression du track le plus ancien...");
|
||||||
|
if let Some(removed) = playlist.remove_oldest().await {
|
||||||
|
println!(" ✓ Supprimé: {} ({})", removed.title, removed.id);
|
||||||
|
}
|
||||||
|
println!(" Total tracks: {}", playlist.len().await);
|
||||||
|
println!(" Update ID: {}\n", playlist.update_id().await);
|
||||||
|
|
||||||
|
// 5. Supprimer par ID
|
||||||
|
println!("5. Suppression d'un track par ID (track-5)...");
|
||||||
|
if playlist.remove_by_id("track-5").await {
|
||||||
|
println!(" ✓ Track supprimé");
|
||||||
|
}
|
||||||
|
println!(" Total tracks: {}", playlist.len().await);
|
||||||
|
println!(" Update ID: {}\n", playlist.update_id().await);
|
||||||
|
|
||||||
|
// 6. Générer un Container DIDL-Lite
|
||||||
|
println!("6. Génération du Container DIDL-Lite...");
|
||||||
|
let container = playlist.as_container().await;
|
||||||
|
println!(" Container:");
|
||||||
|
println!(" - ID: {}", container.id);
|
||||||
|
println!(" - Parent ID: {}", container.parent_id);
|
||||||
|
println!(" - Title: {}", container.title);
|
||||||
|
println!(" - Class: {}", container.class);
|
||||||
|
println!(
|
||||||
|
" - Child Count: {}\n",
|
||||||
|
container.child_count.unwrap_or_default()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. Générer des Items DIDL-Lite
|
||||||
|
println!("7. Génération des Items DIDL-Lite...");
|
||||||
|
let didl_items = playlist
|
||||||
|
.as_objects(0, 10, Some("http://myserver/api/default-image"))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
println!(" Items DIDL-Lite:");
|
||||||
|
for (idx, item) in didl_items.iter().enumerate() {
|
||||||
|
println!("\n Item {}:", idx + 1);
|
||||||
|
println!(" - ID: {}", item.id);
|
||||||
|
println!(" - Title: {}", item.title);
|
||||||
|
println!(" - Artist: {}", item.artist.as_deref().unwrap_or("N/A"));
|
||||||
|
println!(" - Album: {}", item.album.as_deref().unwrap_or("N/A"));
|
||||||
|
println!(" - Class: {}", item.class);
|
||||||
|
println!(" - Parent ID: {}", item.parent_id);
|
||||||
|
|
||||||
|
if !item.resources.is_empty() {
|
||||||
|
println!(" - Resource URI: {}", item.resources[0].url);
|
||||||
|
if let Some(ref duration) = item.resources[0].duration {
|
||||||
|
println!(" - Duration: {}", duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref art) = item.album_art {
|
||||||
|
println!(" - Album Art: {}", art);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Image par défaut
|
||||||
|
println!("\n8. Image par défaut...");
|
||||||
|
let default_image = playlist.default_image().await;
|
||||||
|
println!(
|
||||||
|
" ✓ Taille de l'image par défaut: {} bytes",
|
||||||
|
default_image.len()
|
||||||
|
);
|
||||||
|
println!(" (Cette image peut être servie via un endpoint HTTP)\n");
|
||||||
|
|
||||||
|
// 9. Vider la playlist
|
||||||
|
println!("9. Vidage de la playlist...");
|
||||||
|
playlist.clear().await;
|
||||||
|
println!(" ✓ Playlist vidée");
|
||||||
|
println!(" Total tracks: {}", playlist.len().await);
|
||||||
|
println!(" Is empty: {}", playlist.is_empty().await);
|
||||||
|
println!(" Update ID final: {}\n", playlist.update_id().await);
|
||||||
|
|
||||||
|
println!("=== Exemple terminé ===");
|
||||||
|
}
|
||||||
217
old_code/pmoplaylist/examples/http_server_integration.rs
Normal file
217
old_code/pmoplaylist/examples/http_server_integration.rs
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
//! Exemple d'intégration avec un serveur HTTP
|
||||||
|
//!
|
||||||
|
//! Cet exemple montre comment exposer une playlist FIFO via des endpoints HTTP simples.
|
||||||
|
//! Dans un vrai MediaServer UPnP, ces endpoints seraient appelés par le protocole ContentDirectory.
|
||||||
|
//!
|
||||||
|
//! Pour exécuter :
|
||||||
|
//! ```bash
|
||||||
|
//! cargo run -p pmoplaylist --example http_server_integration
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
println!("=== Intégration HTTP Server ===\n");
|
||||||
|
|
||||||
|
// Créer une playlist partagée
|
||||||
|
let playlist = Arc::new(FifoPlaylist::new(
|
||||||
|
"my-radio".to_string(),
|
||||||
|
"My Internet Radio".to_string(),
|
||||||
|
20,
|
||||||
|
DEFAULT_IMAGE,
|
||||||
|
));
|
||||||
|
|
||||||
|
println!("📻 Playlist créée: {}", playlist.title().await);
|
||||||
|
println!("🆔 ID: {}\n", playlist.id().await);
|
||||||
|
|
||||||
|
// Ajouter quelques tracks initiaux
|
||||||
|
println!("📝 Ajout de tracks initiaux...");
|
||||||
|
let initial_tracks = vec![
|
||||||
|
("The Beatles", "Come Together", "Abbey Road", 259),
|
||||||
|
("Nirvana", "Smells Like Teen Spirit", "Nevermind", 301),
|
||||||
|
("Queen", "Bohemian Rhapsody", "A Night at the Opera", 354),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (idx, (artist, title, album, duration)) in initial_tracks.iter().enumerate() {
|
||||||
|
playlist
|
||||||
|
.append_track(
|
||||||
|
Track::new(
|
||||||
|
format!("track-{}", idx),
|
||||||
|
*title,
|
||||||
|
format!("http://media.server/music/{}.flac", idx),
|
||||||
|
)
|
||||||
|
.with_artist(*artist)
|
||||||
|
.with_album(*album)
|
||||||
|
.with_duration(*duration)
|
||||||
|
.with_image(format!("http://media.server/covers/{}.jpg", idx)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
println!(" ✓ {} - {}", artist, title);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Simuler différents endpoints HTTP
|
||||||
|
|
||||||
|
// 1. GET /playlist/container - Retourne le container DIDL-Lite
|
||||||
|
println!("🌐 Endpoint: GET /playlist/container");
|
||||||
|
simulate_get_container(playlist.clone()).await;
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// 2. GET /playlist/items?offset=0&count=10 - Retourne les items
|
||||||
|
println!("🌐 Endpoint: GET /playlist/items?offset=0&count=10");
|
||||||
|
simulate_get_items(playlist.clone(), 0, 10).await;
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// 3. GET /playlist/metadata - Retourne les métadonnées
|
||||||
|
println!("🌐 Endpoint: GET /playlist/metadata");
|
||||||
|
simulate_get_metadata(playlist.clone()).await;
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// 4. POST /playlist/track - Ajoute un nouveau track
|
||||||
|
println!("🌐 Endpoint: POST /playlist/track");
|
||||||
|
let new_track = Track::new(
|
||||||
|
"track-new-1",
|
||||||
|
"Stairway to Heaven",
|
||||||
|
"http://media.server/music/stairway.flac",
|
||||||
|
)
|
||||||
|
.with_artist("Led Zeppelin")
|
||||||
|
.with_album("Led Zeppelin IV")
|
||||||
|
.with_duration(482);
|
||||||
|
|
||||||
|
simulate_add_track(playlist.clone(), new_track).await;
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// 5. DELETE /playlist/oldest - Supprime le plus ancien
|
||||||
|
println!("🌐 Endpoint: DELETE /playlist/oldest");
|
||||||
|
simulate_delete_oldest(playlist.clone()).await;
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// 6. GET /playlist/default-image - Retourne l'image par défaut
|
||||||
|
println!("🌐 Endpoint: GET /playlist/default-image");
|
||||||
|
simulate_get_default_image(playlist.clone()).await;
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// 7. Vérifier l'état final
|
||||||
|
println!("📊 État final:");
|
||||||
|
let final_items = playlist.get_items(0, 10).await;
|
||||||
|
println!(" Total tracks: {}", playlist.len().await);
|
||||||
|
println!(" Update ID: {}", playlist.update_id().await);
|
||||||
|
println!("\n Tracks actuels:");
|
||||||
|
for (idx, track) in final_items.iter().enumerate() {
|
||||||
|
let artist = track.artist.as_deref().unwrap_or("Unknown");
|
||||||
|
println!(" {}. {} - {}", idx + 1, artist, track.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n=== Exemple terminé ===");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simule GET /playlist/container
|
||||||
|
async fn simulate_get_container(playlist: Arc<FifoPlaylist>) {
|
||||||
|
let container = playlist.as_container().await;
|
||||||
|
|
||||||
|
println!(" Response (JSON representation):");
|
||||||
|
println!(" {{");
|
||||||
|
println!(" \"id\": \"{}\",", container.id);
|
||||||
|
println!(" \"parentId\": \"{}\",", container.parent_id);
|
||||||
|
println!(" \"title\": \"{}\",", container.title);
|
||||||
|
println!(" \"class\": \"{}\",", container.class);
|
||||||
|
println!(
|
||||||
|
" \"childCount\": {}",
|
||||||
|
container.child_count.unwrap_or_default()
|
||||||
|
);
|
||||||
|
println!(" }}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simule GET /playlist/items?offset=X&count=Y
|
||||||
|
async fn simulate_get_items(playlist: Arc<FifoPlaylist>, offset: usize, count: usize) {
|
||||||
|
let items = playlist
|
||||||
|
.as_objects(offset, count, Some("http://media.server/api/default-image"))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
println!(" Response: {} items", items.len());
|
||||||
|
println!(" [");
|
||||||
|
for (idx, item) in items.iter().enumerate() {
|
||||||
|
println!(" {{");
|
||||||
|
println!(" \"id\": \"{}\",", item.id);
|
||||||
|
println!(" \"title\": \"{}\",", item.title);
|
||||||
|
println!(
|
||||||
|
" \"artist\": \"{}\",",
|
||||||
|
item.artist.as_deref().unwrap_or("")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" \"album\": \"{}\",",
|
||||||
|
item.album.as_deref().unwrap_or("")
|
||||||
|
);
|
||||||
|
println!(" \"class\": \"{}\",", item.class);
|
||||||
|
if !item.resources.is_empty() {
|
||||||
|
println!(" \"uri\": \"{}\",", item.resources[0].url);
|
||||||
|
}
|
||||||
|
print!(" }}");
|
||||||
|
if idx < items.len() - 1 {
|
||||||
|
println!(",");
|
||||||
|
} else {
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!(" ]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simule GET /playlist/metadata
|
||||||
|
async fn simulate_get_metadata(playlist: Arc<FifoPlaylist>) {
|
||||||
|
let update_id = playlist.update_id().await;
|
||||||
|
let last_change = playlist.last_change().await;
|
||||||
|
let count = playlist.len().await;
|
||||||
|
let id = playlist.id().await;
|
||||||
|
let title = playlist.title().await;
|
||||||
|
|
||||||
|
println!(" Response:");
|
||||||
|
println!(" {{");
|
||||||
|
println!(" \"id\": \"{}\",", id);
|
||||||
|
println!(" \"title\": \"{}\",", title);
|
||||||
|
println!(" \"trackCount\": {},", count);
|
||||||
|
println!(" \"updateId\": {},", update_id);
|
||||||
|
println!(" \"lastChange\": \"{:?}\"", last_change);
|
||||||
|
println!(" }}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simule POST /playlist/track
|
||||||
|
async fn simulate_add_track(playlist: Arc<FifoPlaylist>, track: Track) {
|
||||||
|
let old_update_id = playlist.update_id().await;
|
||||||
|
|
||||||
|
playlist.append_track(track.clone()).await;
|
||||||
|
|
||||||
|
let new_update_id = playlist.update_id().await;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" Track added: {} - {}",
|
||||||
|
track.artist.as_deref().unwrap_or("Unknown"),
|
||||||
|
track.title
|
||||||
|
);
|
||||||
|
println!(" Update ID: {} → {}", old_update_id, new_update_id);
|
||||||
|
println!(" Response: 201 Created");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simule DELETE /playlist/oldest
|
||||||
|
async fn simulate_delete_oldest(playlist: Arc<FifoPlaylist>) {
|
||||||
|
if let Some(removed) = playlist.remove_oldest().await {
|
||||||
|
println!(" Track removed: {} ({})", removed.title, removed.id);
|
||||||
|
println!(" New update ID: {}", playlist.update_id().await);
|
||||||
|
println!(" Response: 200 OK");
|
||||||
|
} else {
|
||||||
|
println!(" No tracks to remove");
|
||||||
|
println!(" Response: 404 Not Found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simule GET /playlist/default-image
|
||||||
|
async fn simulate_get_default_image(playlist: Arc<FifoPlaylist>) {
|
||||||
|
let image_bytes = playlist.default_image().await;
|
||||||
|
|
||||||
|
println!(" Response:");
|
||||||
|
println!(" Content-Type: image/webp");
|
||||||
|
println!(" Content-Length: {} bytes", image_bytes.len());
|
||||||
|
println!(" Status: 200 OK");
|
||||||
|
println!(" (Image WebP {} bytes ready to serve)", image_bytes.len());
|
||||||
|
}
|
||||||
198
old_code/pmoplaylist/examples/radio_streaming.rs
Normal file
198
old_code/pmoplaylist/examples/radio_streaming.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
//! Exemple simulant une radio en streaming
|
||||||
|
//!
|
||||||
|
//! Cet exemple démontre :
|
||||||
|
//! - L'utilisation de FifoPlaylist dans un contexte multi-thread
|
||||||
|
//! - La simulation d'un flux radio continu
|
||||||
|
//! - La surveillance des changements via update_id
|
||||||
|
//!
|
||||||
|
//! Pour exécuter :
|
||||||
|
//! ```bash
|
||||||
|
//! cargo run -p pmoplaylist --example radio_streaming
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::sleep;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
println!("=== Simulation Radio en Streaming ===\n");
|
||||||
|
|
||||||
|
// Créer une radio avec historique limité à 10 tracks
|
||||||
|
let radio = FifoPlaylist::new(
|
||||||
|
"radio-paradise".to_string(),
|
||||||
|
"Radio Paradise - Main Mix".to_string(),
|
||||||
|
10,
|
||||||
|
DEFAULT_IMAGE,
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("📻 Radio créée: {}", radio.title().await);
|
||||||
|
println!("📊 Capacité: 10 tracks (historique limité)");
|
||||||
|
println!("🆔 ID: {}\n", radio.id().await);
|
||||||
|
|
||||||
|
// Cloner pour les différentes tâches
|
||||||
|
let radio_streamer = radio.clone();
|
||||||
|
let radio_monitor = radio.clone();
|
||||||
|
let radio_client = radio.clone();
|
||||||
|
|
||||||
|
// Tâche 1: Simuler le streaming (ajoute des tracks régulièrement)
|
||||||
|
let streamer = tokio::spawn(async move {
|
||||||
|
println!("🎵 [STREAMER] Démarrage du flux radio...\n");
|
||||||
|
|
||||||
|
let tracks_data = vec![
|
||||||
|
("Radiohead", "Paranoid Android", "OK Computer", 383),
|
||||||
|
("Massive Attack", "Teardrop", "Mezzanine", 329),
|
||||||
|
(
|
||||||
|
"Pink Floyd",
|
||||||
|
"Shine On You Crazy Diamond",
|
||||||
|
"Wish You Were Here",
|
||||||
|
810,
|
||||||
|
),
|
||||||
|
("Portishead", "Glory Box", "Dummy", 305),
|
||||||
|
("Dire Straits", "Sultans of Swing", "Dire Straits", 349),
|
||||||
|
("The Cure", "Pictures of You", "Disintegration", 428),
|
||||||
|
("David Bowie", "Heroes", "Heroes", 371),
|
||||||
|
(
|
||||||
|
"Talking Heads",
|
||||||
|
"Once in a Lifetime",
|
||||||
|
"Remain in Light",
|
||||||
|
259,
|
||||||
|
),
|
||||||
|
("Fleetwood Mac", "Dreams", "Rumours", 257),
|
||||||
|
(
|
||||||
|
"The Smiths",
|
||||||
|
"There Is a Light That Never Goes Out",
|
||||||
|
"The Queen Is Dead",
|
||||||
|
244,
|
||||||
|
),
|
||||||
|
("Joy Division", "Love Will Tear Us Apart", "Closer", 206),
|
||||||
|
("New Order", "Blue Monday", "Power, Corruption & Lies", 448),
|
||||||
|
("Depeche Mode", "Enjoy the Silence", "Violator", 376),
|
||||||
|
("R.E.M.", "Losing My Religion", "Out of Time", 269),
|
||||||
|
(
|
||||||
|
"U2",
|
||||||
|
"Where the Streets Have No Name",
|
||||||
|
"The Joshua Tree",
|
||||||
|
337,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (idx, (artist, title, album, duration)) in tracks_data.iter().enumerate() {
|
||||||
|
let track = Track::new(
|
||||||
|
format!("radio-track-{}", idx),
|
||||||
|
*title,
|
||||||
|
format!("http://stream.radioparadise.com/track/{}", idx),
|
||||||
|
)
|
||||||
|
.with_artist(*artist)
|
||||||
|
.with_album(*album)
|
||||||
|
.with_duration(*duration);
|
||||||
|
|
||||||
|
radio_streamer.append_track(track).await;
|
||||||
|
|
||||||
|
println!("🎵 [STREAMER] Now Playing: {} - {}", artist, title);
|
||||||
|
|
||||||
|
// Simuler l'attente entre les tracks
|
||||||
|
sleep(Duration::from_millis(500)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n🎵 [STREAMER] Fin du streaming");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tâche 2: Monitorer les changements (update_id)
|
||||||
|
let monitor = tokio::spawn(async move {
|
||||||
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
println!("👁️ [MONITOR] Surveillance des changements...\n");
|
||||||
|
|
||||||
|
let mut last_update_id = 0;
|
||||||
|
let mut iterations = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let current_update_id = radio_monitor.update_id().await;
|
||||||
|
let count = radio_monitor.len().await;
|
||||||
|
|
||||||
|
if current_update_id != last_update_id {
|
||||||
|
println!(
|
||||||
|
"👁️ [MONITOR] Changement détecté! Update ID: {} → {} | Tracks: {}",
|
||||||
|
last_update_id, current_update_id, count
|
||||||
|
);
|
||||||
|
last_update_id = current_update_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
iterations += 1;
|
||||||
|
if iterations >= 50 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(Duration::from_millis(200)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n👁️ [MONITOR] Fin de la surveillance");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tâche 3: Client consultant l'historique
|
||||||
|
let client = tokio::spawn(async move {
|
||||||
|
sleep(Duration::from_millis(2000)).await;
|
||||||
|
|
||||||
|
println!("\n📱 [CLIENT] Consultation de l'historique de la radio...\n");
|
||||||
|
|
||||||
|
// Consulter plusieurs fois pendant le streaming
|
||||||
|
for i in 0..3 {
|
||||||
|
sleep(Duration::from_millis(2000)).await;
|
||||||
|
|
||||||
|
let history = radio_client.get_items(0, 10).await;
|
||||||
|
let update_id = radio_client.update_id().await;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"📱 [CLIENT] Consultation #{} (Update ID: {})",
|
||||||
|
i + 1,
|
||||||
|
update_id
|
||||||
|
);
|
||||||
|
println!(" Historique actuel ({} tracks):", history.len());
|
||||||
|
|
||||||
|
for (idx, track) in history.iter().enumerate() {
|
||||||
|
let artist = track.artist.as_deref().unwrap_or("Unknown");
|
||||||
|
println!(" {}. {} - {}", idx + 1, artist, track.title);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer le container DIDL-Lite à la fin
|
||||||
|
println!("📱 [CLIENT] Génération du Container DIDL-Lite...");
|
||||||
|
let container = radio_client.as_container().await;
|
||||||
|
println!(" Container ID: {}", container.id);
|
||||||
|
println!(" Title: {}", container.title);
|
||||||
|
println!(
|
||||||
|
" Child Count: {}",
|
||||||
|
container.child_count.unwrap_or_default()
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("\n📱 [CLIENT] Fin de la consultation");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attendre que toutes les tâches se terminent
|
||||||
|
let _ = tokio::join!(streamer, monitor, client);
|
||||||
|
|
||||||
|
// Afficher l'état final
|
||||||
|
println!("\n=== État Final ===");
|
||||||
|
println!("📊 Total tracks dans la radio: {}", radio.len().await);
|
||||||
|
println!("🆔 Update ID final: {}", radio.update_id().await);
|
||||||
|
|
||||||
|
let final_history = radio.get_items(0, 10).await;
|
||||||
|
println!("\n🎵 Historique final (10 derniers tracks):");
|
||||||
|
for (idx, track) in final_history.iter().enumerate() {
|
||||||
|
let artist = track.artist.as_deref().unwrap_or("Unknown");
|
||||||
|
let duration_min = track.duration.map(|d| d / 60).unwrap_or(0);
|
||||||
|
let duration_sec = track.duration.map(|d| d % 60).unwrap_or(0);
|
||||||
|
println!(
|
||||||
|
" {}. {} - {} ({}:{:02})",
|
||||||
|
idx + 1,
|
||||||
|
artist,
|
||||||
|
track.title,
|
||||||
|
duration_min,
|
||||||
|
duration_sec
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n=== Simulation terminée ===");
|
||||||
|
}
|
||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "pmomusic",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user