Compare commits
844 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b8ec744dd | |||
| 5f11f5b114 | |||
| 61f62363b3 | |||
| 3278e32711 | |||
| 0be6455d46 | |||
| 0bf5a39a92 | |||
| 5424648158 | |||
| dcfd95f276 | |||
| 4d6f7d8b08 | |||
| 2c2cf8cdf8 | |||
| 08c738dc69 | |||
| eb36b0bb7b | |||
| 3fd14e21eb | |||
| 408895b607 | |||
| 1a01147a95 | |||
| 8950907428 | |||
| eb40a88437 | |||
| 7f82049beb | |||
| c0c1d745f3 | |||
| c2b38a7c5a | |||
| ae8638a4b2 | |||
| b864fafa82 | |||
| ee5ab1a751 | |||
| 64b884e27a | |||
| dc8bb2cbc2 | |||
| d882fc292c | |||
| 5dc0980ced | |||
| 1cd668c869 | |||
| a827ebf6f4 | |||
| 3917ae02e2 | |||
| bd14c7dc63 | |||
| e0e28aee38 | |||
| 1550eedc12 | |||
| b2074dfd02 | |||
| e9171d6f21 | |||
| ef60bba2e1 | |||
| 12fb942f16 | |||
| 3a2481e8b2 | |||
| bede5ae8d7 | |||
| 445b186e3b | |||
| 354fe61b85 | |||
| 95f5ae610e | |||
| 2e806a28b9 | |||
| 2ab0350733 | |||
| ce813bc216 | |||
| 21fe047e00 | |||
| 8558450378 | |||
| f9e68b628d | |||
| 50509d0a16 | |||
| c1c0494912 | |||
| 58e615462c | |||
| f0bf769f0d | |||
| 423d50cfb5 | |||
| 2f4a62e03c | |||
| e64bea41e6 | |||
| f0acda0f01 | |||
| af4e4561ec | |||
| 1787059f42 | |||
| b2705cb2ae | |||
| f236d72a19 | |||
| cf270a36ff | |||
| 6d932386b0 | |||
| 9c054b9e3a | |||
| d9f0007a2d | |||
| ee35f52baf | |||
| 21347420f3 | |||
| 26987459f3 | |||
| 897388853b | |||
| ef52332b8b | |||
| 1489378ffd | |||
| ccc93f881a | |||
| ded8b68098 | |||
| 983be8b37a | |||
| 7b22bbf25f | |||
| 06f2b9ec97 | |||
| 7fee4cea4f | |||
| 526897b23b | |||
| c10c2a290c | |||
| fb5204b0a6 | |||
| 9db4048bc0 | |||
| 63c68b4d4d | |||
| 953ef37882 | |||
| da85a2dcc2 | |||
| 49869792cf | |||
| fb2dda1ed1 | |||
| fad4c4ea36 | |||
| 6b5345a6e5 | |||
| ca413a16fa | |||
| b8b670642c | |||
| 2a2e2924eb | |||
| adea3de737 | |||
| 7d300a39c9 | |||
| 688a5f2add | |||
| d736e5aafe | |||
| 3a536ad348 | |||
| 5dedeb4971 | |||
| 7624e24ea6 | |||
| 7b248d8ab4 | |||
| fdb2009856 | |||
| 8419a75b04 | |||
| 5d474d6fe8 | |||
| e597505a1c | |||
| 8675d263e7 | |||
| 1ce66b9e03 | |||
| cfda124995 | |||
| 212f1cacca | |||
| dd89de7cad | |||
| 8b4372dc7f | |||
| 2a25557632 | |||
| 0cbb339948 | |||
| 1496f51e30 | |||
| d1c5fe0605 | |||
| 56786f60ff | |||
| af5d36f69f | |||
| e40da71ef8 | |||
| 26b8bf422c | |||
| 0a545706bd | |||
| 9ebac610c7 | |||
| 5fc8a6af2a | |||
| 8e68af79aa | |||
| 6246e6e821 | |||
| 421d5ffdc8 | |||
| b82dabe316 | |||
| ffdaf14ba5 | |||
| f52527a41b | |||
| 56a89c5fc6 | |||
| 4f5163be01 | |||
| 822c094c8c | |||
| 1623f443bb | |||
| aa47bc4499 | |||
| f461322842 | |||
| cce05a0077 | |||
| 98dc868f47 | |||
| 821a41c10e | |||
| 853ccd657a | |||
| 680fc81db2 | |||
| 36470eda24 | |||
| a37dd6c8cb | |||
| 588f742871 | |||
| ff25a10e5b | |||
| 499457f66a | |||
| 6d15050009 | |||
| 5ba30031c3 | |||
| 82c0eef504 | |||
| 616267e997 | |||
| 161b0c8c21 | |||
| facd185d6c | |||
| 42858bf336 | |||
| 716be88caf | |||
| b296726a9d | |||
| 092f18d7a5 | |||
| f1ef33e319 | |||
| fc9bc95418 | |||
| c61e64f332 | |||
| 70ebb8ef1a | |||
| a4c6a92478 | |||
| 76b453e535 | |||
| 19acdd87f5 | |||
| 492e1335ef | |||
| 23cde7add3 | |||
| a20c28db25 | |||
| f07d46c49e | |||
| e9781a24a6 | |||
| 15be15ba58 | |||
| 0952b76e11 | |||
| 8011d41e53 | |||
| 5412f23d26 | |||
| 0c39ff47f2 | |||
| 537af905f6 | |||
| 6b4f70bde3 | |||
| be2b6d2c1f | |||
| 0c1a6d8f19 | |||
| 2821997260 | |||
| 0546a33b10 | |||
| deb98d8dfb | |||
| 72c658eda7 | |||
| df17f10c8a | |||
| 9cacf2dc8e | |||
| c7bc9f5b1c | |||
| 49ba8ae0d2 | |||
| 7291dbd9e2 | |||
| fb4cd75cb2 | |||
| 8b7cecc1c5 | |||
| 3a62442ed0 | |||
| 012dcdc2dd | |||
| 3a1b92f9c4 | |||
| 629eb66595 | |||
| 36749a40d3 | |||
| 4336e6dc78 | |||
| 3e3e87e73e | |||
| 1b8d6ce7fa | |||
| 60f1df1488 | |||
| ff86869c33 | |||
| 30f97394ec | |||
| 592308c1c6 | |||
| 2a2d817314 | |||
| 8bcfc63da0 | |||
| a9cfff2692 | |||
| 9e7ff56113 | |||
| 9071143bbd | |||
| 7845ac8be5 | |||
| 40770aff15 | |||
| 81547013f9 | |||
| 8e605cbd0f | |||
| d664d46ca4 | |||
| b4031936a0 | |||
| f84a33bbf2 | |||
| 8f5c59683a | |||
| 4b7146afe4 | |||
| 2bc5ef34ee | |||
| 939407675b | |||
| 6b9a3d95cd | |||
| 20ac6b2cd4 | |||
| 904b45e8f6 | |||
| 1bd54c530b | |||
| 4fe51cef96 | |||
| d005e2e2e7 | |||
| fb5d8826a2 | |||
| 4bc28704ff | |||
| ed7171133f | |||
| 67885e17ed | |||
| fd4da1b7c4 | |||
| 242a57b7eb | |||
| 18467c54d6 | |||
| 8238e2fe68 | |||
| 672ce024f8 | |||
| 8224e93447 | |||
| 1ba810fffb | |||
| 1a725d0d31 | |||
| 51c5b42a78 | |||
| 2908827018 | |||
| b985cbf694 | |||
| 13c2360b7e | |||
| f1138ec7af | |||
| 1293d92896 | |||
| 705d41931d | |||
| 29de69d323 | |||
| 28727d89f6 | |||
| 4704bcf52f | |||
| 13c148fb6c | |||
| e6079452f9 | |||
| b68b7d5c9b | |||
| 741fcdb4d9 | |||
| 642f8c5398 | |||
| 1c15d5e7d3 | |||
| e71090338c | |||
| 7c0feaaae0 | |||
| 5aa3ff4bb5 | |||
| 0e00660e2e | |||
| aad72226c5 | |||
| d4c83db428 | |||
| 9f2d51fd4d | |||
| 83d7106e35 | |||
| 30a7cba02a | |||
| 01a5b43613 | |||
| 149cdc782d | |||
| 36137e8970 | |||
| d24435dbc2 | |||
| 823e56926f | |||
| bb06ab7e12 | |||
| 2143de3aa7 | |||
| dd8a54dd43 | |||
| 1ff33b96fa | |||
| b5973c45a2 | |||
| 9a78798854 | |||
| 101ab3f521 | |||
| cfc8e699f3 | |||
| 6b342aeac6 | |||
| b306056995 | |||
| 82e317c4a8 | |||
| a4dc776bfb | |||
| 5bdaa35ced | |||
| e187ac461d | |||
| 1b4a6cd042 | |||
| dcfb22c3f4 | |||
| 501158df03 | |||
| e17a4fad4e | |||
| 34894faabf | |||
| b329acd710 | |||
| 4be9273768 | |||
| f458ac2162 | |||
| b5ea2bb4c1 | |||
| 284d257921 | |||
| 30bf6b7f9a | |||
| 4941b6bd23 | |||
| 33d99817ec | |||
| 37e1af50ad | |||
| 8a6efb1303 | |||
| 7823b19b89 | |||
| 2a9aa544a9 | |||
| f387c8ff85 | |||
| 7e537aec0b | |||
| 66cd465565 | |||
| 87dc8eb5ea | |||
| 397669965d | |||
| 60bd0e619e | |||
| 2c7621c1a5 | |||
| 83afa40423 | |||
| 486e7eb101 | |||
| 05eb9e60d3 | |||
| dde7095644 | |||
| f1e9a2915d | |||
| ae3495d373 | |||
| 6fb2c1b688 | |||
| 1526c558e7 | |||
| 324e0f053b | |||
| 25cb33c78e | |||
| 942b6d9569 | |||
| cd46c79383 | |||
| 0bdcdcc229 | |||
| 1a5863a7fb | |||
| b55be00fab | |||
| f8b7812943 | |||
| 8f14ff169a | |||
| ca3abeb1cf | |||
| bb0cc23461 | |||
| 45fa33e1ec | |||
| 64dbf4441c | |||
| 148e5c1231 | |||
| 3a7419ec9f | |||
| 01c7c9cc3a | |||
| 701015ad55 | |||
| 3f56b88fa5 | |||
| bdd3f4aef5 | |||
| 611abdc6ae | |||
| 6e9fa45915 | |||
| 63cfac626a | |||
| e6c5a21bfc | |||
| 7dafbc1063 | |||
| ad8ac3bd2b | |||
| 2d80739141 | |||
| 6494102e15 | |||
| 0e6aa2efd9 | |||
| cd2c2a9854 | |||
| f412c216c5 | |||
| af15e3d914 | |||
| b00ff3f3f0 | |||
| 1607e6830e | |||
| 817e0bf2bd | |||
| 0f12fbce6a | |||
| 953a09d75f | |||
| 5098989614 | |||
| 5828bcffdd | |||
| ae87a7d58f | |||
| bb7c86c29e | |||
| 32ab78a213 | |||
| 69583d172c | |||
| 38367c1c77 | |||
| 2f6bf91a1c | |||
| 60b062bbaf | |||
| 30e8b604a9 | |||
| 7c3ab92e17 | |||
| 37b101c70f | |||
| b7be46e6ae | |||
| df96cc4a1d | |||
| 6c3d92cee4 | |||
| bf1f79866b | |||
| a6460426a2 | |||
| 304ba14d20 | |||
| db47233d92 | |||
| 74eeb98be8 | |||
| 331da0f897 | |||
| 73964ee648 | |||
| a5e8402141 | |||
| c5e7fcf29b | |||
| d3cf6d30a7 | |||
| 803cd2de96 | |||
| 8f2ca33e87 | |||
| d87e0d7e01 | |||
| 86b8709ea1 | |||
| 702b917929 | |||
| 74e14f7a43 | |||
| 02e347adb0 | |||
| 56983cb85b | |||
| 7917c656b0 | |||
| fc34c1e548 | |||
| f32aeaa0ff | |||
| 86097a932c | |||
| f74f24c41f | |||
| 8e99e7b07e | |||
| e06aab6e87 | |||
| a81e56fb26 | |||
| 9a09b119c5 | |||
| 4b28ca1055 | |||
| d684d9f8d1 | |||
| 16ce6089fb | |||
| 6895e45f2c | |||
| e87f7a1177 | |||
| bcd8a05352 | |||
| 4b219ad18e | |||
| 57051bd649 | |||
| d6fca6ca55 | |||
| 153ec2d9e5 | |||
| be90e85d94 | |||
| 4af089f56c | |||
| 62519d2d1c | |||
| 27c0880e87 | |||
| f312b74b30 | |||
| bd49e307ef | |||
| e904a836c1 | |||
| 763c9478f1 | |||
| 427bdf74dc | |||
| 373a276c54 | |||
| dccadf1f87 | |||
| d9933fe038 | |||
| d47ac0934d | |||
| dbba4d6630 | |||
| 7405855e01 | |||
| ed020c9303 | |||
| 378742e37a | |||
| c79bee534e | |||
| 1d6df75829 | |||
| b7f51b5f14 | |||
| 1c8e9df727 | |||
| 01540fe3fc | |||
| 071db2f109 | |||
| e097d3f605 | |||
| 277f783f62 | |||
| 7637aaf168 | |||
| c4878470bf | |||
| a3725e8c48 | |||
| 917ba842f5 | |||
| dac17ead33 | |||
| 6845ebe04c | |||
| eff709480d | |||
| 67833424cc | |||
| 5c48e1b476 | |||
| 5e17c9f238 | |||
| 7d330fb2ec | |||
| cd6a4594fa | |||
| bcf727f4ec | |||
| 4c4553913f | |||
| f0013fac16 | |||
| ce4be0ba97 | |||
| 4bac38ef2a | |||
| 4b213f47d9 | |||
| a1010f72f2 | |||
| 21077a26d0 | |||
| b50eec5a47 | |||
| 38a8b715f8 | |||
| 2b47537bb5 | |||
| a5cf241846 | |||
| 53a4773480 | |||
| 89603af1f1 | |||
| 2143084d3c | |||
| 0e265193b8 | |||
| c7e9749ce4 | |||
| e21cffff0b | |||
| d9e20040be | |||
| 6689173525 | |||
| f37e4704a6 | |||
| 65dbd5c8e4 | |||
| d034144e9c | |||
| 7c4309955e | |||
| 63e90d13d4 | |||
| bfb0cad603 | |||
| cc10a917dc | |||
| 5e833c1f75 | |||
| 8c576ac7e4 | |||
| 92160537c0 | |||
| 120ecaa0e5 | |||
| fd3a34303e | |||
| d89b70e155 | |||
| e3b63c1d27 | |||
| 96301c0dbf | |||
| a2458c1292 | |||
| 1737e12dd2 | |||
| b770d7d9ca | |||
| b712b9f509 | |||
| 51496cd34e | |||
| 2b2c2bc90a | |||
| e2a489ec92 | |||
| 4f46dd947d | |||
| fbb8d30db0 | |||
| c0637006af | |||
| 3fc371b8c4 | |||
| ee5b3824e9 | |||
| be6a856773 | |||
| e41c299d49 | |||
| 981786b4a2 | |||
| eeb6f11808 | |||
| 8e361e14b4 | |||
| d58d46eb1f | |||
| 562a17f7ae | |||
| b035e66540 | |||
| 38792a753e | |||
| d5b34b4f15 | |||
| 2a45c8dcdb | |||
| e7a2166a4f | |||
| bd73eb292d | |||
| 8ee2919934 | |||
| f29177216d | |||
| 18d3612674 | |||
| f7c0e417d7 | |||
| 3fd13e9930 | |||
| 0b20cb895e | |||
| 8979210804 | |||
| e9b24712c5 | |||
| 3d6e5615fa | |||
| fc7220b572 | |||
| 198ed5ce6f | |||
| b48462a945 | |||
| 0f327cd1f6 | |||
| f54597e655 | |||
| 4f2e677e8b | |||
| 79a69f8f70 | |||
| bf0f4bdf3e | |||
| 5e1cc3ecb5 | |||
| d4b37edc2f | |||
| 9483614bc7 | |||
| a73f2e1a13 | |||
| 091e3fadd9 | |||
| 5340ca7b16 | |||
| 85d3e58a26 | |||
| 1125c757fe | |||
| 66d714d368 | |||
| 49c2501fbc | |||
| e487817f21 | |||
| d8bbeb1e67 | |||
| 9693616645 | |||
| 0423e36d34 | |||
| c8d605fdee | |||
| 03fd734048 | |||
| da9d64ccfd | |||
| 02e64b7a3c | |||
| a435009d4d | |||
| 9ca73a99a6 | |||
| 4974284760 | |||
| a0306bd345 | |||
| ea7e594c68 | |||
| d00a84f1b9 | |||
| 58b6203681 | |||
| d299144c47 | |||
| 40b224e5a1 | |||
| 7021e5493f | |||
| 68bbc8a259 | |||
| be94a59441 | |||
| 3a73aee1b7 | |||
| c91154ea3e | |||
| 4f365ca7fe | |||
| 98fdc0ed7c | |||
| 12be560cb8 | |||
| 4cf885a52e | |||
| c57c8a4267 | |||
| 2ca6c737c0 | |||
| 2a451ec2a3 | |||
| 346e79b247 | |||
| 497ba342c0 | |||
| aca0bbb819 | |||
| 2df8fd6282 | |||
| 999317eba1 | |||
| 16991476ed | |||
| ba33639818 | |||
| 23cab16471 | |||
| 0a892011de | |||
| acb1d957d3 | |||
| 4a492aeefc | |||
| eb143a41fc | |||
| 75db2f162b | |||
| 855d0e3ffc | |||
| 5ccd06cc68 | |||
| b2873378fc | |||
| 66a89d9e8e | |||
| 814deca19d | |||
| 3bb6754d9c | |||
| 7d11d67cd2 | |||
| c0bd10cfca | |||
| e003b15ffd | |||
| ac1c7d31c9 | |||
| 6fc9ffeb23 | |||
| 9bebed506b | |||
| bffeb55a7a | |||
| c66d13c9fd | |||
| 8529985a0e | |||
| a8a3973225 | |||
| 6710f90e1e | |||
| 929c5f3249 | |||
| f170ead7b9 | |||
| e63e366228 | |||
| 95e755e54e | |||
| c719406425 | |||
| 9627ef66cf | |||
| 15f977d98d | |||
| 5b5f043624 | |||
| 529a920b24 | |||
| 09eb6cf206 | |||
| af6fa6ea53 | |||
| 280b921755 | |||
| 6ebe0c51ce | |||
| 47bd24c1bd | |||
| 2b23678c0d | |||
| e8327545ad | |||
| 89a38af538 | |||
| b7f34ec47c | |||
| 967523bfc6 | |||
| 29d8a185f9 | |||
| 4495d4bf4e | |||
| 67737467e0 | |||
| 13845eea04 | |||
| 12779778d3 | |||
| d4178ad036 | |||
| 49ea84384d | |||
| a6d9849468 | |||
| 16100aa0fd | |||
| 387dd47374 | |||
| 6ecb69feae | |||
| feff985439 | |||
| 2e8fe34824 | |||
| f58005f406 | |||
| 75abc03a4f | |||
| 84381d142a | |||
| f67f52eba9 | |||
| 3747ffff64 | |||
| ed47efed17 | |||
| c0d72e89d7 | |||
| a4313cfe0f | |||
| c7bef03ee3 | |||
| ce5a9e0cff | |||
| 859b823e77 | |||
| 7d8cf5f7ca | |||
| 4adaed8da0 | |||
| 554fe08fcd | |||
| b8af75bf6e | |||
| 35f2f119db | |||
| f36096e0ac | |||
| 1665e4cd57 | |||
| 42f0267277 | |||
| 82f59d32b9 | |||
| 941347b007 | |||
| 739c89569f | |||
| 18607597e9 | |||
| 7bb808cba5 | |||
| 78cd396847 | |||
| bb342c01e2 | |||
| 8a5dc0edfe | |||
| 8540da484f | |||
| 20f789f8e0 | |||
| 3e89326c95 | |||
| a7ea4de25a | |||
| aabfbf062e | |||
| 7b9ed3ec8e | |||
| 6dad66d62d | |||
| 31018230ee | |||
| 54ddc1f59c | |||
| c6856bd1a1 | |||
| 8c18c7b8f1 | |||
| 10c5293f64 | |||
| d5381afcf9 | |||
| 134bf4375f | |||
| aa9854fc0a | |||
| 10bc29e347 | |||
| 733efce161 | |||
| ac9141f167 | |||
| d89850e8a9 | |||
| 5948e4f125 | |||
| 34d22f783c | |||
| c347b6999e | |||
| adc74741ce | |||
| 48f614359e | |||
| 16669d8b7a | |||
| f1eef47600 | |||
| fc1567d2c8 | |||
| fffce6039a | |||
| cbfa147a12 | |||
| 5b8c953ae6 | |||
| 37a4dc096b | |||
| b3808645fb | |||
| 24aa804bf2 | |||
| 941ffb2bb7 | |||
| 59737d6f2b | |||
| c8ad93ee9b | |||
| 8cb0c037c2 | |||
| e30b69397b | |||
| d6e837fd61 | |||
| 5c97d202b9 | |||
| 0f6cfa75bb | |||
| 91bd6d1572 | |||
| df77ae3986 | |||
| 3cd6d068a2 | |||
| 29165da5ac | |||
| 9343583c69 | |||
| d82d255bae | |||
| 93a7042a84 | |||
| 5be5c869da | |||
| 8d45e023b2 | |||
| f2ae1398db | |||
| c2736a61fb | |||
| 76fe8dbc69 | |||
| dd05061829 | |||
| 8f6b99c550 | |||
| f54ee86591 | |||
| 42e0ec2663 | |||
| 0456a97b35 | |||
| 07c609cc3a | |||
| de5d26403f | |||
| 73c2d0efac | |||
| d3c1c440cc | |||
| 94195c636f | |||
| 9abf492362 | |||
| defc84c216 | |||
| 3c9ae39145 | |||
| 64408c8d8b | |||
| db55bb4693 | |||
| 9c6856b584 | |||
| 581f43f4c1 | |||
| 221d7e4829 | |||
| 706528f04b | |||
| f95a96dd1f | |||
| d85c16ce0f | |||
| 35afdf4be4 | |||
| eb5ed86019 | |||
| 0cfa6f56be | |||
| 5af88ead33 | |||
| 8ec63ee610 | |||
| c8247bf7a0 | |||
| 2f3270c7ff | |||
| 960d60f0bc | |||
| a4899144c5 | |||
| 808083c938 | |||
| 7e41ab4460 | |||
| 75a2bec8d5 | |||
| c35857bb61 | |||
| 2c897992c5 | |||
| 7d5cb574c6 | |||
| c582f96cf6 | |||
| 8fab3f60a7 | |||
| c6e981b3a1 | |||
| f0c5c5660a | |||
| 9c647bb31b | |||
| e1e82ac586 | |||
| 585d6da98d | |||
| bc279dd7fd | |||
| f2fdead6d3 | |||
| f66ccb4741 | |||
| 32c10c2b23 | |||
| 05674d9586 | |||
| 11bda9aae5 | |||
| 02c803385c | |||
| 8fe7a1e756 | |||
| 4a61ffea8d | |||
| 91548691ad | |||
| 36a646e5c0 | |||
| f306599ab2 | |||
| 3a7b777717 | |||
| 2334e659ad | |||
| 2a0216c87a | |||
| ab2d671760 | |||
| 5532d0a7d9 | |||
| 277e7719d3 | |||
| d6cb9fc261 | |||
| e6a857335f | |||
| e82e3a8343 | |||
| 6d812c76c2 | |||
| 5af4bb7ade | |||
| 030d66fd65 | |||
| c929f8d0a6 | |||
| 6fb50cfc67 | |||
| ebcdcf40dc | |||
| 76a05e717b | |||
| 062ce31cf7 | |||
| 8675ab3215 | |||
| ad6ef2884a | |||
| 3ebb8a5e79 | |||
| 652b1b0821 | |||
| a202ca4865 | |||
| a2db5bef25 | |||
| a81fa1ead7 | |||
| e7315cbc7e | |||
| cd757f177f | |||
| 103c55c072 | |||
| 765caab6df | |||
| 72f4663dd5 | |||
| deb6d92b55 | |||
| 0222ea6ccb | |||
| 8c047600a0 | |||
| 57b5877fdc | |||
| 7ddf67a977 | |||
| 7af2212d11 | |||
| 5e13651ed9 | |||
| 08e9c8d463 | |||
| b3d93880b5 | |||
| 05e100a492 | |||
| a4e22de455 | |||
| d76d020cfe | |||
| 85bf3cfa84 | |||
| 8eec73d88c | |||
| b63dbbbfd5 | |||
| 8b16157047 | |||
| 6628682f97 | |||
| 5971ffc470 | |||
| baf95ec328 | |||
| 0a6590fafd | |||
| 22dd0ee0f6 | |||
| f9ad6046e8 | |||
| 8a21902fa1 | |||
| 016564eda7 | |||
| 5a8ff7db37 | |||
| cc08596adf | |||
| e83fd66023 | |||
| d49bab403d | |||
| a6bef63aa7 | |||
| 898e28c40c | |||
| 9fda7ef596 | |||
| 17ba1713ad | |||
| f4110204b1 | |||
| d2a183b52d | |||
| a8dcf3113c | |||
| 1f52a6c9e0 | |||
| adbed63196 | |||
| 33e20845f1 | |||
| 9a7096c301 | |||
| 4c365032ff | |||
| bbd32d40a6 | |||
| 73f4a91fa1 | |||
| 1e2e383794 | |||
| 3b70b071e3 | |||
| 838c0ea421 | |||
| b39ec41255 | |||
| d4d661d6d4 | |||
| 2092f078ec | |||
| 924569aefb | |||
| a5864e15f8 | |||
| 564dd8bf95 | |||
| b317f7cd76 | |||
| a3b49d2642 | |||
| 6f20620c97 | |||
| b6a055a01a | |||
| 44ac593ddc | |||
| ca4c2a661e | |||
| 8b3b39f390 | |||
| 915934e5dd | |||
| 42f15018ae | |||
| 3554a7b5b9 | |||
| f2941939b7 | |||
| 1a77ded997 | |||
| 05d25d4d7c | |||
| 7cc1fef989 | |||
| 4a966e5e52 | |||
| d8ba4549aa | |||
| 309568becc | |||
| dd9b6dbfe3 | |||
| 4692b48174 | |||
| db82fa3ae1 | |||
| 5c42507b12 |
@@ -4,5 +4,5 @@ contact_links:
|
||||
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
||||
about: Check the README for setup instructions and FAQ
|
||||
- name: Extension Development Guide
|
||||
url: https://zarz.moe/docs
|
||||
url: https://spotiflac.zarz.moe/docs
|
||||
about: Documentation for building SpotiFLAC extensions
|
||||
|
||||
@@ -66,12 +66,12 @@ jobs:
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
java-version: "25"
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.7"
|
||||
go-version: "1.25.8"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache Gradle for faster builds
|
||||
@@ -93,12 +93,12 @@ jobs:
|
||||
# Accept licenses
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||
|
||||
# Install NDK r27d LTS (required for 16KB page size support on Android 15+)
|
||||
# Install NDK r29 (supports 16KB page size for Android 15+)
|
||||
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.3.13750724" "platforms;android-36" "build-tools;36.0.0"
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-36" "build-tools;36.0.0"
|
||||
|
||||
# Set NDK path
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.3.13750724" >> $GITHUB_ENV
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
|
||||
|
||||
- name: Install gomobile
|
||||
run: |
|
||||
@@ -164,17 +164,22 @@ jobs:
|
||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||
|
||||
build-ios:
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-15
|
||||
needs: get-version # Only depends on version, NOT android build!
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Select Xcode 26.1.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25.7"
|
||||
go-version: "1.25.8"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache CocoaPods
|
||||
@@ -252,6 +257,15 @@ jobs:
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Normalize ffmpeg plugin shell scripts (strip CRLF)
|
||||
run: |
|
||||
find "$HOME/.pub-cache/hosted" -path "*ffmpeg_kit_flutter_new_full*/scripts/*.sh" -type f -print0 |
|
||||
while IFS= read -r -d '' f; do
|
||||
perl -pi -e 's/\r$//' "$f"
|
||||
chmod +x "$f"
|
||||
echo "Normalized line endings: $f"
|
||||
done
|
||||
|
||||
- name: Generate app icons
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
@@ -309,32 +323,22 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git-cliff
|
||||
|
||||
- name: Extract changelog for version
|
||||
- name: Generate changelog with git-cliff
|
||||
id: changelog
|
||||
uses: orhun/git-cliff-action@v4
|
||||
with:
|
||||
config: cliff.toml
|
||||
args: --latest --strip header
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OUTPUT: /tmp/changelog.txt
|
||||
|
||||
- name: Show generated changelog
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
||||
|
||||
echo "Looking for version: $VERSION_NUM"
|
||||
|
||||
# Extract changelog section for this version using sed
|
||||
# Find the line with version, then print until next version header or end
|
||||
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
|
||||
|
||||
# If no changelog found, use default message
|
||||
if [ -z "$CHANGELOG" ]; then
|
||||
echo "No changelog found for version $VERSION_NUM"
|
||||
CHANGELOG="See CHANGELOG.md for details."
|
||||
else
|
||||
echo "Found changelog content"
|
||||
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
|
||||
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
|
||||
fi
|
||||
|
||||
# Save to file for multiline support
|
||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||
echo "Extracted changelog:"
|
||||
echo "Generated changelog:"
|
||||
cat /tmp/changelog.txt
|
||||
|
||||
- name: Download Android APK
|
||||
@@ -352,15 +356,22 @@ jobs:
|
||||
- name: Prepare release body
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
cat > /tmp/release_body.txt << 'HEADER'
|
||||
### What's New
|
||||
HEADER
|
||||
|
||||
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
||||
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
|
||||
CURRENT_REF=$(git rev-list -n 1 "$VERSION" 2>/dev/null || git rev-parse HEAD)
|
||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${CURRENT_REF}^" 2>/dev/null || true)
|
||||
|
||||
# Start with git-cliff changelog, but replace its compare footer with a
|
||||
# deterministic previous-tag lookup from git.
|
||||
sed '/^## [0-9][0-9.[:alpha:]-]*$/d; /^\*\*Full Changelog\*\*/d' /tmp/changelog.txt > /tmp/release_body.txt
|
||||
|
||||
if [ -n "$PREVIOUS_TAG" ]; then
|
||||
printf '\n**Full Changelog**: [%s...%s](https://github.com/%s/%s/compare/%s...%s)\n' \
|
||||
"$PREVIOUS_TAG" "$VERSION" "$REPO_OWNER" "$REPO_NAME" "$PREVIOUS_TAG" "$VERSION" \
|
||||
>> /tmp/release_body.txt
|
||||
fi
|
||||
|
||||
# Append download section
|
||||
cat >> /tmp/release_body.txt << FOOTER
|
||||
|
||||
---
|
||||
@@ -377,8 +388,6 @@ jobs:
|
||||
### Installation
|
||||
**Android**: Enable "Install from unknown sources" and install the APK
|
||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||
|
||||
  
|
||||
FOOTER
|
||||
|
||||
echo "Release body:"
|
||||
@@ -388,7 +397,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.get-version.outputs.version }}
|
||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||
name: SpotiFLAC-Mobile ${{ needs.get-version.outputs.version }}
|
||||
body_path: /tmp/release_body.txt
|
||||
files: ./release/*
|
||||
draft: false
|
||||
@@ -396,6 +405,63 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
update-altstore:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, build-ios, create-release]
|
||||
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout main branch
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
|
||||
- name: Update apps.json
|
||||
run: |
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
VERSION_NUM="${VERSION#v}"
|
||||
DATE=$(date -u +%Y-%m-%d)
|
||||
IPA_FILE=$(find ./release -name "*ios*.ipa" | head -1)
|
||||
|
||||
if [ -z "$IPA_FILE" ]; then
|
||||
echo "WARNING: IPA file not found, skipping apps.json update"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IPA_SIZE=$(stat -c%s "$IPA_FILE" 2>/dev/null || stat -f%z "$IPA_FILE")
|
||||
|
||||
if [ ! -f apps.json ]; then
|
||||
echo "WARNING: apps.json not found on main, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq --arg ver "$VERSION_NUM" \
|
||||
--arg date "$DATE" \
|
||||
--arg url "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa" \
|
||||
--argjson size "$IPA_SIZE" \
|
||||
'.apps[0].version = $ver | .apps[0].versionDate = $date | .apps[0].downloadURL = $url | .apps[0].size = $size' \
|
||||
apps.json > apps.json.tmp && mv apps.json.tmp apps.json
|
||||
|
||||
echo "Updated apps.json:"
|
||||
cat apps.json
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
VERSION="${{ needs.get-version.outputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add apps.json
|
||||
git diff --cached --quiet && echo "No changes to commit" || \
|
||||
(git commit -m "chore: update AltStore source to ${VERSION}" && git push)
|
||||
|
||||
notify-telegram:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, create-release]
|
||||
@@ -404,6 +470,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download Android APK
|
||||
uses: actions/download-artifact@v7
|
||||
@@ -417,52 +485,43 @@ jobs:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
|
||||
- name: Extract changelog for version
|
||||
- name: Generate changelog with git-cliff for Telegram
|
||||
uses: orhun/git-cliff-action@v4
|
||||
with:
|
||||
config: cliff.toml
|
||||
args: --latest --strip all
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OUTPUT: /tmp/cliff_tg.txt
|
||||
|
||||
- name: Convert changelog for Telegram
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
VERSION_NUM=${VERSION#v}
|
||||
|
||||
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
|
||||
# Use tr -d '\r' to handle CRLF line endings from Windows
|
||||
FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
|
||||
|
||||
echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
|
||||
echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
|
||||
|
||||
if [ -z "$FULL_CHANGELOG" ]; then
|
||||
CHANGELOG="See release notes on GitHub for details."
|
||||
if [ ! -s /tmp/cliff_tg.txt ]; then
|
||||
echo "See release notes on GitHub for details." > /tmp/changelog.txt
|
||||
else
|
||||
# Convert GitHub Markdown to Telegram HTML:
|
||||
# - **text** → <b>text</b>
|
||||
# - `code` → <code>code</code>
|
||||
# - ### Header → <b>Header</b>
|
||||
# - Escape HTML special chars first
|
||||
# - Remove > blockquote prefix
|
||||
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
|
||||
sed 's/^> //' | \
|
||||
# Convert Markdown to Telegram HTML
|
||||
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
|
||||
sed '/^## [0-9][0-9.[:alpha:]-]*$/d' | \
|
||||
sed '/^\*\*Full Changelog\*\*/d' | \
|
||||
sed 's/ by \[@[^]]*\](https:\/\/github\.com\/[^)]*)//g' | \
|
||||
sed 's/ by @[A-Za-z0-9_-]\+//g' | \
|
||||
sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
|
||||
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
|
||||
sed 's/&/\&/g' | \
|
||||
sed 's/</\</g' | \
|
||||
sed 's/>/\>/g' | \
|
||||
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
|
||||
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
|
||||
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
|
||||
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
||||
sed 's/^- /• /g' | \
|
||||
sed 's/^ - / ◦ /g')
|
||||
|
||||
# Take first 2500 characters, then cut at last complete line
|
||||
sed 's/^- /• /g')
|
||||
|
||||
# Truncate for Telegram 4096 char limit
|
||||
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
||||
|
||||
# Check if truncated
|
||||
FULL_LEN=${#FULL_CHANGELOG}
|
||||
if [ $FULL_LEN -gt 2500 ]; then
|
||||
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
|
||||
fi
|
||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||
fi
|
||||
|
||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||
echo "DEBUG: Final changelog:"
|
||||
echo "Telegram changelog:"
|
||||
cat /tmp/changelog.txt
|
||||
|
||||
- name: Send to Telegram Channel
|
||||
@@ -504,7 +563,7 @@ jobs:
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM64_APK}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)"
|
||||
fi
|
||||
|
||||
# Upload arm32 APK to channel
|
||||
@@ -513,7 +572,7 @@ jobs:
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM32_APK}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - arm32"
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - arm32"
|
||||
fi
|
||||
|
||||
# Upload iOS IPA to channel
|
||||
@@ -523,7 +582,7 @@ jobs:
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${IOS_IPA}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)"
|
||||
fi
|
||||
|
||||
echo "Telegram notification sent!"
|
||||
|
||||
@@ -12,6 +12,9 @@ Thumbs.db
|
||||
# Kiro specs (development only)
|
||||
.kiro/
|
||||
|
||||
# Design assets (banners, mockups)
|
||||
design/
|
||||
|
||||
# Reference folder (development only)
|
||||
referensi/
|
||||
|
||||
@@ -41,6 +44,7 @@ go_backend/*.xcframework/
|
||||
# Android
|
||||
android/.gradle/
|
||||
android/app/libs/gobackend.aar
|
||||
android/app/libs/gobackend-sources.jar
|
||||
android/local.properties
|
||||
android/*.iml
|
||||
android/key.properties
|
||||
@@ -54,16 +58,22 @@ ios/Pods/
|
||||
ios/.symlinks/
|
||||
ios/Flutter/Flutter.framework/
|
||||
ios/Flutter/Flutter.podspec
|
||||
android/app/libs/gobackend-sources.jar
|
||||
|
||||
# Extension folder
|
||||
extension/
|
||||
extension/*
|
||||
extension/v2/
|
||||
extension/v2/**
|
||||
|
||||
# Agent instructions
|
||||
AGENTS.md
|
||||
|
||||
# Temp/misc
|
||||
.tmp/
|
||||
nul
|
||||
NUL
|
||||
network_requests.txt
|
||||
*.bak
|
||||
/AndroidManifest.xml
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
@@ -73,3 +83,7 @@ flutter_*.log
|
||||
# Development tools
|
||||
tool/
|
||||
.claude/settings.local.json
|
||||
.playwright-mcp/
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
# Changelog
|
||||
|
||||
## [3.7.2] - 2026-03-07
|
||||
|
||||
### Changed
|
||||
|
||||
- **Amazon Music is now an Extension**: Amazon Music has been moved from a built-in service to a separate installable extension. Install the "Amazon Music" extension from the Store to continue using it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Deezer Downloads Timing Out**: Deezer downloads were failing with "context deadline exceeded" on larger files. Now uses the proper download timeout, matching Tidal and Qobuz.
|
||||
- **iOS Local Library Scan Fails**: Local library scanning was failing on iOS because the app lost access to user-picked folders after the FilePicker session ended. Implemented iOS security-scoped bookmark system:
|
||||
- When a library folder is picked on iOS, a security-scoped bookmark is created and persisted in settings (`localLibraryBookmark`)
|
||||
- Before each scan, the bookmark is resolved and security-scoped access is started; access is released in `finally` block after scan completes
|
||||
- `cleanupMissingFiles` also activates the bookmark before checking file existence on iOS
|
||||
- New `AppDelegate.swift` method channel handlers: `createIosBookmarkFromPath`, `startAccessingIosBookmark`, `stopAccessingIosBookmark`, `resolveIosBookmark`
|
||||
- New `PlatformBridge` methods: `createIosBookmarkFromPath()`, `startAccessingIosBookmark()`, `stopAccessingIosBookmark()`
|
||||
- All scan call-sites (Library Settings, Queue tab, Local Album screen) now pass the iOS bookmark to `startScan()`
|
||||
|
||||
### Added
|
||||
|
||||
- **Amazon Music Extension**: Available in `extension/Amazon-SpotiFLAC/` — same functionality as before, now as an installable extension.
|
||||
- **Accessibility Tooltips**: Added localized tooltips to all `IconButton` and `PopupMenuButton` widgets across the entire UI for screen reader and long-press discoverability
|
||||
- Back buttons use `MaterialLocalizations.backButtonTooltip`
|
||||
- Close buttons use `MaterialLocalizations.closeButtonTooltip`
|
||||
- Menu buttons use `MaterialLocalizations.showMenuTooltip`
|
||||
- Search buttons use `MaterialLocalizations.searchFieldLabel`
|
||||
- Contextual actions use descriptive labels: "Play track", "Dismiss", "Clear search", "Change folder", "Refresh"
|
||||
- Screens affected: Album, Artist, Playlist, Downloaded Album, Local Album, Home, Search, Queue, Library Playlists, Library Tracks Folder, Setup, Tutorial, Track Metadata, Store, Extension Store Details, and all Settings sub-pages (About, Appearance, Cache Management, Donate, Download, Extensions, Extension Detail, Library, Log, Options, Provider Priority)
|
||||
- **Semantics Wrappers**: Added `Semantics` widgets to interactive elements that previously had no accessibility information
|
||||
- Album tiles in Artist screen: announces selection state and album name
|
||||
- Recently downloaded track tiles in Home tab: announces track name and artist
|
||||
- Explore items (albums/artists/playlists) in Home tab: announces item type and name
|
||||
- Color palette picker in Appearance settings: announces selected state and color hex value
|
||||
- Download button demo in Tutorial screen: added `ExcludeSemantics` on icon to prevent duplicate screen reader announcements
|
||||
- Queue tab playlist cards: announces playlist name and item count
|
||||
- Queue tab downloaded album cards: announces album name, artist, and track count
|
||||
- Queue tab local album cards: announces album name, artist, and track count
|
||||
- Queue tab play button on completed downloads: announces track name and artist with `ExcludeSemantics` on icon
|
||||
- Queue tab download status indicators: "Finalizing download", "Download completed", "Downloaded file missing" labels with `ExcludeSemantics` on icons
|
||||
|
||||
### Improved
|
||||
|
||||
- **Code Formatting**: Reformatted and corrected indentation across multiple files to comply with Dart style guidelines
|
||||
- `extension_detail_page.dart`: Fixed `SliverAppBar` and all subsequent slivers indentation (was 2 spaces short)
|
||||
- `log_screen.dart`: Fixed `SliverAppBar` indentation alignment
|
||||
- `donate_page.dart`: Reformatted ternary expressions and `_cr` function body
|
||||
- `library_tracks_folder_screen.dart`: Minor line-break formatting
|
||||
|
||||
---
|
||||
|
||||
## [3.7.1] - 2026-03-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Deezer Download Service**: Deezer is now available as a built-in download service (FLAC CD Quality).
|
||||
- **Smarter YouTube Downloads**: If the YouTube Music extension is installed, the app now uses it first to find the correct song — more accurate than SongLink, especially for new releases.
|
||||
- **Songs-Only Search Filter**: YouTube Music extension search now filters results server-side, so you only get actual songs — no music videos or covers mixed in.
|
||||
- **Qobuz Squid.wtf Fallback**: Added Squid.wtf as an additional Qobuz download provider.
|
||||
- **Qobuz Search Fallback**: If Qobuz API search returns nothing, the app now tries the Qobuz web store as a backup to find the track.
|
||||
- **Better ISRC Lookup**: Tracks can now be resolved via ISRC even without a Spotify ID, using Deezer as an intermediary.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Download Queue Stability**: Fixed duplicate queue item IDs, cancel not working reliably, and "Clear All" not properly stopping active downloads.
|
||||
- **Queue Restore on Restart**: Duplicate or broken queue item IDs are now auto-fixed when the app restarts.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Update Checker**: The app can now detect updates across all versions, not just within the same major version.
|
||||
- **Localization Cleanup**: Cleaned up and consolidated translation files across all 13 supported languages.
|
||||
|
||||
---
|
||||
|
||||
## [3.7.0] - 2026-03-04
|
||||
|
||||
Hey everyone, thank you so much for sticking with SpotiFLAC Mobile.
|
||||
@@ -262,7 +334,7 @@ Thank you for your understanding and continued support. This decision was made t
|
||||
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
||||
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
||||
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
||||
- New backend client for `spotify.afkarxyz.fun/api`
|
||||
- New backend client for `sp.afkarxyz.qzz.io/api`
|
||||
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
||||
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
||||
- Includes heuristic detection of lyrics stored in Comment fields
|
||||
@@ -277,7 +349,7 @@ Thank you for your understanding and continued support. This decision was made t
|
||||
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
||||
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
||||
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
||||
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||
- Amazon now uses the new `amzn.afkarxyz.qzz.io` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
||||
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
||||
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
||||
|
||||
@@ -86,17 +86,31 @@ Translation files are located in `lib/l10n/arb/`.
|
||||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||
```
|
||||
|
||||
3. **Install dependencies**
|
||||
3. **Use FVM (Flutter Version: 3.41.5)**
|
||||
```bash
|
||||
fvm use
|
||||
```
|
||||
|
||||
4. **Install dependencies**
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
4. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||
5. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
5. **Run the app**
|
||||
6. **Set up Go environment (Go Version: 1.25.7)**
|
||||
```bash
|
||||
cd go_backend
|
||||
mkdir -p ../android/app/libs
|
||||
gomobile init
|
||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
cd ..
|
||||
```
|
||||
|
||||
7. **Run the app**
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
@@ -1,102 +1,183 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="icon.png" width="128" />
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/readme/banner-readme-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/readme/banner-readme-light.png">
|
||||
<img alt="SpotiFLAC Mobile" src="assets/readme/banner-readme-light.png" width="650" height="auto">
|
||||
</picture>
|
||||
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
|
||||

|
||||

|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/25971" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/25971" alt="spotiflacapp%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
<div align="center">
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/images/1.jpg?v=2" width="200" />
|
||||
<img src="assets/images/2.jpg?v=2" width="200" />
|
||||
<img src="assets/images/3.jpg?v=2" width="200" />
|
||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||
</p>
|
||||
|
||||
## Extensions
|
||||
|
||||
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||
|
||||
### Installing Extensions
|
||||
1. Go to **Store** tab in the app
|
||||
2. Browse and install extensions with one tap
|
||||
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||
4. Configure extension settings if needed
|
||||
5. Set provider priority in **Settings > Extensions > Provider Priority**
|
||||
|
||||
### Developing Extensions
|
||||
Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
|
||||
|
||||
## Other project
|
||||
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||
|
||||
## Telegram
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
[](https://t.me/spotiflac)
|
||||
[](https://t.me/spotiflac_chat)
|
||||
|
||||
</div>
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/readme/1.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/2.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/3.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/4.jpg?v=2" width="200" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Extensions
|
||||
|
||||
Extensions let the community add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||
|
||||
### Installing Extensions
|
||||
|
||||
1. Open the **Store** tab in the app
|
||||
2. On first launch, enter an **Extension Repository URL** when prompted
|
||||
3. Browse and install extensions with one tap
|
||||
4. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||
5. Configure extension settings if needed
|
||||
6. Set provider priority under **Settings > Extensions > Provider Priority**
|
||||
|
||||
### Developing Extensions
|
||||
|
||||
> [!NOTE]
|
||||
> Want to build your own extension? The [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) has everything you need.
|
||||
|
||||
---
|
||||
|
||||
## Related Projects
|
||||
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
Download music in true lossless FLAC from extension-provided sources on Windows, macOS & Linux.
|
||||
|
||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Why is my download failing with "Song not found"?**
|
||||
A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.
|
||||
<details>
|
||||
<summary><b>Why does the Store tab ask me to enter a URL?</b></summary>
|
||||
<br>
|
||||
|
||||
**Q: Why are some tracks downloading in lower quality?**
|
||||
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
|
||||
Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository system extensions are hosted on GitHub repositories rather than a built-in server, so anyone can create and host their own. Enter a repository URL in the Store tab to browse and install extensions.
|
||||
|
||||
**Q: Can I download playlists?**
|
||||
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||
</details>
|
||||
|
||||
**Q: Why do I need to grant storage permission?**
|
||||
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
|
||||
<details>
|
||||
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||
<br>
|
||||
|
||||
**Q: Is this app safe?**
|
||||
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
|
||||
The track may not be available from your enabled providers. Try enabling more providers under **Settings > Extensions > Provider Priority**, or install additional download extensions from the Store.
|
||||
|
||||
**Q: Why is download not working in my country?**
|
||||
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||
<br>
|
||||
|
||||
### Want to support SpotiFLAC-Mobile?
|
||||
Quality depends on what's available from the source and the installed download extension. Check each extension's quality options and service notes in the app.
|
||||
|
||||
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
|
||||
</details>
|
||||
|
||||
[](https://ko-fi.com/zarzet)
|
||||
<details>
|
||||
<summary><b>Can I download playlists?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||
|
||||
## Disclaimer
|
||||
</details>
|
||||
|
||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||
<details>
|
||||
<summary><b>Why do I need to grant storage permission?</b></summary>
|
||||
<br>
|
||||
|
||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service.
|
||||
The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant **All files access** under **Settings > Apps > SpotiFLAC > Permissions**.
|
||||
|
||||
The application is purely a user interface that facilitates communication between your device and existing third-party services.
|
||||
</details>
|
||||
|
||||
You are solely responsible for:
|
||||
1. Ensuring your use of this software complies with your local laws.
|
||||
2. Reading and adhering to the Terms of Service of the respective platforms.
|
||||
3. Any legal consequences resulting from the misuse of this tool.
|
||||
<details>
|
||||
<summary><b>Is this app safe?</b></summary>
|
||||
<br>
|
||||
|
||||
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
|
||||
Yes SpotiFLAC is open source and you can verify the code yourself. Each release is also scanned with VirusTotal (see badge above).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why is downloading not working in my country?</b></summary>
|
||||
<br>
|
||||
|
||||
Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I add SpotiFLAC to AltStore or SideStore?</b></summary>
|
||||
<br>
|
||||
|
||||
Yes! Add the official source to receive updates directly within the app. Copy this link:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/refs/heads/main/apps.json
|
||||
```
|
||||
|
||||
In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link.
|
||||
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> If SpotiFLAC is useful to you, consider supporting development:
|
||||
>
|
||||
> [](https://ko-fi.com/zarzet)
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to everyone who has contributed to SpotiFLAC Mobile!
|
||||
|
||||
<a href="https://github.com/zarzet/SpotiFLAC-Mobile/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=zarzet/SpotiFLAC-Mobile" />
|
||||
</a>
|
||||
|
||||
We also appreciate everyone who helped with [translations on Crowdin](https://crowdin.com/project/spotiflac-mobile), reported bugs, suggested features, and spread the word.
|
||||
|
||||
Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md) to get started!
|
||||
|
||||
---
|
||||
|
||||
## API Credits
|
||||
|
||||
[hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify)
|
||||
| | | | | |
|
||||
|---|---|---|---|---|
|
||||
| [MusicDL](https://www.musicdl.me) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) | [Song.link](https://song.link) |
|
||||
| [IDHS](https://github.com/sjdonado/idonthavespotify) | | | | |
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This repository and its contents are provided strictly for educational and research purposes. The software is provided "as-is" without warranty of any kind, express or implied, as stated in the [MIT License](LICENSE).
|
||||
|
||||
- No copyrighted content is hosted, stored, mirrored, or distributed by this repository.
|
||||
- Users must ensure that their use of this software is properly authorized and complies with all applicable laws, regulations, and third-party terms of service.
|
||||
- This software is provided free of charge by the maintainer. If you paid a third party for access to this software in its original form from this repository, you may have been misled or scammed. Any redistribution or commercial use by third parties must comply with the terms of the repository license. No affiliation, endorsement, or support by the maintainer is implied unless explicitly stated in writing.
|
||||
- SpotiFLAC Mobile is an independent project. It is not affiliated with, endorsed by, or connected to any other project or version on other platforms that may share a similar name. The maintainer of this repository has no control over or responsibility for third-party projects.
|
||||
- The author(s) disclaim all liability for any direct, indirect, incidental, or consequential damages arising from the use or misuse of this software. Users assume all risk associated with its use.
|
||||
- If you are a copyright holder or authorized representative and believe this repository infringes upon your rights, please contact the maintainer with sufficient detail (including relevant URLs and proof of ownership). The matter will be promptly investigated and appropriate action will be taken, which may include removal of the referenced material.
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||
> **Star the repo** to get notified about all new releases directly from GitHub.
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
plugins:
|
||||
riverpod_lint: 3.1.4-dev.3
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- .dart_tool/**
|
||||
- lib/**/*.g.dart
|
||||
- lib/l10n/*.dart
|
||||
language:
|
||||
strict-casts: true
|
||||
strict-inference: true
|
||||
strict-raw-types: true
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
@@ -23,6 +36,13 @@ linter:
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
always_declare_return_types: true
|
||||
avoid_dynamic_calls: true
|
||||
avoid_types_as_parameter_names: true
|
||||
strict_top_level_inference: true
|
||||
type_annotate_public_apis: true
|
||||
cancel_subscriptions: true
|
||||
close_sinks: true
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.zarz.spotiflac"
|
||||
compileSdk flutter.compileSdkVersion
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.zarz.spotiflac"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Go backend library (gomobile generated)
|
||||
implementation fileTree(dir: 'libs', include: ['*.aar'])
|
||||
|
||||
// Kotlin coroutines for async Go backend calls
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
}
|
||||
@@ -17,18 +17,22 @@ if (keystorePropertiesFile.exists()) {
|
||||
|
||||
android {
|
||||
namespace = "com.zarz.spotiflac"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
compileSdk = 37
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
sourceCompatibility = JavaVersion.VERSION_25
|
||||
targetCompatibility = JavaVersion.VERSION_25
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +50,7 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "com.zarz.spotiflac"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = 36
|
||||
targetSdk = 37
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
multiDexEnabled = true
|
||||
@@ -57,6 +61,20 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
|
||||
getByName("profile") {
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
|
||||
release {
|
||||
// For local builds: use release signing if key.properties exists
|
||||
// For CI builds: APK is signed by GitHub Action after build
|
||||
@@ -71,6 +89,9 @@ android {
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +122,9 @@ dependencies {
|
||||
// Include all AAR and JAR files from libs folder
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0-beta02")
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation("androidx.activity:activity-ktx:1.12.3")
|
||||
implementation("androidx.activity:activity-ktx:1.13.0")
|
||||
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:label="SpotiFLAC"
|
||||
android:label="SpotiFLAC Mobile"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="false"
|
||||
@@ -86,6 +86,26 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="music.youtube.com" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Extension OAuth (PKCE) redirect: spotiflac://callback?code=...&state=<extension_id> -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="callback" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="spotify-callback" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="session-grant" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Download Service -->
|
||||
@@ -94,16 +114,15 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- Audio playback service for media notification / background audio -->
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
android:enabled="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
@@ -128,6 +147,10 @@
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<!-- FileProvider for APK installation -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.example.temp_project
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -0,0 +1,496 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Shared SAF download wrapper for foreground activity calls and service-owned
|
||||
* native workers.
|
||||
*/
|
||||
object SafDownloadHandler {
|
||||
private val safDirLock = Any()
|
||||
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
|
||||
private const val STAGED_SAF_MIME_TYPE = "application/octet-stream"
|
||||
|
||||
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
|
||||
val req = JSONObject(requestJson)
|
||||
val storageMode = req.optString("storage_mode", "")
|
||||
val treeUriStr = req.optString("saf_tree_uri", "")
|
||||
if (storageMode != "saf" || treeUriStr.isBlank()) {
|
||||
return downloader(requestJson)
|
||||
}
|
||||
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
|
||||
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
|
||||
val mimeType = mimeTypeForExt(outputExt)
|
||||
val fileName = buildSafFileName(req, outputExt)
|
||||
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
|
||||
val useStagedOutput = req.optBoolean("stage_saf_output", false) && !deferSafPublish
|
||||
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName
|
||||
val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType
|
||||
|
||||
val existingDir = findDocumentDir(context, treeUri, relativeDir)
|
||||
if (existingDir != null) {
|
||||
val existing = existingDir.findFile(fileName)
|
||||
if (existing != null && existing.isFile && existing.length() > 0) {
|
||||
if (useStagedOutput || deferSafPublish) {
|
||||
deleteStaleStagedFiles(existingDir, fileName, outputExt)
|
||||
}
|
||||
val obj = JSONObject()
|
||||
obj.put("success", true)
|
||||
obj.put("message", "File already exists")
|
||||
obj.put("file_path", existing.uri.toString())
|
||||
obj.put("file_name", existing.name ?: fileName)
|
||||
obj.put("already_exists", true)
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
|
||||
val targetDir = ensureDocumentDir(context, treeUri, relativeDir)
|
||||
?: return errorJson("Failed to access SAF directory")
|
||||
|
||||
if (deferSafPublish) {
|
||||
deleteStaleStagedFiles(targetDir, fileName, outputExt)
|
||||
val workingExt = outputExt.ifBlank { ".tmp" }
|
||||
val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir)
|
||||
Log.i("SpotiFLAC", "SAF deferred native output: target=$fileName working=${workingFile.name}")
|
||||
return try {
|
||||
req.put("output_path", workingFile.absolutePath)
|
||||
req.put("output_ext", outputExt)
|
||||
req.remove("output_fd")
|
||||
val response = downloader(req.toString())
|
||||
val respObj = JSONObject(response)
|
||||
if (respObj.optBoolean("success", false)) {
|
||||
val reportedPath = respObj.optString("file_path", "").trim()
|
||||
if (reportedPath.isEmpty() || reportedPath.startsWith("/proc/self/fd/")) {
|
||||
respObj.put("file_path", workingFile.absolutePath)
|
||||
} else if (reportedPath != workingFile.absolutePath) {
|
||||
workingFile.delete()
|
||||
}
|
||||
respObj.put("file_name", respObj.optString("file_name", "").ifBlank { fileName })
|
||||
respObj.put("saf_deferred_publish", true)
|
||||
respObj.put("saf_final_file_name", fileName)
|
||||
respObj.put("saf_relative_dir", relativeDir)
|
||||
respObj.put("saf_tree_uri", treeUriStr)
|
||||
respObj.put("saf_output_ext", outputExt)
|
||||
respObj.put("saf_final_mime_type", mimeType)
|
||||
} else {
|
||||
workingFile.delete()
|
||||
}
|
||||
respObj.toString()
|
||||
} catch (e: Exception) {
|
||||
workingFile.delete()
|
||||
errorJson("SAF deferred download failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
|
||||
?: return errorJson("Failed to create SAF file")
|
||||
|
||||
val pfd = context.contentResolver.openFileDescriptor(document.uri, "rw")
|
||||
?: return errorJson("Failed to open SAF file")
|
||||
|
||||
var detachedFd: Int? = null
|
||||
try {
|
||||
detachedFd = pfd.detachFd()
|
||||
req.put("output_path", "")
|
||||
req.put("output_fd", detachedFd)
|
||||
req.put("output_ext", outputExt)
|
||||
val response = downloader(req.toString())
|
||||
val respObj = JSONObject(response)
|
||||
if (respObj.optBoolean("success", false)) {
|
||||
val goFilePath = respObj.optString("file_path", "")
|
||||
if (goFilePath.isNotEmpty() &&
|
||||
!goFilePath.startsWith("content://") &&
|
||||
!goFilePath.startsWith("/proc/self/fd/")
|
||||
) {
|
||||
try {
|
||||
val srcFile = File(goFilePath)
|
||||
if (!srcFile.exists() || srcFile.length() <= 0) {
|
||||
throw IllegalStateException("extension output missing or empty: $goFilePath")
|
||||
}
|
||||
val actualExt = normalizeExt(srcFile.extension)
|
||||
if (actualExt.isNotBlank()) {
|
||||
respObj.put("actual_extension", actualExt)
|
||||
}
|
||||
if (actualExt.isNotBlank() && actualExt != outputExt) {
|
||||
val actualFileName = buildSafFileName(req, actualExt)
|
||||
val actualStagedFileName = if (useStagedOutput) {
|
||||
buildStagedSafFileName(actualFileName)
|
||||
} else {
|
||||
actualFileName
|
||||
}
|
||||
val actualMimeType = mimeTypeForExt(actualExt)
|
||||
val replacement = createOrReuseDocumentFile(
|
||||
targetDir,
|
||||
if (useStagedOutput) STAGED_SAF_MIME_TYPE else actualMimeType,
|
||||
actualStagedFileName
|
||||
) ?: throw IllegalStateException(
|
||||
"failed to create SAF output with actual extension"
|
||||
)
|
||||
if (replacement.uri != document.uri) {
|
||||
document.delete()
|
||||
document = replacement
|
||||
}
|
||||
}
|
||||
context.contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
||||
srcFile.inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: throw IllegalStateException("failed to open SAF output stream")
|
||||
srcFile.delete()
|
||||
} catch (e: Exception) {
|
||||
document.delete()
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"Failed to copy extension output to SAF: ${e.message}"
|
||||
)
|
||||
return errorJson("Failed to copy extension output to SAF: ${e.message}")
|
||||
}
|
||||
}
|
||||
respObj.put("file_path", document.uri.toString())
|
||||
respObj.put("file_name", document.name ?: fileName)
|
||||
if (useStagedOutput) {
|
||||
respObj.put("saf_staged_output", true)
|
||||
respObj.put("saf_staged_file_name", document.name ?: stagedFileName)
|
||||
}
|
||||
} else {
|
||||
document.delete()
|
||||
}
|
||||
return respObj.toString()
|
||||
} catch (e: Exception) {
|
||||
document.delete()
|
||||
return errorJson("SAF download failed: ${e.message}")
|
||||
} finally {
|
||||
if (detachedFd == null) {
|
||||
try {
|
||||
pfd.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun copyContentUriToTemp(context: Context, uriStr: String): String? {
|
||||
return try {
|
||||
val uri = Uri.parse(uriStr)
|
||||
val extension = DocumentFile.fromSingleUri(context, uri)
|
||||
?.name
|
||||
?.substringAfterLast('.', "")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { ".$it" }
|
||||
?: ".tmp"
|
||||
val temp = File.createTempFile("native_saf_", extension, context.cacheDir)
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
temp.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: return null
|
||||
temp.absolutePath
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "Failed to copy SAF URI to temp: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun writeFileToSaf(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
srcPath: String
|
||||
): String? {
|
||||
var stagedDocument: DocumentFile? = null
|
||||
return try {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
|
||||
val finalName = sanitizeFilename(fileName)
|
||||
val ext = normalizeExt(finalName.substringAfterLast('.', ""))
|
||||
val stagedName = buildStagedSafFileName(finalName)
|
||||
deleteStaleStagedFiles(targetDir, finalName, ext)
|
||||
val document = createOrReuseDocumentFile(targetDir, STAGED_SAF_MIME_TYPE, stagedName)
|
||||
?: return null
|
||||
stagedDocument = document
|
||||
val outputStream = context.contentResolver.openOutputStream(document.uri, "wt")
|
||||
if (outputStream == null) {
|
||||
document.delete()
|
||||
stagedDocument = null
|
||||
return null
|
||||
}
|
||||
outputStream.use { output ->
|
||||
File(srcPath).inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
val existingFinal = targetDir.findFile(finalName)
|
||||
if (existingFinal != null && existingFinal.uri != document.uri) {
|
||||
existingFinal.delete()
|
||||
}
|
||||
if (!document.renameTo(finalName)) {
|
||||
document.delete()
|
||||
return null
|
||||
}
|
||||
stagedDocument = null
|
||||
targetDir.findFile(finalName)?.uri?.toString() ?: document.uri.toString()
|
||||
} catch (e: Exception) {
|
||||
stagedDocument?.delete()
|
||||
android.util.Log.w("SpotiFLAC", "Failed to write file to SAF: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteContentUri(context: Context, uriStr: String): Boolean {
|
||||
return try {
|
||||
DocumentFile.fromSingleUri(context, Uri.parse(uriStr))?.delete() == true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeExt(ext: String?): String {
|
||||
if (ext.isNullOrBlank()) return ""
|
||||
return if (ext.startsWith(".")) {
|
||||
ext.lowercase(Locale.ROOT)
|
||||
} else {
|
||||
".${ext.lowercase(Locale.ROOT)}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun mimeTypeForExt(ext: String?): String {
|
||||
return when (normalizeExt(ext)) {
|
||||
".m4a", ".mp4" -> "audio/mp4"
|
||||
".mp3" -> "audio/mpeg"
|
||||
".opus" -> "audio/ogg"
|
||||
".flac" -> "audio/flac"
|
||||
".lrc" -> "application/octet-stream"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
private fun forceFilenameExt(name: String, outputExt: String): String {
|
||||
val normalizedExt = normalizeExt(outputExt)
|
||||
if (normalizedExt.isBlank()) return sanitizeFilename(name)
|
||||
|
||||
val safeName = sanitizeFilename(name)
|
||||
val lower = safeName.lowercase(Locale.ROOT)
|
||||
val knownExts = listOf(".flac", ".m4a", ".mp4", ".mp3", ".opus", ".lrc")
|
||||
for (knownExt in knownExts) {
|
||||
if (lower.endsWith(knownExt)) {
|
||||
return safeName.dropLast(knownExt.length) + normalizedExt
|
||||
}
|
||||
}
|
||||
return safeName + normalizedExt
|
||||
}
|
||||
|
||||
private fun buildStagedSafFileName(fileName: String): String {
|
||||
val safeName = sanitizeFilename(fileName)
|
||||
return "$safeName.partial"
|
||||
}
|
||||
|
||||
private fun buildLegacyStagedSafFileName(fileName: String, outputExt: String): String {
|
||||
val safeName = sanitizeFilename(fileName)
|
||||
val ext = normalizeExt(outputExt)
|
||||
if (ext.isNotBlank() && safeName.lowercase(Locale.ROOT).endsWith(ext)) {
|
||||
return safeName.dropLast(ext.length).trimEnd('.', ' ') + ".partial$ext"
|
||||
}
|
||||
val dot = safeName.lastIndexOf('.')
|
||||
if (dot > 0 && dot < safeName.lastIndex) {
|
||||
return safeName.substring(0, dot).trimEnd('.', ' ') +
|
||||
".partial" +
|
||||
safeName.substring(dot)
|
||||
}
|
||||
return "$safeName.partial"
|
||||
}
|
||||
|
||||
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
|
||||
val stagedNames = linkedSetOf(
|
||||
buildStagedSafFileName(fileName),
|
||||
buildLegacyStagedSafFileName(fileName, outputExt)
|
||||
)
|
||||
for (stagedName in stagedNames) {
|
||||
try {
|
||||
parent.findFile(stagedName)?.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeFilename(name: String): String {
|
||||
var sanitized = name
|
||||
.replace("/", " ")
|
||||
.replace(Regex("[\\\\:*?\"<>|]"), " ")
|
||||
.filter { ch ->
|
||||
val code = ch.code
|
||||
!((code < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') ||
|
||||
code == 0x7F ||
|
||||
(Character.isISOControl(ch) && ch != '\t' && ch != '\n' && ch != '\r'))
|
||||
}
|
||||
.trim()
|
||||
.trim('.', ' ')
|
||||
|
||||
sanitized = sanitized
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.replace(Regex("_+"), "_")
|
||||
.trim('_', ' ')
|
||||
|
||||
sanitized = truncateSafDisplayName(sanitized, MAX_SAF_DISPLAY_NAME_UTF8_BYTES)
|
||||
sanitized = sanitized.trim().trim('.', ' ').trim('_', ' ')
|
||||
return if (sanitized.isBlank()) "Unknown" else sanitized
|
||||
}
|
||||
|
||||
private fun truncateSafDisplayName(name: String, maxBytes: Int): String {
|
||||
if (maxBytes <= 0 || name.toByteArray(Charsets.UTF_8).size <= maxBytes) return name
|
||||
|
||||
val dotIndex = name.lastIndexOf('.')
|
||||
val ext = if (
|
||||
dotIndex > 0 &&
|
||||
dotIndex < name.length - 1 &&
|
||||
name.length - dotIndex <= 10
|
||||
) {
|
||||
name.substring(dotIndex)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val stem = if (ext.isNotEmpty()) name.substring(0, dotIndex) else name
|
||||
val maxStemBytes = (maxBytes - ext.toByteArray(Charsets.UTF_8).size).coerceAtLeast(1)
|
||||
return truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').trim('_', ' ') + ext
|
||||
}
|
||||
|
||||
private fun truncateUtf8Bytes(value: String, maxBytes: Int): String {
|
||||
if (maxBytes <= 0 || value.toByteArray(Charsets.UTF_8).size <= maxBytes) return value
|
||||
|
||||
val builder = StringBuilder()
|
||||
var usedBytes = 0
|
||||
var index = 0
|
||||
while (index < value.length) {
|
||||
val codePoint = value.codePointAt(index)
|
||||
val char = String(Character.toChars(codePoint))
|
||||
val charBytes = char.toByteArray(Charsets.UTF_8).size
|
||||
if (usedBytes + charBytes > maxBytes) break
|
||||
builder.append(char)
|
||||
usedBytes += charBytes
|
||||
index += Character.charCount(codePoint)
|
||||
}
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private fun sanitizeRelativeDir(relativeDir: String): String {
|
||||
if (relativeDir.isBlank()) return ""
|
||||
return relativeDir
|
||||
.split("/")
|
||||
.map { sanitizeFilename(it) }
|
||||
.filter { it.isNotBlank() && it != "." && it != ".." }
|
||||
.joinToString("/")
|
||||
}
|
||||
|
||||
private fun ensureDocumentDir(
|
||||
context: Context,
|
||||
treeUri: Uri,
|
||||
relativeDir: String
|
||||
): DocumentFile? {
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
if (safeRelativeDir.isBlank()) {
|
||||
return DocumentFile.fromTreeUri(context, treeUri)
|
||||
}
|
||||
|
||||
synchronized(safDirLock) {
|
||||
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
|
||||
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||
for (part in parts) {
|
||||
val existing = current.findFile(part)
|
||||
current = if (existing != null && existing.isDirectory) {
|
||||
existing
|
||||
} else {
|
||||
val created = current.createDirectory(part) ?: return null
|
||||
val createdName = created.name ?: part
|
||||
if (createdName != part) {
|
||||
created.delete()
|
||||
current.findFile(part) ?: return null
|
||||
} else {
|
||||
created
|
||||
}
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
private fun findDocumentDir(
|
||||
context: Context,
|
||||
treeUri: Uri,
|
||||
relativeDir: String
|
||||
): DocumentFile? {
|
||||
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
if (safeRelativeDir.isBlank()) return current
|
||||
|
||||
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
||||
for (part in parts) {
|
||||
val existing = current.findFile(part)
|
||||
if (existing == null || !existing.isDirectory) return null
|
||||
current = existing
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private fun createOrReuseDocumentFile(
|
||||
parent: DocumentFile,
|
||||
mimeType: String,
|
||||
fileName: String
|
||||
): DocumentFile? {
|
||||
val safeFileName = sanitizeFilename(fileName)
|
||||
if (safeFileName.isBlank()) return null
|
||||
|
||||
synchronized(safDirLock) {
|
||||
val existing = parent.findFile(safeFileName)
|
||||
if (existing != null && existing.isFile) {
|
||||
return existing
|
||||
}
|
||||
|
||||
val created = parent.createFile(mimeType, safeFileName) ?: return null
|
||||
val createdName = created.name ?: safeFileName
|
||||
if (createdName == safeFileName) {
|
||||
return created
|
||||
}
|
||||
|
||||
val winner = parent.findFile(safeFileName)
|
||||
if (winner != null && winner.isFile) {
|
||||
if (winner.uri != created.uri) {
|
||||
try {
|
||||
created.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
return winner
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
||||
val provided = req.optString("saf_file_name", "")
|
||||
if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
|
||||
|
||||
val trackName = req.optString("track_name", "track")
|
||||
val artistName = req.optString("artist_name", "")
|
||||
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
|
||||
return forceFilenameExt(baseName, outputExt)
|
||||
}
|
||||
|
||||
private fun errorJson(message: String): String {
|
||||
val obj = JSONObject()
|
||||
obj.put("success", false)
|
||||
obj.put("error", message)
|
||||
obj.put("message", message)
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -1,12 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -1,12 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
||||
@@ -6,4 +6,9 @@
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
<monochrome>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_monochrome"
|
||||
android:inset="16%" />
|
||||
</monochrome>
|
||||
</adaptive-icon>
|
||||
|
||||
|
Before Width: | Height: | Size: 954 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 647 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -1,17 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="media" />
|
||||
</automotiveApp>
|
||||
@@ -11,8 +11,8 @@ subprojects {
|
||||
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
sourceCompatibility = JavaVersion.VERSION_25
|
||||
targetCompatibility = JavaVersion.VERSION_25
|
||||
}
|
||||
|
||||
// Enable multidex for all subprojects
|
||||
@@ -27,7 +27,7 @@ subprojects {
|
||||
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-all.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||
|
||||
@@ -19,8 +19,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "SpotiFLAC Mobile Source",
|
||||
"identifier": "com.zarzet.spotiflac.source",
|
||||
"subtitle": "FLAC Downloader for iOS",
|
||||
"apps": [
|
||||
{
|
||||
"name": "SpotiFLAC Mobile",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "4.7.0",
|
||||
"versionDate": "2026-06-30",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.7.0/SpotiFLAC-v4.7.0-ios-unsigned.ipa",
|
||||
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 37442462
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 539 KiB After Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 811 KiB After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,103 @@
|
||||
# git-cliff configuration for SpotiFLAC Mobile
|
||||
# https://git-cliff.org/docs/configuration
|
||||
|
||||
[changelog]
|
||||
# Template for the changelog body
|
||||
body = """
|
||||
{%- macro remote_url() -%}
|
||||
https://github.com/zarzet/SpotiFLAC-Mobile
|
||||
{%- endmacro -%}
|
||||
|
||||
{% if version %}\
|
||||
## {{ version | trim_start_matches(pat="v") }}
|
||||
{% else %}\
|
||||
## Unreleased
|
||||
{% endif %}\
|
||||
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
|
||||
{{ commit.message | upper_first }}\
|
||||
{% if commit.github.pr_number %} \
|
||||
([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
|
||||
{% endif %}\
|
||||
{%- if commit.github.username and commit.github.username != "zarzet" %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
|
||||
{%- endfor %}
|
||||
{% endfor %}
|
||||
|
||||
{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
|
||||
|
||||
### New Contributors
|
||||
{%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
|
||||
* @{{ contributor.username }} made their first contribution
|
||||
{%- if contributor.pr_number %} in \
|
||||
[#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endif -%}
|
||||
|
||||
{% if version %}
|
||||
{% if previous.version %}
|
||||
**Full Changelog**: [{{ previous.version }}...{{ version }}]({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})
|
||||
{% endif %}
|
||||
{% else -%}
|
||||
{% raw %}\n{% endraw %}
|
||||
{% endif %}
|
||||
"""
|
||||
# Remove leading and trailing whitespace
|
||||
trim = true
|
||||
|
||||
[git]
|
||||
# Parse conventional commits
|
||||
conventional_commits = true
|
||||
filter_unconventional = true
|
||||
|
||||
# Process each line of a commit as an individual commit
|
||||
split_commits = false
|
||||
|
||||
# Regex for preprocessing the commit messages
|
||||
commit_preprocessors = [
|
||||
# Strip conventional commit prefix for cleaner messages
|
||||
# (group header already shows the type)
|
||||
]
|
||||
|
||||
# Regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
# Skip noise: translation commits from Crowdin
|
||||
{ message = "^New translations", skip = true },
|
||||
{ message = "^Update source file", skip = true },
|
||||
# Skip merge commits
|
||||
{ message = "^Merge", skip = true },
|
||||
# Skip version bump commits
|
||||
{ message = "^v\\d+", skip = true },
|
||||
{ message = "^chore: update VirusTotal", skip = true },
|
||||
|
||||
# Group by conventional commit type
|
||||
{ message = "^feat", group = "<!-- 0 -->New Features" },
|
||||
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
|
||||
{ message = "^perf", group = "<!-- 2 -->Performance" },
|
||||
{ message = "^refactor", group = "<!-- 3 -->Refactoring" },
|
||||
{ message = "^doc", group = "<!-- 4 -->Documentation" },
|
||||
{ message = "^style", group = "<!-- 5 -->Styling" },
|
||||
{ message = "^test", group = "<!-- 6 -->Testing" },
|
||||
{ message = "^chore\\(deps\\)", group = "<!-- 7 -->Dependencies" },
|
||||
{ message = "^chore\\(l10n\\)", skip = true },
|
||||
{ message = "^chore|^ci", group = "<!-- 8 -->Chores" },
|
||||
]
|
||||
|
||||
# Protect breaking changes from being skipped
|
||||
protect_breaking_commits = true
|
||||
|
||||
# Filter out commits by matching patterns
|
||||
filter_commits = false
|
||||
|
||||
# Tag pattern for version detection
|
||||
tag_pattern = "v[0-9].*"
|
||||
|
||||
# Sort commits by newest first
|
||||
sort_commits = "newest"
|
||||
|
||||
[remote.github]
|
||||
owner = "zarzet"
|
||||
repo = "SpotiFLAC-Mobile"
|
||||
@@ -3,17 +3,21 @@ files:
|
||||
translation: /lib/l10n/arb/app_%locale%.arb
|
||||
languages_mapping:
|
||||
locale:
|
||||
# Short codes for single-variant languages
|
||||
# Keys MUST be the project's Crowdin language ids; values are the
|
||||
# %locale% suffix used in app_%locale%.arb (underscores so Flutter
|
||||
# gen-l10n parses them — hyphenated filenames break gen-l10n).
|
||||
ar: ar
|
||||
de: de
|
||||
es: es
|
||||
es-ES: es_ES
|
||||
fr: fr
|
||||
hi: hi
|
||||
id: id
|
||||
ja: ja
|
||||
ko: ko
|
||||
nl: nl
|
||||
pt: pt
|
||||
pt-PT: pt_PT
|
||||
ru: ru
|
||||
# Full codes for Chinese variants
|
||||
tr: tr
|
||||
uk: uk
|
||||
zh-CN: zh_CN
|
||||
zh-TW: zh_TW
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// mp4Box is a minimal ISO-BMFF / QuickTime box view over an in-memory buffer.
|
||||
type mp4Box struct {
|
||||
offset int64
|
||||
size int64
|
||||
hdr int64
|
||||
typ string
|
||||
}
|
||||
|
||||
func (b mp4Box) body() int64 { return b.offset + b.hdr }
|
||||
func (b mp4Box) end() int64 { return b.offset + b.size }
|
||||
|
||||
func readMP4Box(data []byte, pos int64) (mp4Box, bool) {
|
||||
n := int64(len(data))
|
||||
if pos < 0 || pos+8 > n {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
size := int64(binary.BigEndian.Uint32(data[pos : pos+4]))
|
||||
typ := string(data[pos+4 : pos+8])
|
||||
hdr := int64(8)
|
||||
if size == 1 {
|
||||
if pos+16 > n {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
size = int64(binary.BigEndian.Uint64(data[pos+8 : pos+16]))
|
||||
hdr = 16
|
||||
} else if size == 0 {
|
||||
size = n - pos
|
||||
}
|
||||
if size < hdr || pos+size > n {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
return mp4Box{offset: pos, size: size, hdr: hdr, typ: typ}, true
|
||||
}
|
||||
|
||||
func findChildMP4(data []byte, start, end int64, typ string) (mp4Box, bool) {
|
||||
pos := start
|
||||
for pos+8 <= end {
|
||||
b, ok := readMP4Box(data, pos)
|
||||
if !ok {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
if b.typ == typ {
|
||||
return b, true
|
||||
}
|
||||
pos = b.end()
|
||||
}
|
||||
return mp4Box{}, false
|
||||
}
|
||||
|
||||
func eachChildMP4(data []byte, start, end int64, typ string, fn func(mp4Box) bool) {
|
||||
pos := start
|
||||
for pos+8 <= end {
|
||||
b, ok := readMP4Box(data, pos)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if b.typ == typ && !fn(b) {
|
||||
return
|
||||
}
|
||||
pos = b.end()
|
||||
}
|
||||
}
|
||||
|
||||
// findBoxBySignature scans [start,end) for a box of the given type, matching the
|
||||
// 4-byte type tag and validating the preceding size field. Used to locate dac4
|
||||
// which may be nested inside an encrypted (enca) sample entry.
|
||||
func findBoxBySignature(data []byte, start, end int64, typ string) (mp4Box, bool) {
|
||||
if len(typ) != 4 {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
for i := start; i+8 <= end; i++ {
|
||||
if data[i+4] == typ[0] && data[i+5] == typ[1] && data[i+6] == typ[2] && data[i+7] == typ[3] {
|
||||
if b, ok := readMP4Box(data, i); ok && b.typ == typ {
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return mp4Box{}, false
|
||||
}
|
||||
|
||||
// audioSampleEntryHeaderLen returns the byte length of the fixed audio sample
|
||||
// entry header (from the box body start) before child boxes begin. ok is false
|
||||
// for malformed/truncated entries whose declared header is not fully present.
|
||||
func audioSampleEntryHeaderLen(data []byte, entry mp4Box) (hdrLen int64, ok bool) {
|
||||
// 6 bytes reserved + 2 bytes data_reference_index, then the audio fields.
|
||||
base := entry.body()
|
||||
if base+10 > entry.end() {
|
||||
return 0, false
|
||||
}
|
||||
version := binary.BigEndian.Uint16(data[base+8 : base+10])
|
||||
hdrLen = 8 + 20
|
||||
switch version {
|
||||
case 1:
|
||||
hdrLen += 16
|
||||
case 2:
|
||||
hdrLen += 36
|
||||
}
|
||||
if base+hdrLen > entry.end() {
|
||||
return 0, false
|
||||
}
|
||||
return hdrLen, true
|
||||
}
|
||||
|
||||
type ac4Location struct {
|
||||
chain []mp4Box // moov, trak, mdia, minf, stbl, stsd (ancestors to grow)
|
||||
entry mp4Box // the ac-4 sample entry
|
||||
}
|
||||
|
||||
func locateAC4Entry(data []byte) (ac4Location, bool) {
|
||||
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
|
||||
if !ok {
|
||||
return ac4Location{}, false
|
||||
}
|
||||
var found ac4Location
|
||||
var ok2 bool
|
||||
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
|
||||
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
stsd, ok := findChildMP4(data, stbl.body(), stbl.end(), "stsd")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
entry, ok := findChildMP4(data, stsd.body()+8, stsd.end(), "ac-4")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
found = ac4Location{chain: []mp4Box{moov, trak, mdia, minf, stbl, stsd}, entry: entry}
|
||||
ok2 = true
|
||||
return false
|
||||
})
|
||||
return found, ok2
|
||||
}
|
||||
|
||||
func growBoxSize(data []byte, b mp4Box, delta int64) {
|
||||
if b.hdr == 16 {
|
||||
binary.BigEndian.PutUint64(data[b.offset+8:b.offset+16], uint64(b.size+delta))
|
||||
} else {
|
||||
binary.BigEndian.PutUint32(data[b.offset:b.offset+4], uint32(b.size+delta))
|
||||
}
|
||||
}
|
||||
|
||||
// shiftChunkOffsets adds delta to every stco/co64 entry that references a file
|
||||
// offset at or beyond insertPos, keeping sample pointers valid after bytes are
|
||||
// inserted into moov.
|
||||
func shiftChunkOffsets(data []byte, moov mp4Box, insertPos, delta int64) {
|
||||
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
|
||||
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if stco, ok := findChildMP4(data, stbl.body(), stbl.end(), "stco"); ok {
|
||||
base := stco.body() + 4
|
||||
if base+4 <= stco.end() {
|
||||
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
|
||||
p := base + 4
|
||||
for i := int64(0); i < count && p+4 <= stco.end(); i++ {
|
||||
v := int64(binary.BigEndian.Uint32(data[p : p+4]))
|
||||
if v >= insertPos {
|
||||
binary.BigEndian.PutUint32(data[p:p+4], uint32(v+delta))
|
||||
}
|
||||
p += 4
|
||||
}
|
||||
}
|
||||
}
|
||||
if co64, ok := findChildMP4(data, stbl.body(), stbl.end(), "co64"); ok {
|
||||
base := co64.body() + 4
|
||||
if base+4 <= co64.end() {
|
||||
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
|
||||
p := base + 4
|
||||
for i := int64(0); i < count && p+8 <= co64.end(); i++ {
|
||||
v := int64(binary.BigEndian.Uint64(data[p : p+8]))
|
||||
if v >= insertPos {
|
||||
binary.BigEndian.PutUint64(data[p:p+8], uint64(v+delta))
|
||||
}
|
||||
p += 8
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeQuickTimeAudioToMP4 rewrites a QuickTime-flavored file (FFmpeg mov
|
||||
// muxer output: ftyp brand "qt " and a version-1 sound sample entry) into a
|
||||
// standard ISO MP4: an isom/mp42 brand and a plain version-0 AudioSampleEntry.
|
||||
// Windows Media Foundation (and other strict parsers) reject the QuickTime
|
||||
// flavor for AC-4 even when dac4 is present.
|
||||
func normalizeQuickTimeAudioToMP4(data []byte) []byte {
|
||||
if ftyp, ok := findChildMP4(data, 0, int64(len(data)), "ftyp"); ok {
|
||||
if ftyp.body()+4 <= int64(len(data)) {
|
||||
copy(data[ftyp.body():ftyp.body()+4], []byte("mp42"))
|
||||
}
|
||||
for p := ftyp.body() + 8; p+4 <= ftyp.end(); p += 4 {
|
||||
if string(data[p:p+4]) == "qt " {
|
||||
copy(data[p:p+4], []byte("isom"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loc, ok := locateAC4Entry(data)
|
||||
if !ok {
|
||||
return data
|
||||
}
|
||||
entry := loc.entry
|
||||
verPos := entry.body() + 8
|
||||
if verPos+2 > entry.end() {
|
||||
return data
|
||||
}
|
||||
if binary.BigEndian.Uint16(data[verPos:verPos+2]) != 1 {
|
||||
return data // already v0 (or v2, left untouched)
|
||||
}
|
||||
|
||||
// The v1 QuickTime sound extension is the 16 bytes following the 20-byte v0
|
||||
// audio fields (samplesPerPacket, bytesPerPacket, bytesPerFrame, bytesPerSample).
|
||||
extStart := entry.body() + 8 + 20
|
||||
extEnd := extStart + 16
|
||||
if extEnd > entry.end() {
|
||||
return data
|
||||
}
|
||||
delta := int64(-16)
|
||||
|
||||
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
|
||||
shiftChunkOffsets(data, loc.chain[0], extStart, delta)
|
||||
for _, b := range loc.chain {
|
||||
growBoxSize(data, b, delta)
|
||||
}
|
||||
growBoxSize(data, entry, delta)
|
||||
|
||||
out := make([]byte, 0, len(data)-16)
|
||||
out = append(out, data[:extStart]...)
|
||||
out = append(out, data[extEnd:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// EnsureAC4ConfigBox makes a decrypted AC-4 MP4 standards-compliant and
|
||||
// playable: it normalizes FFmpeg's QuickTime-flavored mov output to an ISO MP4
|
||||
// and injects the AC-4 configuration box (dac4) into the ac-4 sample entry. The
|
||||
// dac4 box is copied verbatim from sourcePath (the original MP4, whose plaintext
|
||||
// moov still carries it). No-op when the file has no AC-4 track.
|
||||
func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
|
||||
dst, err := os.ReadFile(decryptedPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, ok := locateAC4Entry(dst); !ok {
|
||||
return nil // not an AC-4 file; nothing to do
|
||||
}
|
||||
|
||||
dst = normalizeQuickTimeAudioToMP4(dst)
|
||||
|
||||
loc, ok := locateAC4Entry(dst)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
hdrLen, ok := audioSampleEntryHeaderLen(dst, loc.entry)
|
||||
if !ok {
|
||||
return fmt.Errorf("malformed ac-4 sample entry")
|
||||
}
|
||||
childStart := loc.entry.body() + hdrLen
|
||||
if _, has := findChildMP4(dst, childStart, loc.entry.end(), "dac4"); has {
|
||||
// Already has dac4; still persist any normalization changes.
|
||||
return os.WriteFile(decryptedPath, dst, 0o644)
|
||||
}
|
||||
|
||||
src, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srcMoov, ok := findChildMP4(src, 0, int64(len(src)), "moov")
|
||||
if !ok {
|
||||
return fmt.Errorf("source has no moov")
|
||||
}
|
||||
dac4Box, ok := findBoxBySignature(src, srcMoov.body(), srcMoov.end(), "dac4")
|
||||
if !ok {
|
||||
return fmt.Errorf("dac4 not found in source")
|
||||
}
|
||||
dac4 := append([]byte{}, src[dac4Box.offset:dac4Box.end()]...)
|
||||
|
||||
insertPos := childStart
|
||||
delta := int64(len(dac4))
|
||||
|
||||
shiftChunkOffsets(dst, loc.chain[0], insertPos, delta)
|
||||
for _, b := range loc.chain {
|
||||
growBoxSize(dst, b, delta)
|
||||
}
|
||||
growBoxSize(dst, loc.entry, delta)
|
||||
|
||||
out := make([]byte, 0, len(dst)+len(dac4))
|
||||
out = append(out, dst[:insertPos]...)
|
||||
out = append(out, dac4...)
|
||||
out = append(out, dst[insertPos:]...)
|
||||
|
||||
return os.WriteFile(decryptedPath, out, 0o644)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mp4TestBox(typ string, body []byte) []byte {
|
||||
out := make([]byte, 8+len(body))
|
||||
binary.BigEndian.PutUint32(out[:4], uint32(len(out)))
|
||||
copy(out[4:8], typ)
|
||||
copy(out[8:], body)
|
||||
return out
|
||||
}
|
||||
|
||||
func mp4TestAC4Tree(entryBody []byte) []byte {
|
||||
entry := mp4TestBox("ac-4", entryBody)
|
||||
stsdBody := append([]byte{
|
||||
0, 0, 0, 0, // version/flags
|
||||
0, 0, 0, 1, // entry_count
|
||||
}, entry...)
|
||||
stsd := mp4TestBox("stsd", stsdBody)
|
||||
stbl := mp4TestBox("stbl", stsd)
|
||||
minf := mp4TestBox("minf", stbl)
|
||||
mdia := mp4TestBox("mdia", minf)
|
||||
trak := mp4TestBox("trak", mdia)
|
||||
moov := mp4TestBox("moov", trak)
|
||||
return moov
|
||||
}
|
||||
|
||||
func shortAC4SampleEntryBody(version uint16) []byte {
|
||||
body := make([]byte, 10)
|
||||
binary.BigEndian.PutUint16(body[8:10], version)
|
||||
return body
|
||||
}
|
||||
|
||||
func TestNormalizeQuickTimeAudioToMP4IgnoresTruncatedAC4Entry(t *testing.T) {
|
||||
input := mp4TestAC4Tree(shortAC4SampleEntryBody(1))
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("normalizeQuickTimeAudioToMP4 panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
got := normalizeQuickTimeAudioToMP4(append([]byte{}, input...))
|
||||
if !bytes.Equal(got, input) {
|
||||
t.Fatal("truncated QuickTime AC-4 entry should be left unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAC4ConfigBoxRejectsTruncatedAC4Entry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
decryptedPath := filepath.Join(dir, "decrypted.mp4")
|
||||
sourcePath := filepath.Join(dir, "source.mp4")
|
||||
|
||||
if err := os.WriteFile(decryptedPath, mp4TestAC4Tree(shortAC4SampleEntryBody(2)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(sourcePath, mp4TestBox("moov", mp4TestBox("dac4", []byte{1, 2, 3, 4})), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("EnsureAC4ConfigBox panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := EnsureAC4ConfigBox(decryptedPath, sourcePath); err == nil {
|
||||
t.Fatal("expected malformed AC-4 sample entry error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ac4Metadata mirrors the tag fields the app embeds for other formats. Numeric
|
||||
// fields are strings because they arrive as a JSON-encoded map of strings.
|
||||
type ac4Metadata struct {
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
Date string `json:"date"`
|
||||
Genre string `json:"genre"`
|
||||
Composer string `json:"composer"`
|
||||
TrackNumber string `json:"trackNumber"`
|
||||
TotalTracks string `json:"totalTracks"`
|
||||
DiscNumber string `json:"discNumber"`
|
||||
TotalDiscs string `json:"totalDiscs"`
|
||||
ISRC string `json:"isrc"`
|
||||
Label string `json:"label"`
|
||||
Copyright string `json:"copyright"`
|
||||
Lyrics string `json:"lyrics"`
|
||||
}
|
||||
|
||||
func atoiSafe(s string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func itunesTextTag(atomType, value string) []byte {
|
||||
data := make([]byte, 8+len(value))
|
||||
binary.BigEndian.PutUint32(data[0:4], 1) // well-known type 1 = UTF-8
|
||||
copy(data[8:], []byte(value))
|
||||
return buildM4AAtom(atomType, buildM4AAtom("data", data))
|
||||
}
|
||||
|
||||
func itunesNumberPairTag(atomType string, number, total int) []byte {
|
||||
payload := make([]byte, 8)
|
||||
binary.BigEndian.PutUint16(payload[2:4], uint16(number))
|
||||
binary.BigEndian.PutUint16(payload[4:6], uint16(total))
|
||||
data := make([]byte, 8+len(payload))
|
||||
binary.BigEndian.PutUint32(data[0:4], 0) // type 0 = implicit/binary
|
||||
copy(data[8:], payload)
|
||||
return buildM4AAtom(atomType, buildM4AAtom("data", data))
|
||||
}
|
||||
|
||||
func itunesCoverTag(image []byte) []byte {
|
||||
typeCode := uint32(13) // JPEG
|
||||
if len(image) >= 8 &&
|
||||
image[0] == 0x89 && image[1] == 0x50 && image[2] == 0x4E && image[3] == 0x47 {
|
||||
typeCode = 14 // PNG
|
||||
}
|
||||
data := make([]byte, 8+len(image))
|
||||
binary.BigEndian.PutUint32(data[0:4], typeCode)
|
||||
copy(data[8:], image)
|
||||
return buildM4AAtom("covr", buildM4AAtom("data", data))
|
||||
}
|
||||
|
||||
func itunesMetadataHandler() []byte {
|
||||
payload := make([]byte, 0, 25)
|
||||
payload = append(payload, 0, 0, 0, 0) // version + flags
|
||||
payload = append(payload, 0, 0, 0, 0) // pre_defined
|
||||
payload = append(payload, []byte("mdir")...) // handler type
|
||||
payload = append(payload, []byte("appl")...) // reserved[0]
|
||||
payload = append(payload, 0, 0, 0, 0, 0, 0, 0, 0) // reserved[1..2]
|
||||
payload = append(payload, 0) // empty name
|
||||
return buildM4AAtom("hdlr", payload)
|
||||
}
|
||||
|
||||
// buildITunesUdta assembles a fresh udta>meta>(hdlr+ilst) box from metadata.
|
||||
func buildITunesUdta(md ac4Metadata, cover []byte) []byte {
|
||||
ilst := make([]byte, 0, 256)
|
||||
add := func(atomType, value string) {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
ilst = append(ilst, itunesTextTag(atomType, value)...)
|
||||
}
|
||||
}
|
||||
add("\xa9nam", md.Title)
|
||||
add("\xa9ART", md.Artist)
|
||||
add("\xa9alb", md.Album)
|
||||
add("aART", md.AlbumArtist)
|
||||
add("\xa9day", md.Date)
|
||||
add("\xa9gen", md.Genre)
|
||||
add("\xa9wrt", md.Composer)
|
||||
if tn := atoiSafe(md.TrackNumber); tn > 0 {
|
||||
ilst = append(ilst, itunesNumberPairTag("trkn", tn, atoiSafe(md.TotalTracks))...)
|
||||
}
|
||||
if dn := atoiSafe(md.DiscNumber); dn > 0 {
|
||||
ilst = append(ilst, itunesNumberPairTag("disk", dn, atoiSafe(md.TotalDiscs))...)
|
||||
}
|
||||
if strings.TrimSpace(md.ISRC) != "" {
|
||||
ilst = append(ilst, buildM4AFreeformAtom("ISRC", strings.TrimSpace(md.ISRC))...)
|
||||
}
|
||||
if strings.TrimSpace(md.Label) != "" {
|
||||
ilst = append(ilst, buildM4AFreeformAtom("LABEL", strings.TrimSpace(md.Label))...)
|
||||
}
|
||||
if strings.TrimSpace(md.Copyright) != "" {
|
||||
add("cprt", md.Copyright)
|
||||
}
|
||||
if strings.TrimSpace(md.Lyrics) != "" {
|
||||
add("\xa9lyr", md.Lyrics)
|
||||
}
|
||||
if len(cover) > 0 {
|
||||
ilst = append(ilst, itunesCoverTag(cover)...)
|
||||
}
|
||||
|
||||
ilstBox := buildM4AAtom("ilst", ilst)
|
||||
metaPayload := append([]byte{0, 0, 0, 0}, itunesMetadataHandler()...)
|
||||
metaPayload = append(metaPayload, ilstBox...)
|
||||
meta := buildM4AAtom("meta", metaPayload)
|
||||
return buildM4AAtom("udta", meta)
|
||||
}
|
||||
|
||||
// writeMP4iTunesMetadata replaces (or inserts) a udta>meta>ilst metadata box in
|
||||
// the moov of an MP4 buffer and returns the rewritten bytes.
|
||||
func writeMP4iTunesMetadata(data []byte, md ac4Metadata, cover []byte) []byte {
|
||||
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
|
||||
if !ok {
|
||||
return data
|
||||
}
|
||||
newUdta := buildITunesUdta(md, cover)
|
||||
|
||||
if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok {
|
||||
delta := int64(len(newUdta)) - udta.size
|
||||
shiftChunkOffsets(data, moov, udta.offset, delta)
|
||||
growBoxSize(data, moov, delta)
|
||||
out := make([]byte, 0, len(data)+len(newUdta))
|
||||
out = append(out, data[:udta.offset]...)
|
||||
out = append(out, newUdta...)
|
||||
out = append(out, data[udta.end():]...)
|
||||
return out
|
||||
}
|
||||
|
||||
delta := int64(len(newUdta))
|
||||
insertPos := moov.end()
|
||||
shiftChunkOffsets(data, moov, insertPos, delta)
|
||||
growBoxSize(data, moov, delta)
|
||||
out := make([]byte, 0, len(data)+len(newUdta))
|
||||
out = append(out, data[:insertPos]...)
|
||||
out = append(out, newUdta...)
|
||||
out = append(out, data[insertPos:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// WriteAC4MetadataIfApplicable writes iTunes metadata into an AC-4 MP4. Returns
|
||||
// true when the file was an AC-4 track and metadata was written; false when the
|
||||
// file is not AC-4 (the caller should fall back to its normal metadata path).
|
||||
func WriteAC4MetadataIfApplicable(decryptedPath, metadataJSON, coverPath string) (bool, error) {
|
||||
data, err := os.ReadFile(decryptedPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, ok := locateAC4Entry(data); !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var md ac4Metadata
|
||||
if strings.TrimSpace(metadataJSON) != "" {
|
||||
_ = json.Unmarshal([]byte(metadataJSON), &md)
|
||||
}
|
||||
var cover []byte
|
||||
if strings.TrimSpace(coverPath) != "" {
|
||||
if b, err := os.ReadFile(coverPath); err == nil {
|
||||
cover = b
|
||||
}
|
||||
}
|
||||
|
||||
out := writeMP4iTunesMetadata(data, md, cover)
|
||||
if err := os.WriteFile(decryptedPath, out, 0o644); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,692 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Amazon API timeout and retry configuration for mobile networks
|
||||
const (
|
||||
amazonAPITimeoutMobile = 30 * time.Second // Longer timeout for unstable mobile networks
|
||||
amazonMaxRetries = 2 // Number of retry attempts
|
||||
amazonRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
type AmazonDownloader struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
var (
|
||||
globalAmazonDownloader *AmazonDownloader
|
||||
amazonDownloaderOnce sync.Once
|
||||
amazonASINRegex = regexp.MustCompile(`(?i)^B[0-9A-Z]{9}$`)
|
||||
amazonASINFindRegex = regexp.MustCompile(`(?i)B[0-9A-Z]{9}`)
|
||||
)
|
||||
|
||||
// AfkarXYZResponse is the response from AfkarXYZ API
|
||||
type AfkarXYZResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
DirectLink string `json:"direct_link"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// AmazonStreamResponse is the new response format from amzn.afkarxyz.fun/api/track/{asin}
|
||||
type AmazonStreamResponse struct {
|
||||
StreamURL string `json:"streamUrl"`
|
||||
DecryptionKey string `json:"decryptionKey"`
|
||||
}
|
||||
|
||||
func NewAmazonDownloader() *AmazonDownloader {
|
||||
amazonDownloaderOnce.Do(func() {
|
||||
globalAmazonDownloader = &AmazonDownloader{
|
||||
client: NewHTTPClientWithTimeout(120 * time.Second),
|
||||
}
|
||||
})
|
||||
return globalAmazonDownloader
|
||||
}
|
||||
|
||||
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks.
|
||||
// Returns downloadURL, suggested fileName, optional decryptionKey.
|
||||
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := amazonRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
||||
GoLog("[Amazon] Retry %d/%d after %v...\n", attempt, amazonMaxRetries, delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL)
|
||||
if err == nil {
|
||||
return downloadURL, fileName, decryptionKey, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
// Check if error is retryable
|
||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "eof") ||
|
||||
strings.Contains(errStr, "status 5") ||
|
||||
strings.Contains(errStr, "status 429") ||
|
||||
strings.Contains(errStr, "http 429")
|
||||
|
||||
if !isRetryable {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
||||
}
|
||||
|
||||
return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
func normalizeAmazonASIN(candidate string) string {
|
||||
trimmed := strings.TrimSpace(candidate)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if decoded, err := url.QueryUnescape(trimmed); err == nil {
|
||||
trimmed = decoded
|
||||
}
|
||||
|
||||
trimmed = strings.ToUpper(trimmed)
|
||||
if idx := strings.IndexAny(trimmed, "?#&/"); idx >= 0 {
|
||||
trimmed = trimmed[:idx]
|
||||
}
|
||||
|
||||
if amazonASINRegex.MatchString(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractAmazonASIN(amazonURL string) string {
|
||||
raw := strings.TrimSpace(amazonURL)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(raw)
|
||||
if err == nil {
|
||||
query := parsed.Query()
|
||||
|
||||
// Prefer track-level ASIN when URL also contains albumAsin.
|
||||
for _, key := range []string{"trackAsin", "trackasin", "trackASIN", "asin", "ASIN", "i"} {
|
||||
if asin := normalizeAmazonASIN(query.Get(key)); asin != "" {
|
||||
return asin
|
||||
}
|
||||
}
|
||||
|
||||
path := strings.Trim(parsed.Path, "/")
|
||||
if path != "" {
|
||||
segments := strings.Split(path, "/")
|
||||
|
||||
for i := 0; i < len(segments)-1; i++ {
|
||||
segment := strings.ToLower(strings.TrimSpace(segments[i]))
|
||||
if segment == "track" || segment == "tracks" {
|
||||
if asin := normalizeAmazonASIN(segments[i+1]); asin != "" {
|
||||
return asin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if asin := normalizeAmazonASIN(segments[len(segments)-1]); asin != "" {
|
||||
return asin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match := amazonASINFindRegex.FindString(strings.ToUpper(raw))
|
||||
return normalizeAmazonASIN(match)
|
||||
}
|
||||
|
||||
// doAfkarXYZRequest performs a single request to Amazon API.
|
||||
// It tries new endpoint first, then falls back to legacy /convert endpoint.
|
||||
func (a *AmazonDownloader) doAfkarXYZRequest(amazonURL string) (string, string, string, error) {
|
||||
asin := extractAmazonASIN(amazonURL)
|
||||
if asin != "" {
|
||||
GoLog("[Amazon] Using ASIN: %s\n", asin)
|
||||
downloadURL, fileName, decryptKey, err := a.doAfkarXYZRequestNew(asin)
|
||||
if err == nil {
|
||||
return downloadURL, fileName, decryptKey, nil
|
||||
}
|
||||
GoLog("[Amazon] New API failed for ASIN %s, trying legacy endpoint: %v\n", asin, err)
|
||||
}
|
||||
return a.doAfkarXYZRequestLegacy(amazonURL)
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||
defer cancel()
|
||||
|
||||
apiURL := fmt.Sprintf("https://amzn.afkarxyz.fun/api/track/%s", asin)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to call Amazon API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return "", "", "", fmt.Errorf("failed to read response: %w", readErr)
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var apiResp AmazonStreamResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(apiResp.StreamURL) == "" {
|
||||
return "", "", "", fmt.Errorf("Amazon API returned empty stream URL")
|
||||
}
|
||||
|
||||
fileName := asin + ".m4a"
|
||||
return apiResp.StreamURL, fileName, strings.TrimSpace(apiResp.DecryptionKey), nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, string, string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||
defer cancel()
|
||||
|
||||
apiURL := "https://amzn.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to create legacy request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to read legacy response: %w", err)
|
||||
}
|
||||
|
||||
var apiResp AfkarXYZResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err)
|
||||
}
|
||||
|
||||
if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" {
|
||||
return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found")
|
||||
}
|
||||
|
||||
fileName := apiResp.Data.FileName
|
||||
if fileName == "" {
|
||||
fileName = "track.flac"
|
||||
}
|
||||
|
||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||
fileName = reg.ReplaceAllString(fileName, "")
|
||||
|
||||
return apiResp.Data.DirectLink, fileName, "", nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) {
|
||||
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
||||
|
||||
downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
if decryptionKey != "" {
|
||||
GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n")
|
||||
}
|
||||
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
|
||||
return downloadURL, fileName, decryptionKey, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
GoLog("[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
|
||||
// AmazonDownloadResult contains download result with quality info
|
||||
type AmazonDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
DecryptionKey string
|
||||
}
|
||||
|
||||
func resolveAmazonURLForRequest(req DownloadRequest, logPrefix string) (string, error) {
|
||||
if strings.TrimSpace(logPrefix) == "" {
|
||||
logPrefix = "Amazon"
|
||||
}
|
||||
|
||||
amazonURL := ""
|
||||
if req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" {
|
||||
amazonURL = cached.AmazonURL
|
||||
GoLog("[%s] Cache hit! Using cached Amazon URL for ISRC %s\n", logPrefix, req.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
if amazonURL != "" {
|
||||
return amazonURL, nil
|
||||
}
|
||||
|
||||
songlink := NewSongLinkClient()
|
||||
var availability *TrackAvailability
|
||||
var err error
|
||||
|
||||
deezerID := strings.TrimSpace(req.DeezerID)
|
||||
if prefixedDeezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found && strings.TrimSpace(prefixedDeezerID) != "" {
|
||||
deezerID = strings.TrimSpace(prefixedDeezerID)
|
||||
}
|
||||
|
||||
if deezerID != "" {
|
||||
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, deezerID)
|
||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||
} else if req.SpotifyID != "" {
|
||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
} else {
|
||||
return "", fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||
}
|
||||
|
||||
if availability == nil || !availability.Amazon || availability.AmazonURL == "" {
|
||||
return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||
}
|
||||
|
||||
amazonURL = availability.AmazonURL
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL)
|
||||
}
|
||||
|
||||
return amazonURL, nil
|
||||
}
|
||||
|
||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
downloader := NewAmazonDownloader()
|
||||
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
amazonURL, err := resolveAmazonURLForRequest(req, "Amazon")
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, err
|
||||
}
|
||||
|
||||
if !isSafOutput && req.OutputDir != "." {
|
||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Download using AfkarXYZ API
|
||||
downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[Amazon] Match found: '%s' by '%s'\n", req.TrackName, req.ArtistName)
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
var outputPath string
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
outputExt := strings.ToLower(filepath.Ext(afkarFileName))
|
||||
if outputExt == "" {
|
||||
outputExt = ".flac"
|
||||
}
|
||||
filename = sanitizeFilename(filename) + outputExt
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// START PARALLEL: Fetch cover and lyrics while downloading audio
|
||||
var parallelResult *ParallelDownloadResult
|
||||
parallelDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(parallelDone)
|
||||
coverURL := req.CoverURL
|
||||
embedLyrics := req.EmbedLyrics
|
||||
if !req.EmbedMetadata {
|
||||
coverURL = ""
|
||||
embedLyrics = false
|
||||
}
|
||||
parallelResult = FetchCoverAndLyricsParallel(
|
||||
coverURL,
|
||||
req.EmbedMaxQualityCover,
|
||||
req.SpotifyID,
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
embedLyrics,
|
||||
int64(req.DurationMS),
|
||||
)
|
||||
}()
|
||||
|
||||
// Download audio file with item ID for progress tracking
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return AmazonDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
actualOutputPath := outputPath
|
||||
needsDecryption := strings.TrimSpace(decryptionKey) != ""
|
||||
if needsDecryption {
|
||||
GoLog("[Amazon] Download requires decryption; deferring decrypt to Flutter FFmpeg path\n")
|
||||
}
|
||||
|
||||
// Wait for parallel operations to complete
|
||||
<-parallelDone
|
||||
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
actualTrackNum := req.TrackNumber
|
||||
actualDiscNum := req.DiscNumber
|
||||
actualDate := req.ReleaseDate
|
||||
actualAlbum := req.AlbumName
|
||||
actualTitle := req.TrackName
|
||||
actualArtist := req.ArtistName
|
||||
|
||||
if !needsDecryption {
|
||||
existingMeta, metaErr := ReadMetadata(actualOutputPath)
|
||||
if metaErr == nil && existingMeta != nil {
|
||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||
actualTrackNum = existingMeta.TrackNumber
|
||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||
}
|
||||
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
||||
actualDiscNum = existingMeta.DiscNumber
|
||||
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
||||
}
|
||||
if existingMeta.Date != "" && req.ReleaseDate == "" {
|
||||
actualDate = existingMeta.Date
|
||||
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
|
||||
}
|
||||
if existingMeta.Album != "" && req.AlbumName == "" {
|
||||
actualAlbum = existingMeta.Album
|
||||
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
|
||||
}
|
||||
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
|
||||
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
|
||||
}
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: actualTitle,
|
||||
Artist: actualArtist,
|
||||
Album: actualAlbum,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: actualDate,
|
||||
TrackNumber: actualTrackNum,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: actualDiscNum,
|
||||
ISRC: req.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 {
|
||||
coverData = parallelResult.CoverData
|
||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
} else {
|
||||
existingCover, coverErr := ExtractCoverArt(actualOutputPath)
|
||||
if coverErr == nil && len(existingCover) > 0 {
|
||||
coverData = existingCover
|
||||
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
||||
} else {
|
||||
GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\n")
|
||||
}
|
||||
}
|
||||
|
||||
if isSafOutput || needsDecryption || !req.EmbedMetadata {
|
||||
if !req.EmbedMetadata {
|
||||
GoLog("[Amazon] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
|
||||
} else {
|
||||
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||
}
|
||||
} else {
|
||||
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
|
||||
if isFlacOutput {
|
||||
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath))
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Amazon] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput {
|
||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
GoLog("[Amazon] Lyrics embedded successfully\n")
|
||||
}
|
||||
} else if (lyricsMode == "embed" || lyricsMode == "both") && !isFlacOutput {
|
||||
GoLog("[Amazon] Skipping embedded lyrics for non-FLAC output\n")
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
||||
|
||||
quality := AudioQuality{}
|
||||
if isSafOutput || needsDecryption {
|
||||
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
||||
} else {
|
||||
quality, err = GetAudioQuality(actualOutputPath)
|
||||
if err != nil {
|
||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
} else {
|
||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
|
||||
finalMeta, metaReadErr := ReadMetadata(actualOutputPath)
|
||||
if metaReadErr == nil && finalMeta != nil {
|
||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||
actualTrackNum = finalMeta.TrackNumber
|
||||
actualDiscNum = finalMeta.DiscNumber
|
||||
if finalMeta.Date != "" {
|
||||
req.ReleaseDate = finalMeta.Date
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add to ISRC index for fast duplicate checking.
|
||||
// When decryption is pending in Flutter, postpone indexing until final file is settled.
|
||||
if !isSafOutput && !needsDecryption {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||
}
|
||||
|
||||
bitDepth := 0
|
||||
sampleRate := 0
|
||||
if err == nil {
|
||||
bitDepth = quality.BitDepth
|
||||
sampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
return AmazonDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: actualTrackNum,
|
||||
DiscNumber: actualDiscNum,
|
||||
ISRC: req.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
DecryptionKey: decryptionKey,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractAmazonASIN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "prefers trackAsin over albumAsin",
|
||||
url: "https://music.amazon.com/albums/B0ALBUM123?trackAsin=B0TRACK456&musicTerritory=US",
|
||||
want: "B0TRACK456",
|
||||
},
|
||||
{
|
||||
name: "extract from tracks path",
|
||||
url: "https://music.amazon.com/tracks/B0CYQHGWZJ?musicTerritory=US",
|
||||
want: "B0CYQHGWZJ",
|
||||
},
|
||||
{
|
||||
name: "extract from plain query asin",
|
||||
url: "https://example.com/?asin=B0CYQHGWZJ",
|
||||
want: "B0CYQHGWZJ",
|
||||
},
|
||||
{
|
||||
name: "fallback regex",
|
||||
url: "https://example.com/path/B0CYQHGWZJ",
|
||||
want: "B0CYQHGWZJ",
|
||||
},
|
||||
{
|
||||
name: "invalid url",
|
||||
url: "https://music.amazon.com/tracks/not-valid",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractAmazonASIN(tt.url)
|
||||
if got != tt.want {
|
||||
t.Fatalf("extractAmazonASIN() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// APEv2 tag format constants.
|
||||
const (
|
||||
apeTagPreamble = "APETAGEX"
|
||||
apeTagHeaderSize = 32
|
||||
apeTagVersion2 = 2000
|
||||
apeTagFlagHeader = 1 << 29 // bit 29: this is the header, not the footer
|
||||
apeTagFlagReadOnly = 1 << 0
|
||||
// Item flags: bits 1-2 encode content type
|
||||
apeItemFlagUTF8 = 0 << 1 // 00: UTF-8 text
|
||||
apeItemFlagBinary = 1 << 1 // 01: binary data
|
||||
apeItemFlagLink = 2 << 1 // 10: external link
|
||||
)
|
||||
|
||||
// APETagItem represents a single key-value item in an APEv2 tag.
|
||||
type APETagItem struct {
|
||||
Key string
|
||||
Value string
|
||||
Flags uint32
|
||||
}
|
||||
|
||||
// APETag represents a complete APEv2 tag block.
|
||||
type APETag struct {
|
||||
Version uint32
|
||||
Items []APETagItem
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
// ReadAPETags reads APEv2 tags from a file.
|
||||
// APEv2 tags are typically appended at the end of the file.
|
||||
// The layout is: [audio data] [APEv2 header (optional)] [items...] [APEv2 footer]
|
||||
// We locate the footer first (last 32 bytes), then read the tag block.
|
||||
func ReadAPETags(filePath string) (*APETag, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
fileSize := fi.Size()
|
||||
|
||||
if fileSize < apeTagHeaderSize {
|
||||
return nil, fmt.Errorf("file too small for APE tag")
|
||||
}
|
||||
|
||||
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
|
||||
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
|
||||
if err == nil {
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
// Retry: skip ID3v1 tag (128 bytes) if present
|
||||
if fileSize > apeTagHeaderSize+128 {
|
||||
tag, err = readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize-128)
|
||||
if err == nil {
|
||||
return tag, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no APEv2 tag found")
|
||||
}
|
||||
|
||||
func readAPETagAtOffset(f *os.File, fileSize, footerOffset int64) (*APETag, error) {
|
||||
if footerOffset < 0 || footerOffset+apeTagHeaderSize > fileSize {
|
||||
return nil, fmt.Errorf("invalid footer offset")
|
||||
}
|
||||
|
||||
footer := make([]byte, apeTagHeaderSize)
|
||||
if _, err := f.ReadAt(footer, footerOffset); err != nil {
|
||||
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
||||
}
|
||||
|
||||
if string(footer[0:8]) != apeTagPreamble {
|
||||
return nil, fmt.Errorf("APE preamble not found")
|
||||
}
|
||||
|
||||
version := binary.LittleEndian.Uint32(footer[8:12])
|
||||
tagSize := binary.LittleEndian.Uint32(footer[12:16]) // size of items + footer (32 bytes)
|
||||
itemCount := binary.LittleEndian.Uint32(footer[16:20])
|
||||
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||
|
||||
if version != apeTagVersion2 && version != 1000 {
|
||||
return nil, fmt.Errorf("unsupported APE tag version: %d", version)
|
||||
}
|
||||
if tagSize < apeTagHeaderSize {
|
||||
return nil, fmt.Errorf("APE tag size too small: %d", tagSize)
|
||||
}
|
||||
if itemCount > 1000 {
|
||||
return nil, fmt.Errorf("APE tag item count too large: %d", itemCount)
|
||||
}
|
||||
|
||||
// This should be the footer (bit 29 clear)
|
||||
isHeader := (flags & apeTagFlagHeader) != 0
|
||||
if isHeader {
|
||||
return nil, fmt.Errorf("expected APE footer but found header")
|
||||
}
|
||||
|
||||
// tagSize includes items + footer (32 bytes), but NOT the header.
|
||||
itemsSize := int64(tagSize) - apeTagHeaderSize
|
||||
if itemsSize < 0 {
|
||||
return nil, fmt.Errorf("invalid APE tag: items size negative")
|
||||
}
|
||||
|
||||
itemsOffset := footerOffset - itemsSize
|
||||
if itemsOffset < 0 {
|
||||
return nil, fmt.Errorf("APE tag items extend before file start")
|
||||
}
|
||||
|
||||
itemsData := make([]byte, itemsSize)
|
||||
if _, err := f.ReadAt(itemsData, itemsOffset); err != nil {
|
||||
return nil, fmt.Errorf("failed to read APE items: %w", err)
|
||||
}
|
||||
|
||||
items, err := parseAPEItems(itemsData, int(itemCount))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse APE items: %w", err)
|
||||
}
|
||||
|
||||
return &APETag{
|
||||
Version: version,
|
||||
Items: items,
|
||||
ReadOnly: (flags & apeTagFlagReadOnly) != 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseAPEItems(data []byte, count int) ([]APETagItem, error) {
|
||||
items := make([]APETagItem, 0, count)
|
||||
pos := 0
|
||||
|
||||
for i := 0; i < count && pos < len(data); i++ {
|
||||
if pos+8 > len(data) {
|
||||
break
|
||||
}
|
||||
|
||||
valueSize := int(binary.LittleEndian.Uint32(data[pos : pos+4]))
|
||||
itemFlags := binary.LittleEndian.Uint32(data[pos+4 : pos+8])
|
||||
pos += 8
|
||||
|
||||
// Key is null-terminated ASCII (2-255 bytes, case-insensitive)
|
||||
keyEnd := pos
|
||||
for keyEnd < len(data) && data[keyEnd] != 0 {
|
||||
keyEnd++
|
||||
}
|
||||
if keyEnd >= len(data) {
|
||||
break
|
||||
}
|
||||
|
||||
key := string(data[pos:keyEnd])
|
||||
pos = keyEnd + 1
|
||||
|
||||
if pos+valueSize > len(data) {
|
||||
break
|
||||
}
|
||||
value := string(data[pos : pos+valueSize])
|
||||
pos += valueSize
|
||||
|
||||
items = append(items, APETagItem{
|
||||
Key: key,
|
||||
Value: value,
|
||||
Flags: itemFlags,
|
||||
})
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// WriteAPETags writes APEv2 tags to the end of a file.
|
||||
// If the file already has APEv2 tags, they are replaced.
|
||||
// The tag is written with both header and footer.
|
||||
func WriteAPETags(filePath string, tag *APETag) error {
|
||||
existingSize, err := findExistingAPETagSize(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check existing APE tag: %w", err)
|
||||
}
|
||||
|
||||
tagData, err := marshalAPETag(tag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal APE tag: %w", err)
|
||||
}
|
||||
|
||||
if existingSize > 0 {
|
||||
fi, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
newSize := fi.Size() - int64(existingSize)
|
||||
if err := os.Truncate(filePath, newSize); err != nil {
|
||||
return fmt.Errorf("failed to truncate existing APE tag: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file for writing: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Write(tagData); err != nil {
|
||||
return fmt.Errorf("failed to write APE tag: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findExistingAPETagSize returns the total size of an existing APE tag
|
||||
// (header + items + footer) at the end of the file, or 0 if none exists.
|
||||
func findExistingAPETagSize(filePath string) (int64, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
fileSize := fi.Size()
|
||||
|
||||
offsets := []int64{fileSize - apeTagHeaderSize}
|
||||
if fileSize > apeTagHeaderSize+128 {
|
||||
offsets = append(offsets, fileSize-apeTagHeaderSize-128)
|
||||
}
|
||||
|
||||
for _, offset := range offsets {
|
||||
if offset < 0 {
|
||||
continue
|
||||
}
|
||||
footer := make([]byte, apeTagHeaderSize)
|
||||
if _, err := f.ReadAt(footer, offset); err != nil {
|
||||
continue
|
||||
}
|
||||
if string(footer[0:8]) != apeTagPreamble {
|
||||
continue
|
||||
}
|
||||
|
||||
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||
if (flags & apeTagFlagHeader) != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
|
||||
|
||||
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
|
||||
totalSize := tagSize
|
||||
if hasHeader {
|
||||
totalSize += apeTagHeaderSize
|
||||
}
|
||||
|
||||
// Include any trailing data after the footer (e.g. ID3v1 128-byte tag).
|
||||
// When truncating, we must remove the APE tag AND everything after it.
|
||||
trailingBytes := fileSize - (offset + apeTagHeaderSize)
|
||||
totalSize += trailingBytes
|
||||
|
||||
return totalSize, nil
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// marshalAPETag serializes an APETag into bytes (header + items + footer).
|
||||
func marshalAPETag(tag *APETag) ([]byte, error) {
|
||||
if tag == nil || len(tag.Items) == 0 {
|
||||
return nil, fmt.Errorf("empty APE tag")
|
||||
}
|
||||
|
||||
var itemsData []byte
|
||||
for _, item := range tag.Items {
|
||||
keyBytes := []byte(item.Key)
|
||||
valueBytes := []byte(item.Value)
|
||||
|
||||
// 4 bytes: value size (LE)
|
||||
sizeBuf := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(sizeBuf, uint32(len(valueBytes)))
|
||||
|
||||
// 4 bytes: item flags (LE)
|
||||
flagsBuf := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(flagsBuf, item.Flags)
|
||||
|
||||
itemsData = append(itemsData, sizeBuf...)
|
||||
itemsData = append(itemsData, flagsBuf...)
|
||||
itemsData = append(itemsData, keyBytes...)
|
||||
itemsData = append(itemsData, 0)
|
||||
itemsData = append(itemsData, valueBytes...)
|
||||
}
|
||||
|
||||
// tagSize = items data + footer (32 bytes)
|
||||
tagSize := uint32(len(itemsData) + apeTagHeaderSize)
|
||||
itemCount := uint32(len(tag.Items))
|
||||
|
||||
version := uint32(apeTagVersion2)
|
||||
if tag.Version != 0 {
|
||||
version = tag.Version
|
||||
}
|
||||
|
||||
// flags: bit 29 = 1 (is header), bit 31 = 1 (contains header)
|
||||
headerFlags := uint32(apeTagFlagHeader | (1 << 31))
|
||||
header := buildAPEHeaderFooter(version, tagSize, itemCount, headerFlags)
|
||||
|
||||
// flags: bit 29 = 0 (is footer), bit 31 = 1 (contains header)
|
||||
footerFlags := uint32(1 << 31)
|
||||
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
||||
|
||||
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
||||
result = append(result, header...)
|
||||
result = append(result, itemsData...)
|
||||
result = append(result, footer...)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildAPEHeaderFooter(version, tagSize, itemCount, flags uint32) []byte {
|
||||
buf := make([]byte, apeTagHeaderSize)
|
||||
copy(buf[0:8], apeTagPreamble)
|
||||
binary.LittleEndian.PutUint32(buf[8:12], version)
|
||||
binary.LittleEndian.PutUint32(buf[12:16], tagSize)
|
||||
binary.LittleEndian.PutUint32(buf[16:20], itemCount)
|
||||
binary.LittleEndian.PutUint32(buf[20:24], flags)
|
||||
// bytes 24-31 are reserved (zeros)
|
||||
return buf
|
||||
}
|
||||
|
||||
// APETagToAudioMetadata converts an APETag to our unified AudioMetadata struct.
|
||||
func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
|
||||
if tag == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
metadata := &AudioMetadata{}
|
||||
for _, item := range tag.Items {
|
||||
key := strings.ToUpper(strings.TrimSpace(item.Key))
|
||||
value := strings.TrimSpace(item.Value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "TITLE":
|
||||
metadata.Title = value
|
||||
case "ARTIST":
|
||||
metadata.Artist = value
|
||||
case "ALBUM":
|
||||
metadata.Album = value
|
||||
case "ALBUMARTIST", "ALBUM ARTIST":
|
||||
metadata.AlbumArtist = value
|
||||
case "GENRE":
|
||||
metadata.Genre = value
|
||||
case "YEAR":
|
||||
metadata.Year = value
|
||||
case "DATE":
|
||||
metadata.Date = value
|
||||
case "TRACK", "TRACKNUMBER":
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "DISC", "DISCNUMBER":
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "ISRC":
|
||||
metadata.ISRC = value
|
||||
case "LYRICS", "UNSYNCEDLYRICS":
|
||||
if metadata.Lyrics == "" {
|
||||
metadata.Lyrics = value
|
||||
}
|
||||
case "LABEL", "PUBLISHER":
|
||||
metadata.Label = value
|
||||
case "COPYRIGHT":
|
||||
metadata.Copyright = value
|
||||
case "COMPOSER":
|
||||
metadata.Composer = value
|
||||
case "COMMENT":
|
||||
metadata.Comment = value
|
||||
case "REPLAYGAIN_TRACK_GAIN":
|
||||
metadata.ReplayGainTrackGain = value
|
||||
case "REPLAYGAIN_TRACK_PEAK":
|
||||
metadata.ReplayGainTrackPeak = value
|
||||
case "REPLAYGAIN_ALBUM_GAIN":
|
||||
metadata.ReplayGainAlbumGain = value
|
||||
case "REPLAYGAIN_ALBUM_PEAK":
|
||||
metadata.ReplayGainAlbumPeak = value
|
||||
}
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
// AudioMetadataToAPEItems converts metadata fields to APE tag items.
|
||||
func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var items []APETagItem
|
||||
addItem := func(key, value string) {
|
||||
if value != "" {
|
||||
items = append(items, APETagItem{Key: key, Value: value})
|
||||
}
|
||||
}
|
||||
|
||||
addItem("Title", metadata.Title)
|
||||
addItem("Artist", metadata.Artist)
|
||||
addItem("Album", metadata.Album)
|
||||
addItem("Album Artist", metadata.AlbumArtist)
|
||||
addItem("Genre", metadata.Genre)
|
||||
if metadata.Date != "" {
|
||||
addItem("Year", metadata.Date)
|
||||
} else if metadata.Year != "" {
|
||||
addItem("Year", metadata.Year)
|
||||
}
|
||||
if metadata.TrackNumber > 0 {
|
||||
addItem("Track", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
|
||||
}
|
||||
if metadata.DiscNumber > 0 {
|
||||
addItem("Disc", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
|
||||
}
|
||||
addItem("ISRC", metadata.ISRC)
|
||||
addItem("Lyrics", metadata.Lyrics)
|
||||
addItem("Label", metadata.Label)
|
||||
addItem("Copyright", metadata.Copyright)
|
||||
addItem("Composer", metadata.Composer)
|
||||
addItem("Comment", metadata.Comment)
|
||||
addItem("REPLAYGAIN_TRACK_GAIN", metadata.ReplayGainTrackGain)
|
||||
addItem("REPLAYGAIN_TRACK_PEAK", metadata.ReplayGainTrackPeak)
|
||||
addItem("REPLAYGAIN_ALBUM_GAIN", metadata.ReplayGainAlbumGain)
|
||||
addItem("REPLAYGAIN_ALBUM_PEAK", metadata.ReplayGainAlbumPeak)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// apeKeysFromFields builds a set of upper-case APE tag keys corresponding to
|
||||
// the metadata fields map sent by the editor. This is used during merge to
|
||||
// ensure that even empty (cleared) fields override old values.
|
||||
func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
||||
mapping := map[string]string{
|
||||
"title": "TITLE",
|
||||
"artist": "ARTIST",
|
||||
"album": "ALBUM",
|
||||
"album_artist": "ALBUM ARTIST",
|
||||
"date": "DATE",
|
||||
"genre": "GENRE",
|
||||
"track_number": "TRACK",
|
||||
"disc_number": "DISC",
|
||||
"isrc": "ISRC",
|
||||
"lyrics": "LYRICS",
|
||||
"label": "LABEL",
|
||||
"copyright": "COPYRIGHT",
|
||||
"composer": "COMPOSER",
|
||||
"comment": "COMMENT",
|
||||
"replaygain_track_gain": "REPLAYGAIN_TRACK_GAIN",
|
||||
"replaygain_track_peak": "REPLAYGAIN_TRACK_PEAK",
|
||||
"replaygain_album_gain": "REPLAYGAIN_ALBUM_GAIN",
|
||||
"replaygain_album_peak": "REPLAYGAIN_ALBUM_PEAK",
|
||||
}
|
||||
result := make(map[string]struct{})
|
||||
for fk, apeKey := range mapping {
|
||||
if _, present := fields[fk]; present {
|
||||
result[strings.ToUpper(apeKey)] = struct{}{}
|
||||
}
|
||||
}
|
||||
// Some fields have reader aliases that must also be cleared when the
|
||||
// canonical key is updated (e.g. DATE writer ↔ DATE/YEAR reader,
|
||||
// DISC ↔ DISCNUMBER, TRACK ↔ TRACKNUMBER, "ALBUM ARTIST" ↔ ALBUMARTIST,
|
||||
// LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS).
|
||||
if _, present := fields["date"]; present {
|
||||
result["DATE"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["disc_number"]; present {
|
||||
result["DISCNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["disc_total"]; present {
|
||||
result["DISCNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["track_number"]; present {
|
||||
result["TRACKNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["track_total"]; present {
|
||||
result["TRACKNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["album_artist"]; present {
|
||||
result["ALBUMARTIST"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["label"]; present {
|
||||
result["PUBLISHER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["lyrics"]; present {
|
||||
result["UNSYNCEDLYRICS"] = struct{}{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MergeAPEItems overlays newItems on top of existing items.
|
||||
// For each new item, if a matching key exists (case-insensitive) in existing,
|
||||
// it is replaced. New keys are appended. Existing items whose keys are NOT
|
||||
// in newItems are preserved (cover art, ReplayGain, custom tags, etc.).
|
||||
//
|
||||
// overrideKeys is an optional set of upper-case keys that should be removed
|
||||
// from existing even if they do not appear in newItems. This handles field
|
||||
// deletion: the caller sends an empty value which is not serialized into
|
||||
// newItems, but the old value must still be dropped.
|
||||
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
|
||||
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
|
||||
for k := range overrideKeys {
|
||||
combined[strings.ToUpper(k)] = struct{}{}
|
||||
}
|
||||
for _, item := range newItems {
|
||||
combined[strings.ToUpper(item.Key)] = struct{}{}
|
||||
}
|
||||
|
||||
var merged []APETagItem
|
||||
for _, item := range existing {
|
||||
if _, overwritten := combined[strings.ToUpper(item.Key)]; !overwritten {
|
||||
merged = append(merged, item)
|
||||
}
|
||||
}
|
||||
|
||||
merged = append(merged, newItems...)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ReadAPETagsFromReader reads APEv2 tags from an io.ReaderAt + size.
|
||||
// This is useful for reading APE tags from files opened via SAF or other abstractions.
|
||||
func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
|
||||
if fileSize < apeTagHeaderSize {
|
||||
return nil, fmt.Errorf("file too small for APE tag")
|
||||
}
|
||||
|
||||
footer := make([]byte, apeTagHeaderSize)
|
||||
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
|
||||
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
||||
}
|
||||
|
||||
if string(footer[0:8]) == apeTagPreamble {
|
||||
tag, err := parseAPETagFromFooter(r, fileSize, fileSize-apeTagHeaderSize, footer)
|
||||
if err == nil {
|
||||
return tag, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Retry: skip ID3v1 tag (128 bytes)
|
||||
if fileSize > apeTagHeaderSize+128 {
|
||||
offset := fileSize - apeTagHeaderSize - 128
|
||||
if _, err := r.ReadAt(footer, offset); err == nil {
|
||||
if string(footer[0:8]) == apeTagPreamble {
|
||||
tag, err := parseAPETagFromFooter(r, fileSize, offset, footer)
|
||||
if err == nil {
|
||||
return tag, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no APEv2 tag found")
|
||||
}
|
||||
|
||||
func parseAPETagFromFooter(r io.ReaderAt, fileSize, footerOffset int64, footer []byte) (*APETag, error) {
|
||||
version := binary.LittleEndian.Uint32(footer[8:12])
|
||||
tagSize := binary.LittleEndian.Uint32(footer[12:16])
|
||||
itemCount := binary.LittleEndian.Uint32(footer[16:20])
|
||||
flags := binary.LittleEndian.Uint32(footer[20:24])
|
||||
|
||||
if version != apeTagVersion2 && version != 1000 {
|
||||
return nil, fmt.Errorf("unsupported APE tag version: %d", version)
|
||||
}
|
||||
if tagSize < apeTagHeaderSize {
|
||||
return nil, fmt.Errorf("APE tag size too small: %d", tagSize)
|
||||
}
|
||||
if itemCount > 1000 {
|
||||
return nil, fmt.Errorf("APE tag item count too large: %d", itemCount)
|
||||
}
|
||||
if (flags & apeTagFlagHeader) != 0 {
|
||||
return nil, fmt.Errorf("expected footer, found header")
|
||||
}
|
||||
|
||||
itemsSize := int64(tagSize) - apeTagHeaderSize
|
||||
itemsOffset := footerOffset - itemsSize
|
||||
if itemsOffset < 0 {
|
||||
return nil, fmt.Errorf("APE items extend before file start")
|
||||
}
|
||||
|
||||
itemsData := make([]byte, itemsSize)
|
||||
if _, err := r.ReadAt(itemsData, itemsOffset); err != nil {
|
||||
return nil, fmt.Errorf("failed to read APE items: %w", err)
|
||||
}
|
||||
|
||||
items, err := parseAPEItems(itemsData, int(itemCount))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse APE items: %w", err)
|
||||
}
|
||||
|
||||
return &APETag{
|
||||
Version: version,
|
||||
Items: items,
|
||||
ReadOnly: (flags & apeTagFlagReadOnly) != 0,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAPETagReadWriteMergeAndMetadataConversion(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sample.ape")
|
||||
if err := os.WriteFile(path, []byte("audio-data"), 0600); err != nil {
|
||||
t.Fatalf("write sample: %v", err)
|
||||
}
|
||||
|
||||
metadata := &AudioMetadata{
|
||||
Title: "Song",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
AlbumArtist: "Album Artist",
|
||||
Genre: "Pop",
|
||||
Date: "2026",
|
||||
TrackNumber: 3,
|
||||
TotalTracks: 12,
|
||||
DiscNumber: 1,
|
||||
TotalDiscs: 2,
|
||||
ISRC: "USRC17607839",
|
||||
Lyrics: "lyrics",
|
||||
Label: "Label",
|
||||
Copyright: "Copyright",
|
||||
Composer: "Composer",
|
||||
Comment: "Comment",
|
||||
ReplayGainTrackGain: "-6.50 dB",
|
||||
ReplayGainTrackPeak: "0.98",
|
||||
ReplayGainAlbumGain: "-5.00 dB",
|
||||
ReplayGainAlbumPeak: "0.99",
|
||||
}
|
||||
items := AudioMetadataToAPEItems(metadata)
|
||||
if len(items) == 0 {
|
||||
t.Fatal("expected APE items")
|
||||
}
|
||||
|
||||
tag := &APETag{Items: append(items, APETagItem{Key: "Custom", Value: "Keep"})}
|
||||
if err := WriteAPETags(path, tag); err != nil {
|
||||
t.Fatalf("WriteAPETags: %v", err)
|
||||
}
|
||||
|
||||
readTag, err := ReadAPETags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAPETags: %v", err)
|
||||
}
|
||||
if readTag.Version != apeTagVersion2 {
|
||||
t.Fatalf("version = %d", readTag.Version)
|
||||
}
|
||||
readMetadata := APETagToAudioMetadata(readTag)
|
||||
if readMetadata.Title != "Song" || readMetadata.TrackNumber != 3 || readMetadata.TotalTracks != 12 {
|
||||
t.Fatalf("metadata = %#v", readMetadata)
|
||||
}
|
||||
|
||||
readerTag, err := ReadAPETagsFromReader(bytes.NewReader(mustReadFile(t, path)), int64(len(mustReadFile(t, path))))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAPETagsFromReader: %v", err)
|
||||
}
|
||||
if len(readerTag.Items) != len(readTag.Items) {
|
||||
t.Fatalf("reader items = %d, file items = %d", len(readerTag.Items), len(readTag.Items))
|
||||
}
|
||||
|
||||
override := apeKeysFromFields(map[string]string{"title": "", "lyrics": "", "disc_total": ""})
|
||||
merged := MergeAPEItems(readTag.Items, []APETagItem{{Key: "Title", Value: "New Song"}}, override)
|
||||
mergedMeta := APETagToAudioMetadata(&APETag{Items: merged})
|
||||
if mergedMeta.Title != "New Song" {
|
||||
t.Fatalf("merged title = %q", mergedMeta.Title)
|
||||
}
|
||||
if mergedMeta.Lyrics != "" {
|
||||
t.Fatalf("expected lyrics cleared, got %q", mergedMeta.Lyrics)
|
||||
}
|
||||
|
||||
if err := WriteAPETags(path, &APETag{Items: []APETagItem{{Key: "Title", Value: "Replacement"}}}); err != nil {
|
||||
t.Fatalf("replace APE tags: %v", err)
|
||||
}
|
||||
replaced, err := ReadAPETags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read replacement: %v", err)
|
||||
}
|
||||
if got := APETagToAudioMetadata(replaced).Title; got != "Replacement" {
|
||||
t.Fatalf("replacement title = %q", got)
|
||||
}
|
||||
|
||||
if _, err := marshalAPETag(nil); err == nil {
|
||||
t.Fatal("expected empty tag error")
|
||||
}
|
||||
if _, err := ReadAPETags(filepath.Join(dir, "missing.ape")); err == nil {
|
||||
t.Fatal("expected missing file error")
|
||||
}
|
||||
if _, err := ReadAPETagsFromReader(bytes.NewReader([]byte("short")), 5); err == nil {
|
||||
t.Fatal("expected small reader error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPETagInvalidFooterBranches(t *testing.T) {
|
||||
footer := buildAPEHeaderFooter(9999, apeTagHeaderSize, 1, 0)
|
||||
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||
t.Fatal("expected unsupported version")
|
||||
}
|
||||
|
||||
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize-1, 1, 0)
|
||||
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||
t.Fatal("expected small tag size")
|
||||
}
|
||||
|
||||
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize, 1001, 0)
|
||||
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||
t.Fatal("expected too many items")
|
||||
}
|
||||
|
||||
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize, 1, apeTagFlagHeader)
|
||||
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
||||
t.Fatal("expected header flag error")
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AudioMetadata represents common audio file metadata
|
||||
type AudioMetadata struct {
|
||||
Title string
|
||||
Artist string
|
||||
@@ -22,16 +21,22 @@ type AudioMetadata struct {
|
||||
Year string
|
||||
Date string
|
||||
TrackNumber int
|
||||
TotalTracks int
|
||||
DiscNumber int
|
||||
TotalDiscs int
|
||||
ISRC string
|
||||
Lyrics string
|
||||
Label string
|
||||
Copyright string
|
||||
Composer string
|
||||
Comment string
|
||||
// ReplayGain fields (text values, e.g. "-6.50 dB", "0.988831")
|
||||
ReplayGainTrackGain string
|
||||
ReplayGainTrackPeak string
|
||||
ReplayGainAlbumGain string
|
||||
ReplayGainAlbumPeak string
|
||||
}
|
||||
|
||||
// MP3Quality represents MP3 specific quality info
|
||||
type MP3Quality struct {
|
||||
SampleRate int
|
||||
BitDepth int
|
||||
@@ -39,7 +44,6 @@ type MP3Quality struct {
|
||||
Bitrate int
|
||||
}
|
||||
|
||||
// OggQuality represents Ogg/Opus specific quality info
|
||||
type OggQuality struct {
|
||||
SampleRate int
|
||||
BitDepth int
|
||||
@@ -47,10 +51,6 @@ type OggQuality struct {
|
||||
Bitrate int // estimated bitrate in bps
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ID3 Tag Reading (MP3)
|
||||
// =============================================================================
|
||||
|
||||
func ReadID3Tags(filePath string) (*AudioMetadata, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
@@ -175,9 +175,9 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
||||
case "TCO":
|
||||
metadata.Genre = cleanGenre(value)
|
||||
case "TRK":
|
||||
metadata.TrackNumber = parseTrackNumber(value)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "TPA":
|
||||
metadata.DiscNumber = parseTrackNumber(value)
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "TCM":
|
||||
metadata.Composer = value
|
||||
case "TPB":
|
||||
@@ -294,9 +294,9 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
||||
case "TCON":
|
||||
metadata.Genre = cleanGenre(value)
|
||||
case "TRCK":
|
||||
metadata.TrackNumber = parseTrackNumber(value)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "TPOS":
|
||||
metadata.DiscNumber = parseTrackNumber(value)
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "TSRC":
|
||||
metadata.ISRC = value
|
||||
case "TCOM":
|
||||
@@ -318,6 +318,17 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
||||
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
|
||||
metadata.Lyrics = userValue
|
||||
}
|
||||
upperDesc := strings.ToUpper(desc)
|
||||
switch upperDesc {
|
||||
case "REPLAYGAIN_TRACK_GAIN":
|
||||
metadata.ReplayGainTrackGain = userValue
|
||||
case "REPLAYGAIN_TRACK_PEAK":
|
||||
metadata.ReplayGainTrackPeak = userValue
|
||||
case "REPLAYGAIN_ALBUM_GAIN":
|
||||
metadata.ReplayGainAlbumGain = userValue
|
||||
case "REPLAYGAIN_ALBUM_PEAK":
|
||||
metadata.ReplayGainAlbumPeak = userValue
|
||||
}
|
||||
}
|
||||
|
||||
pos += 10 + frameSize
|
||||
@@ -345,7 +356,6 @@ func readID3v1(file *os.File) (*AudioMetadata, error) {
|
||||
Year: strings.TrimRight(string(tag[93:97]), " \x00"),
|
||||
}
|
||||
|
||||
// ID3v1.1 track number (if byte 125 is 0 and byte 126 is not)
|
||||
if tag[125] == 0 && tag[126] != 0 {
|
||||
metadata.TrackNumber = int(tag[126])
|
||||
}
|
||||
@@ -380,27 +390,23 @@ func extractTextFrame(data []byte) string {
|
||||
}
|
||||
}
|
||||
|
||||
// extractCommentFrame parses an ID3v2 COMM frame.
|
||||
// Format: encoding(1) + language(3) + description(null-terminated) + text
|
||||
func extractCommentFrame(data []byte) string {
|
||||
if len(data) < 5 {
|
||||
return ""
|
||||
}
|
||||
encoding := data[0]
|
||||
// skip 3-byte language code
|
||||
rest := data[4:]
|
||||
|
||||
// find null terminator separating description from text
|
||||
var text []byte
|
||||
switch encoding {
|
||||
case 1, 2: // UTF-16 variants use double-null terminator
|
||||
case 1, 2:
|
||||
for i := 0; i+1 < len(rest); i += 2 {
|
||||
if rest[i] == 0 && rest[i+1] == 0 {
|
||||
text = rest[i+2:]
|
||||
break
|
||||
}
|
||||
}
|
||||
default: // ISO-8859-1 or UTF-8
|
||||
default:
|
||||
idx := bytes.IndexByte(rest, 0)
|
||||
if idx >= 0 && idx+1 < len(rest) {
|
||||
text = rest[idx+1:]
|
||||
@@ -413,33 +419,30 @@ func extractCommentFrame(data []byte) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// re-prepend encoding byte so extractTextFrame can decode properly
|
||||
framed := make([]byte, 1+len(text))
|
||||
framed[0] = encoding
|
||||
copy(framed[1:], text)
|
||||
return extractTextFrame(framed)
|
||||
}
|
||||
|
||||
// extractLyricsFrame parses ID3 unsynchronized lyrics frames (USLT/ULT).
|
||||
// Format: encoding(1) + language(3) + description(null-terminated) + lyrics text.
|
||||
func extractLyricsFrame(data []byte) string {
|
||||
if len(data) < 5 {
|
||||
return ""
|
||||
}
|
||||
|
||||
encoding := data[0]
|
||||
rest := data[4:] // skip 3-byte language code
|
||||
rest := data[4:]
|
||||
|
||||
var text []byte
|
||||
switch encoding {
|
||||
case 1, 2: // UTF-16 variants use double-null terminator
|
||||
case 1, 2:
|
||||
for i := 0; i+1 < len(rest); i += 2 {
|
||||
if rest[i] == 0 && rest[i+1] == 0 {
|
||||
text = rest[i+2:]
|
||||
break
|
||||
}
|
||||
}
|
||||
default: // ISO-8859-1 or UTF-8
|
||||
default:
|
||||
idx := bytes.IndexByte(rest, 0)
|
||||
if idx >= 0 && idx+1 < len(rest) {
|
||||
text = rest[idx+1:]
|
||||
@@ -458,8 +461,6 @@ func extractLyricsFrame(data []byte) string {
|
||||
return extractTextFrame(framed)
|
||||
}
|
||||
|
||||
// extractUserTextFrame parses ID3 TXXX/TXX user text frame:
|
||||
// encoding(1) + description + separator + value.
|
||||
func extractUserTextFrame(data []byte) (string, string) {
|
||||
if len(data) < 2 {
|
||||
return "", ""
|
||||
@@ -470,7 +471,7 @@ func extractUserTextFrame(data []byte) (string, string) {
|
||||
|
||||
var descRaw, valueRaw []byte
|
||||
switch encoding {
|
||||
case 1, 2: // UTF-16 variants
|
||||
case 1, 2:
|
||||
for i := 0; i+1 < len(payload); i += 2 {
|
||||
if payload[i] == 0 && payload[i+1] == 0 {
|
||||
descRaw = payload[:i]
|
||||
@@ -478,7 +479,7 @@ func extractUserTextFrame(data []byte) (string, string) {
|
||||
break
|
||||
}
|
||||
}
|
||||
default: // ISO-8859-1 or UTF-8
|
||||
default:
|
||||
idx := bytes.IndexByte(payload, 0)
|
||||
if idx >= 0 {
|
||||
descRaw = payload[:idx]
|
||||
@@ -505,7 +506,13 @@ func extractUserTextFrame(data []byte) (string, string) {
|
||||
|
||||
func isLyricsDescription(description string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(description)) {
|
||||
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
|
||||
case
|
||||
"lyrics",
|
||||
"lyric",
|
||||
"unsyncedlyrics",
|
||||
"unsynced lyrics",
|
||||
"uslt",
|
||||
"lrc":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -575,14 +582,28 @@ func cleanGenre(genre string) string {
|
||||
}
|
||||
|
||||
func parseTrackNumber(s string) int {
|
||||
s = strings.TrimSpace(s)
|
||||
if idx := strings.Index(s, "/"); idx > 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
num, _ := strconv.Atoi(s)
|
||||
num, _ := parseIndexPair(s)
|
||||
return num
|
||||
}
|
||||
|
||||
func parseIndexPair(s string) (int, int) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
first := s
|
||||
second := ""
|
||||
if idx := strings.Index(s, "/"); idx > 0 {
|
||||
first = s[:idx]
|
||||
second = s[idx+1:]
|
||||
}
|
||||
|
||||
num, _ := strconv.Atoi(strings.TrimSpace(first))
|
||||
total, _ := strconv.Atoi(strings.TrimSpace(second))
|
||||
return num, total
|
||||
}
|
||||
|
||||
func removeUnsync(data []byte) []byte {
|
||||
if len(data) == 0 {
|
||||
return data
|
||||
@@ -666,7 +687,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
|
||||
file.Seek(audioStart, io.SeekStart)
|
||||
|
||||
// Find first valid MP3 frame sync
|
||||
frameHeader := make([]byte, 4)
|
||||
var frameStart int64 = -1
|
||||
for i := 0; i < 10000; i++ {
|
||||
@@ -693,8 +713,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
|
||||
channelMode := (frameHeader[3] >> 6) & 0x03
|
||||
|
||||
// Sample rate tables: [version][index]
|
||||
// version: 0=MPEG2.5, 1=reserved, 2=MPEG2, 3=MPEG1
|
||||
sampleRates := [][]int{
|
||||
{11025, 12000, 8000},
|
||||
{0, 0, 0},
|
||||
@@ -705,15 +723,12 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
quality.SampleRate = sampleRates[version][sampleRateIdx]
|
||||
}
|
||||
|
||||
// Bitrate tables for all MPEG versions and layers
|
||||
// MPEG1 Layer III
|
||||
if version == 3 && layer == 1 {
|
||||
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
|
||||
if bitrateIdx < 16 {
|
||||
quality.Bitrate = bitrates[bitrateIdx] * 1000
|
||||
}
|
||||
}
|
||||
// MPEG2/2.5 Layer III
|
||||
if (version == 0 || version == 2) && layer == 1 {
|
||||
bitrates := []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}
|
||||
if bitrateIdx < 16 {
|
||||
@@ -721,14 +736,11 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Determine samples per frame for duration calculation
|
||||
samplesPerFrame := 1152 // MPEG1 Layer III
|
||||
if version == 0 || version == 2 {
|
||||
samplesPerFrame = 576 // MPEG2/2.5 Layer III
|
||||
}
|
||||
|
||||
// Try to read Xing/VBRI header from the first frame for VBR info
|
||||
// Xing header offset depends on MPEG version and channel mode
|
||||
var xingOffset int
|
||||
if version == 3 { // MPEG1
|
||||
if channelMode == 3 { // Mono
|
||||
@@ -744,7 +756,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Read enough of the first frame to find Xing/VBRI header
|
||||
xingBuf := make([]byte, 200)
|
||||
file.Seek(frameStart+4, io.SeekStart)
|
||||
n, _ := io.ReadFull(file, xingBuf)
|
||||
@@ -754,7 +765,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
vbrBytes := int64(0)
|
||||
isVBR := false
|
||||
|
||||
// Check for Xing/Info header
|
||||
if xingOffset+8 <= n {
|
||||
tag := string(xingBuf[xingOffset : xingOffset+4])
|
||||
if tag == "Xing" || tag == "Info" {
|
||||
@@ -773,7 +783,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for VBRI header (always at offset 32 from frame start + 4)
|
||||
if !isVBR && 36+26 <= n {
|
||||
if string(xingBuf[32:36]) == "VBRI" {
|
||||
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10]))
|
||||
@@ -785,11 +794,9 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
}
|
||||
|
||||
if isVBR && vbrFrames > 0 && quality.SampleRate > 0 {
|
||||
// Accurate duration from total frames
|
||||
totalSamples := int64(vbrFrames) * int64(samplesPerFrame)
|
||||
quality.Duration = int(totalSamples / int64(quality.SampleRate))
|
||||
|
||||
// Accurate average bitrate
|
||||
if vbrBytes > 0 && quality.Duration > 0 {
|
||||
quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration))
|
||||
} else if quality.Duration > 0 {
|
||||
@@ -797,7 +804,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
|
||||
quality.Bitrate = int(audioSize * 8 / int64(quality.Duration))
|
||||
}
|
||||
} else if quality.Bitrate > 0 {
|
||||
// CBR fallback: estimate duration from file size and frame bitrate
|
||||
audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag
|
||||
if audioSize > 0 {
|
||||
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
|
||||
@@ -981,8 +987,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
}
|
||||
|
||||
reader := bytes.NewReader(data)
|
||||
artistValues := make([]string, 0, 1)
|
||||
albumArtistValues := make([]string, 0, 1)
|
||||
|
||||
// Read vendor string length
|
||||
var vendorLen uint32
|
||||
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
|
||||
return
|
||||
@@ -1011,8 +1018,6 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
if commentLen > remaining {
|
||||
break
|
||||
}
|
||||
// Large comment entries are typically METADATA_BLOCK_PICTURE.
|
||||
// Skip them so we can continue parsing normal text tags after/before.
|
||||
if commentLen > 512*1024 {
|
||||
reader.Seek(int64(commentLen), io.SeekCurrent)
|
||||
continue
|
||||
@@ -1035,9 +1040,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
case "TITLE":
|
||||
metadata.Title = value
|
||||
case "ARTIST":
|
||||
metadata.Artist = value
|
||||
artistValues = append(artistValues, value)
|
||||
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
|
||||
metadata.AlbumArtist = value
|
||||
albumArtistValues = append(albumArtistValues, value)
|
||||
case "ALBUM":
|
||||
metadata.Album = value
|
||||
case "DATE", "YEAR":
|
||||
@@ -1048,9 +1053,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
case "GENRE":
|
||||
metadata.Genre = value
|
||||
case "TRACKNUMBER", "TRACK":
|
||||
metadata.TrackNumber = parseTrackNumber(value)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "DISCNUMBER", "DISC":
|
||||
metadata.DiscNumber = parseTrackNumber(value)
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "ISRC":
|
||||
metadata.ISRC = value
|
||||
case "COMPOSER":
|
||||
@@ -1065,8 +1070,23 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
metadata.Label = value
|
||||
case "COPYRIGHT":
|
||||
metadata.Copyright = value
|
||||
case "REPLAYGAIN_TRACK_GAIN":
|
||||
metadata.ReplayGainTrackGain = value
|
||||
case "REPLAYGAIN_TRACK_PEAK":
|
||||
metadata.ReplayGainTrackPeak = value
|
||||
case "REPLAYGAIN_ALBUM_GAIN":
|
||||
metadata.ReplayGainAlbumGain = value
|
||||
case "REPLAYGAIN_ALBUM_PEAK":
|
||||
metadata.ReplayGainAlbumPeak = value
|
||||
}
|
||||
}
|
||||
|
||||
if len(artistValues) > 0 {
|
||||
metadata.Artist = joinVorbisCommentValues(artistValues)
|
||||
}
|
||||
if len(albumArtistValues) > 0 {
|
||||
metadata.AlbumArtist = joinVorbisCommentValues(albumArtistValues)
|
||||
}
|
||||
}
|
||||
|
||||
func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
@@ -1115,7 +1135,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Read granule position from the last Ogg page for accurate duration
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
return quality, nil
|
||||
@@ -1125,7 +1144,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
granule := readLastOggGranulePosition(file, fileSize)
|
||||
if granule > 0 {
|
||||
if isOpus {
|
||||
// Opus always uses 48kHz granule position internally
|
||||
totalSamples := granule - int64(preSkip)
|
||||
if totalSamples > 0 {
|
||||
durationSec := float64(totalSamples) / 48000.0
|
||||
@@ -1143,11 +1161,9 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback bitrate estimate if duration exists but bitrate couldn't be derived.
|
||||
if quality.Bitrate <= 0 && quality.Duration > 0 {
|
||||
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
|
||||
}
|
||||
// Guard against obviously invalid values from corrupted/unreliable granule reads.
|
||||
if quality.Duration > 24*60*60 {
|
||||
quality.Duration = 0
|
||||
quality.Bitrate = 0
|
||||
@@ -1159,10 +1175,7 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
return quality, nil
|
||||
}
|
||||
|
||||
// readLastOggGranulePosition seeks to the end of the file and scans backwards
|
||||
// to find the last Ogg page, then reads its granule position (bytes 6-13).
|
||||
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
||||
// Read the last chunk of the file to find the last OggS sync
|
||||
searchSize := int64(65536)
|
||||
if searchSize > fileSize {
|
||||
searchSize = fileSize
|
||||
@@ -1186,7 +1199,6 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
||||
if i+27 > n {
|
||||
continue
|
||||
}
|
||||
// Validate minimal header fields to avoid false positives inside payload bytes.
|
||||
version := buf[i+4]
|
||||
headerType := buf[i+5]
|
||||
if version != 0 || headerType > 0x07 {
|
||||
@@ -1204,16 +1216,11 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
||||
if i+headerLen+payloadLen > n {
|
||||
continue
|
||||
}
|
||||
// Granule position is at bytes 6-13 of the Ogg page header (little-endian int64).
|
||||
return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14]))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ID3v1 Genre List
|
||||
// =============================================================================
|
||||
|
||||
var id3v1Genres = []string{
|
||||
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
|
||||
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
|
||||
@@ -1244,10 +1251,6 @@ var id3v1Genres = []string{
|
||||
"Thrash Metal", "Anime", "J-Pop", "Synthpop",
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cover Art Extraction
|
||||
// =============================================================================
|
||||
|
||||
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
@@ -1272,7 +1275,6 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// Parse frames looking for APIC (Attached Picture)
|
||||
pos := 0
|
||||
var frameIDLen, headerLen int
|
||||
if majorVersion == 2 {
|
||||
@@ -1303,7 +1305,6 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
|
||||
break
|
||||
}
|
||||
|
||||
// Check for APIC (ID3v2.3/2.4) or PIC (ID3v2.2)
|
||||
if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") {
|
||||
frameData := tagData[pos+headerLen : pos+headerLen+frameSize]
|
||||
imageData, mimeType := parseAPICFrame(frameData, majorVersion)
|
||||
@@ -1581,7 +1582,14 @@ func base64StdDecode(dst, src []byte) (int, error) {
|
||||
}
|
||||
|
||||
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
||||
return extractAnyCoverArtWithHint(filePath, "")
|
||||
}
|
||||
|
||||
func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
if ext == "" {
|
||||
ext = strings.ToLower(filepath.Ext(displayNameHint))
|
||||
}
|
||||
|
||||
switch ext {
|
||||
case ".flac":
|
||||
@@ -1602,7 +1610,22 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
||||
return extractOggCoverArt(filePath)
|
||||
|
||||
case ".m4a":
|
||||
return nil, "", fmt.Errorf("M4A cover extraction not yet supported")
|
||||
data, err := extractCoverFromM4A(filePath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
mimeType := "image/jpeg"
|
||||
if len(data) >= 8 &&
|
||||
data[0] == 0x89 &&
|
||||
data[1] == 0x50 &&
|
||||
data[2] == 0x4E &&
|
||||
data[3] == 0x47 {
|
||||
mimeType = "image/png"
|
||||
}
|
||||
return data, mimeType, nil
|
||||
|
||||
case ".wav", ".aiff", ".aif", ".aifc":
|
||||
return extractWAVAIFFCover(filePath)
|
||||
|
||||
default:
|
||||
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
||||
@@ -1610,10 +1633,28 @@ func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
||||
}
|
||||
|
||||
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||
return SaveCoverToCacheWithHintAndKey(filePath, "", cacheDir, "")
|
||||
}
|
||||
|
||||
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
|
||||
return SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, "")
|
||||
}
|
||||
|
||||
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
|
||||
explicitKey = strings.TrimSpace(explicitKey)
|
||||
if explicitKey != "" {
|
||||
return explicitKey
|
||||
}
|
||||
|
||||
cacheKey := filePath
|
||||
if stat, err := os.Stat(filePath); err == nil {
|
||||
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
|
||||
}
|
||||
return cacheKey
|
||||
}
|
||||
|
||||
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
|
||||
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
||||
hash := hashString(cacheKey)
|
||||
|
||||
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
|
||||
@@ -1626,7 +1667,7 @@ func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||
return pngPath, nil
|
||||
}
|
||||
|
||||
imageData, mimeType, err := extractAnyCoverArt(filePath)
|
||||
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveLibraryCoverCacheKeyUsesExplicitKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const explicitKey = "content://media/external/audio/media/42|123456"
|
||||
got := resolveLibraryCoverCacheKey("/tmp/saf_random.flac", explicitKey)
|
||||
if got != explicitKey {
|
||||
t.Fatalf("expected explicit cache key %q, got %q", explicitKey, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLibraryCoverCacheKeyUsesFilePathAndStatWhenNoExplicitKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempFile, err := os.CreateTemp("", "cover-cache-*.flac")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp failed: %v", err)
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
tempFile.Close()
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
got := resolveLibraryCoverCacheKey(tempPath, "")
|
||||
if !strings.HasPrefix(got, tempPath+"|") {
|
||||
t.Fatalf("expected stat-based cache key to start with %q, got %q", tempPath+"|", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ffmpegCommand(args ...string) *exec.Cmd {
|
||||
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
return exec.Command(ffmpegPath, args...)
|
||||
}
|
||||
return exec.Command("ffmpeg", args...)
|
||||
}
|
||||
|
||||
func runFFmpegTestCommand(t *testing.T, args ...string) {
|
||||
t.Helper()
|
||||
cmd := ffmpegCommand(args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("ffmpeg failed: %v\n%s", err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLyricsReadsMp3AfterCoverEmbed(t *testing.T) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("ffmpeg not available")
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
sourceFlac := filepath.Join(tempDir, "source.flac")
|
||||
baseMp3 := filepath.Join(tempDir, "base.mp3")
|
||||
finalMp3 := filepath.Join(tempDir, "final.mp3")
|
||||
coverPath := filepath.Join(tempDir, "cover.jpg")
|
||||
lyrics := "[ti:Test Song]\n[ar:Test Artist]\n[00:00.00]Hello from embedded lyrics"
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:duration=1",
|
||||
"-c:a",
|
||||
"flac",
|
||||
sourceFlac,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=red:s=32x32:d=1",
|
||||
"-frames:v",
|
||||
"1",
|
||||
coverPath,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-i",
|
||||
sourceFlac,
|
||||
"-b:a",
|
||||
"320k",
|
||||
"-metadata",
|
||||
"title=Test Song",
|
||||
"-metadata",
|
||||
"artist=Test Artist",
|
||||
"-metadata",
|
||||
"lyrics="+lyrics,
|
||||
baseMp3,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-i",
|
||||
baseMp3,
|
||||
"-i",
|
||||
coverPath,
|
||||
"-map",
|
||||
"0:a",
|
||||
"-map_metadata",
|
||||
"-1",
|
||||
"-map",
|
||||
"1:0",
|
||||
"-c:v:0",
|
||||
"copy",
|
||||
"-id3v2_version",
|
||||
"3",
|
||||
"-metadata",
|
||||
"title=Test Song",
|
||||
"-metadata",
|
||||
"artist=Test Artist",
|
||||
"-metadata",
|
||||
"lyrics="+lyrics,
|
||||
"-metadata:s:v",
|
||||
"title=Album cover",
|
||||
"-metadata:s:v",
|
||||
"comment=Cover (front)",
|
||||
"-c:a",
|
||||
"copy",
|
||||
finalMp3,
|
||||
)
|
||||
|
||||
meta, err := ReadID3Tags(finalMp3)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadID3Tags failed: %v", err)
|
||||
}
|
||||
if meta == nil {
|
||||
t.Fatalf("ReadID3Tags returned nil metadata")
|
||||
}
|
||||
|
||||
embeddedLyrics, err := ExtractLyrics(finalMp3)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractLyrics failed: %v (metadata=%+v)", err, meta)
|
||||
}
|
||||
if !strings.Contains(embeddedLyrics, "Hello from embedded lyrics") {
|
||||
t.Fatalf("embedded lyrics missing, got %q (metadata=%+v)", embeddedLyrics, meta)
|
||||
}
|
||||
if !strings.Contains(meta.Lyrics, "Hello from embedded lyrics") {
|
||||
t.Fatalf("ReadID3Tags lyrics missing, got %+v", meta)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(finalMp3); err != nil {
|
||||
t.Fatalf("expected final mp3 to exist: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAudioMetadataID3ParsingBranches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "tagged.mp3")
|
||||
tag := buildID3v23Tag(
|
||||
id3TextFrame("TIT2", "Title"),
|
||||
id3TextFrame("TPE1", "Artist"),
|
||||
id3TextFrame("TPE2", "Album Artist"),
|
||||
id3TextFrame("TALB", "Album"),
|
||||
id3TextFrame("TDRC", "2026-05-04"),
|
||||
id3TextFrame("TCON", "(13)Pop"),
|
||||
id3TextFrame("TRCK", "4/12"),
|
||||
id3TextFrame("TPOS", "1/2"),
|
||||
id3TextFrame("TSRC", "USRC17607839"),
|
||||
id3TextFrame("TCOM", "Composer"),
|
||||
id3TextFrame("TPUB", "Label"),
|
||||
id3TextFrame("TCOP", "Copyright"),
|
||||
id3CommentFrame("COMM", "Comment"),
|
||||
id3CommentFrame("USLT", "Lyrics"),
|
||||
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_GAIN", "-6.50 dB"),
|
||||
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_PEAK", "0.98"),
|
||||
)
|
||||
if err := os.WriteFile(path, append(tag, []byte("audio")...), 0600); err != nil {
|
||||
t.Fatalf("write ID3v2: %v", err)
|
||||
}
|
||||
|
||||
meta, err := ReadID3Tags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadID3Tags: %v", err)
|
||||
}
|
||||
if meta.Title != "Title" || meta.TrackNumber != 4 || meta.TotalTracks != 12 || meta.Genre != "Pop" {
|
||||
t.Fatalf("metadata = %#v", meta)
|
||||
}
|
||||
if meta.Comment != "Comment" || meta.Lyrics != "Lyrics" || meta.ReplayGainTrackGain == "" {
|
||||
t.Fatalf("metadata comments/lyrics/replaygain = %#v", meta)
|
||||
}
|
||||
|
||||
id3v1Path := filepath.Join(dir, "id3v1.mp3")
|
||||
if err := os.WriteFile(id3v1Path, append([]byte("audio"), buildID3v1Tag("V1 Title", "V1 Artist", "V1 Album", "1999", 7, 13)...), 0600); err != nil {
|
||||
t.Fatalf("write ID3v1: %v", err)
|
||||
}
|
||||
v1, err := ReadID3Tags(id3v1Path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadID3Tags v1: %v", err)
|
||||
}
|
||||
if v1.Title != "V1 Title" || v1.Artist != "V1 Artist" || v1.Genre == "" {
|
||||
t.Fatalf("v1 = %#v", v1)
|
||||
}
|
||||
|
||||
v22Path := filepath.Join(dir, "id3v22.mp3")
|
||||
v22 := buildID3v22Tag(
|
||||
id3v22TextFrame("TT2", "V22 Title"),
|
||||
id3v22TextFrame("TP1", "V22 Artist"),
|
||||
id3v22TextFrame("TRK", "2/5"),
|
||||
id3v22CommentFrame("ULT", "V22 Lyrics"),
|
||||
)
|
||||
if err := os.WriteFile(v22Path, append(v22, []byte("audio")...), 0600); err != nil {
|
||||
t.Fatalf("write ID3v2.2: %v", err)
|
||||
}
|
||||
v22Meta, err := ReadID3Tags(v22Path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadID3Tags v2.2: %v", err)
|
||||
}
|
||||
if v22Meta.Title != "V22 Title" || v22Meta.Artist != "V22 Artist" || v22Meta.Lyrics != "V22 Lyrics" {
|
||||
t.Fatalf("v22 = %#v", v22Meta)
|
||||
}
|
||||
|
||||
if got := decodeUTF16([]byte{0xff, 0xfe, 'H', 0, 'i', 0}); got != "Hi" {
|
||||
t.Fatalf("decodeUTF16 = %q", got)
|
||||
}
|
||||
if got := decodeUTF16BE([]byte{0, 'O', 0, 'K'}); got != "OK" {
|
||||
t.Fatalf("decodeUTF16BE = %q", got)
|
||||
}
|
||||
if n, total := parseIndexPair(" 8 / 10 "); n != 8 || total != 10 {
|
||||
t.Fatalf("parseIndexPair = %d/%d", n, total)
|
||||
}
|
||||
if got := parseTrackNumber("9/11"); got != 9 {
|
||||
t.Fatalf("parseTrackNumber = %d", got)
|
||||
}
|
||||
if got := removeUnsync([]byte{0xff, 0x00, 0xe0}); !bytes.Equal(got, []byte{0xff, 0xe0}) {
|
||||
t.Fatalf("removeUnsync = %#v", got)
|
||||
}
|
||||
if got := extendedHeaderSize([]byte{0, 0, 0, 6, 0, 0, 0, 0, 0, 0}, 3); got != 10 {
|
||||
t.Fatalf("extendedHeaderSize = %d", got)
|
||||
}
|
||||
if got := syncsafeToInt([]byte{0, 0, 2, 0}); got != 256 {
|
||||
t.Fatalf("syncsafe = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioMetadataCoverAndQualityHelpers(t *testing.T) {
|
||||
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0}
|
||||
if detectCoverMIME("cover.jpg", png) != "image/png" || detectCoverMIME("cover.webp", []byte("RIFFxxxxWEBPdata")) != "image/webp" {
|
||||
t.Fatal("cover MIME detection mismatch")
|
||||
}
|
||||
if _, err := buildPictureBlock("", nil); err == nil {
|
||||
t.Fatal("expected empty picture block error")
|
||||
}
|
||||
|
||||
apic := append([]byte{3}, []byte("image/png\x00")...)
|
||||
apic = append(apic, 3, 0)
|
||||
apic = append(apic, png...)
|
||||
image, mime := parseAPICFrame(apic, 3)
|
||||
if mime != "image/png" || !bytes.Equal(image, png) {
|
||||
t.Fatalf("APIC = %s/%v", mime, image)
|
||||
}
|
||||
pic := append([]byte{0}, []byte("PNG")...)
|
||||
pic = append(pic, 3, 0)
|
||||
pic = append(pic, png...)
|
||||
image, mime = parseAPICFrame(pic, 2)
|
||||
if mime != "image/png" || !bytes.Equal(image, png) {
|
||||
t.Fatalf("PIC = %s/%v", mime, image)
|
||||
}
|
||||
|
||||
frame := make([]byte, 10)
|
||||
copy(frame[:4], "APIC")
|
||||
binary.BigEndian.PutUint32(frame[4:8], uint32(len(apic)))
|
||||
tag := append(frame, apic...)
|
||||
header := []byte{'I', 'D', '3', 3, 0, 0, byte(len(tag) >> 21), byte(len(tag) >> 14), byte(len(tag) >> 7), byte(len(tag))}
|
||||
mp3CoverPath := filepath.Join(t.TempDir(), "cover.mp3")
|
||||
if err := os.WriteFile(mp3CoverPath, append(append(header, tag...), []byte("audio")...), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
extracted, extractedMIME, err := extractMP3CoverArt(mp3CoverPath)
|
||||
if err != nil || extractedMIME != "image/png" || !bytes.Equal(extracted, png) {
|
||||
t.Fatalf("extractMP3CoverArt = %s/%v/%v", extractedMIME, extracted, err)
|
||||
}
|
||||
|
||||
var picture bytes.Buffer
|
||||
binary.Write(&picture, binary.BigEndian, uint32(3))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(len("image/png")))
|
||||
picture.WriteString("image/png")
|
||||
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(32))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(len(png)))
|
||||
picture.Write(png)
|
||||
flacImage, flacMIME := parseFLACPictureBlock(picture.Bytes())
|
||||
if flacMIME != "image/png" || !bytes.Equal(flacImage, png) {
|
||||
t.Fatalf("FLAC picture = %s/%v", flacMIME, flacImage)
|
||||
}
|
||||
|
||||
comment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture.Bytes())
|
||||
var vorbis bytes.Buffer
|
||||
binary.Write(&vorbis, binary.LittleEndian, uint32(6))
|
||||
vorbis.WriteString("vendor")
|
||||
binary.Write(&vorbis, binary.LittleEndian, uint32(1))
|
||||
binary.Write(&vorbis, binary.LittleEndian, uint32(len(comment)))
|
||||
vorbis.WriteString(comment)
|
||||
commentImage, commentMIME := extractPictureFromVorbisComments(vorbis.Bytes())
|
||||
if commentMIME != "image/png" || !bytes.Equal(commentImage, png) {
|
||||
t.Fatalf("vorbis picture = %s/%v", commentMIME, commentImage)
|
||||
}
|
||||
decoded := make([]byte, base64StdDecodeLen(len("SGV sbG8="))+4)
|
||||
n, err := base64StdDecode(decoded, []byte("SGV sbG8="))
|
||||
if err != nil || strings.TrimRight(string(decoded[:n]), "\x00") != "Hello" {
|
||||
t.Fatalf("base64 decode = %q/%v", decoded[:n], err)
|
||||
}
|
||||
|
||||
if detectOggStreamType([][]byte{[]byte("OpusHeadxxxx")}) != oggStreamOpus {
|
||||
t.Fatal("expected opus stream")
|
||||
}
|
||||
if detectOggStreamType([][]byte{append([]byte{1}, []byte("vorbisxxxx")...)}) != oggStreamVorbis {
|
||||
t.Fatal("expected vorbis stream")
|
||||
}
|
||||
|
||||
mp3Path := filepath.Join(t.TempDir(), "quality.mp3")
|
||||
audio := append([]byte{0xFF, 0xFB, 0x90, 0x64}, bytes.Repeat([]byte{0}, 2000)...)
|
||||
if err := os.WriteFile(mp3Path, audio, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quality, err := GetMP3Quality(mp3Path)
|
||||
if err != nil || quality.SampleRate != 44100 || quality.Bitrate != 128000 {
|
||||
t.Fatalf("MP3 quality = %#v/%v", quality, err)
|
||||
}
|
||||
if _, _, err := extractMP3CoverArt(filepath.Join(t.TempDir(), "missing.mp3")); err == nil {
|
||||
t.Fatal("expected missing MP3 cover error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestM4AMetadataAtomHelpers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "tagged.m4a")
|
||||
cover := []byte{0xFF, 0xD8, 0xFF, 0x00}
|
||||
ilstPayload := []byte{}
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9nam", "M4A Title")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9ART", "M4A Artist")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9alb", "M4A Album")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("aART", "Album Artist")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9day", "2026")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9gen", "Pop")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9wrt", "Composer")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9cmt", "[ti:Comment Lyrics]")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("cprt", "Copyright")...)
|
||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9lyr", "[00:00.00]M4A Lyrics")...)
|
||||
ilstPayload = append(ilstPayload, buildM4AIndexTag("trkn", 3, 12)...)
|
||||
ilstPayload = append(ilstPayload, buildM4AIndexTag("disk", 1, 2)...)
|
||||
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("ISRC", "USRC17607839")...)
|
||||
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("LABEL", "Label")...)
|
||||
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("REPLAYGAIN_TRACK_GAIN", "-6.50 dB")...)
|
||||
ilstPayload = append(ilstPayload, buildM4AAtom("covr", buildM4AAtom("data", append([]byte{0, 0, 0, 13, 0, 0, 0, 0}, cover...)))...)
|
||||
fileData := buildM4AFileWithIlst(ilstPayload, true)
|
||||
if err := os.WriteFile(path, fileData, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
meta, err := ReadM4ATags(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadM4ATags: %v", err)
|
||||
}
|
||||
if meta.Title != "M4A Title" || meta.Artist != "M4A Artist" || meta.TrackNumber != 3 || meta.TotalTracks != 12 || meta.ISRC != "USRC17607839" {
|
||||
t.Fatalf("M4A metadata = %#v", meta)
|
||||
}
|
||||
if lyrics, err := extractLyricsFromM4A(path); err != nil || !strings.Contains(lyrics, "M4A Lyrics") {
|
||||
t.Fatalf("extractLyricsFromM4A = %q/%v", lyrics, err)
|
||||
}
|
||||
if image, err := extractCoverFromM4A(path); err != nil || !bytes.Equal(image, cover) {
|
||||
t.Fatalf("extractCoverFromM4A = %#v/%v", image, err)
|
||||
}
|
||||
if pathInfo, err := func() (m4aMetadataPath, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return m4aMetadataPath{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
info, _ := f.Stat()
|
||||
return findM4AMetadataPath(f, info.Size())
|
||||
}(); err != nil || pathInfo.udta == nil {
|
||||
t.Fatalf("findM4AMetadataPath = %#v/%v", pathInfo, err)
|
||||
}
|
||||
if err := EditM4AReplayGain(path, map[string]string{"replaygain_track_gain": "-5.00 dB", "replaygain_track_peak": "0.98"}); err != nil {
|
||||
t.Fatalf("EditM4AReplayGain: %v", err)
|
||||
}
|
||||
edited, err := ReadM4ATags(path)
|
||||
if err != nil || edited.ReplayGainTrackGain != "-5.00 dB" || edited.ReplayGainTrackPeak != "0.98" {
|
||||
t.Fatalf("edited M4A = %#v/%v", edited, err)
|
||||
}
|
||||
|
||||
noUdtaPath := filepath.Join(dir, "noudta.m4a")
|
||||
if err := os.WriteFile(noUdtaPath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "No Udta"), false), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta, err := ReadM4ATags(noUdtaPath); err != nil || meta.Title != "No Udta" {
|
||||
t.Fatalf("ReadM4ATags no udta = %#v/%v", meta, err)
|
||||
}
|
||||
if _, err := ReadM4ATags(filepath.Join(dir, "missing.m4a")); err == nil {
|
||||
t.Fatal("expected missing M4A error")
|
||||
}
|
||||
emptyM4A := filepath.Join(dir, "empty.m4a")
|
||||
if err := os.WriteFile(emptyM4A, buildM4AFileWithIlst(nil, true), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadM4ATags(emptyM4A); err == nil {
|
||||
t.Fatal("expected empty M4A tags error")
|
||||
}
|
||||
if _, err := extractCoverFromM4A(emptyM4A); err == nil {
|
||||
t.Fatal("expected missing M4A cover error")
|
||||
}
|
||||
if _, err := extractLyricsFromM4A(emptyM4A); err == nil {
|
||||
t.Fatal("expected missing M4A lyrics error")
|
||||
}
|
||||
|
||||
sidecarAudio := filepath.Join(dir, "sidecar.mp3")
|
||||
if err := os.WriteFile(sidecarAudio, []byte("audio"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "sidecar.lrc"), []byte(" [00:00.00]Sidecar "), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lyrics, err := extractLyricsFromSidecarLRC(sidecarAudio); err != nil || !strings.Contains(lyrics, "Sidecar") {
|
||||
t.Fatalf("sidecar lyrics = %q/%v", lyrics, err)
|
||||
}
|
||||
if !looksLikeEmbeddedLyrics("[ti:Song]") || !looksLikeEmbeddedLyrics("[00:00.00]Line\n[00:01.00]Next") || looksLikeEmbeddedLyrics("plain") {
|
||||
t.Fatal("embedded lyric heuristic mismatch")
|
||||
}
|
||||
if formatIndexValue(3, 12) != "3/12" || formatIndexValue(3, 0) != "3" || formatIndexValue(0, 12) != "" {
|
||||
t.Fatal("formatIndexValue mismatch")
|
||||
}
|
||||
if parsePositiveInt(" 42 ") != 42 || parsePositiveInt("bad") != 0 {
|
||||
t.Fatal("parsePositiveInt mismatch")
|
||||
}
|
||||
if !hasMapKey(map[string]string{"x": "y"}, "x") {
|
||||
t.Fatal("expected map key")
|
||||
}
|
||||
if _, ok := parseReplayGainDb("-6.50 dB"); !ok {
|
||||
t.Fatal("expected ReplayGain dB parse")
|
||||
}
|
||||
if _, ok := parseReplayGainPeak("0.98"); !ok {
|
||||
t.Fatal("expected ReplayGain peak parse")
|
||||
}
|
||||
if norm := buildITunNORMTag("-6.50 dB", "0.98"); norm == "" {
|
||||
t.Fatal("expected iTunNORM")
|
||||
}
|
||||
if fields := collectM4AReplayGainFields(map[string]string{"replaygain_track_gain": "-6 dB", "replaygain_track_peak": "0.9"}); fields["iTunNORM"] == "" {
|
||||
t.Fatalf("ReplayGain fields = %#v", fields)
|
||||
}
|
||||
|
||||
qualityPath := filepath.Join(dir, "quality-alac.m4a")
|
||||
mvhd := make([]byte, 20)
|
||||
binary.BigEndian.PutUint32(mvhd[12:16], 1000)
|
||||
binary.BigEndian.PutUint32(mvhd[16:20], 180000)
|
||||
sampleEntry := make([]byte, 32)
|
||||
copy(sampleEntry[0:4], "alac")
|
||||
binary.BigEndian.PutUint16(sampleEntry[22:24], 24)
|
||||
sampleEntry[28] = 0xAC
|
||||
sampleEntry[29] = 0x44
|
||||
alacConfig := make([]byte, 24)
|
||||
alacConfig[5] = 24
|
||||
binary.BigEndian.PutUint32(alacConfig[20:24], 44100)
|
||||
alacEntryPayload := append(append([]byte{}, sampleEntry[4:]...), buildM4AAtom("alac", alacConfig)...)
|
||||
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), buildM4AAtom("alac", alacEntryPayload)...))...)
|
||||
if err := os.WriteFile(qualityPath, qualityFile, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality, err := GetM4AQuality(qualityPath); err != nil || quality.BitDepth != 24 || quality.SampleRate != 44100 || quality.Duration != 180 {
|
||||
t.Fatalf("GetM4AQuality = %#v/%v", quality, err)
|
||||
}
|
||||
if quality, err := GetAudioQuality(qualityPath); err != nil || quality.SampleRate != 44100 {
|
||||
t.Fatalf("GetAudioQuality M4A = %#v/%v", quality, err)
|
||||
}
|
||||
aacQualityPath := filepath.Join(dir, "quality-aac.m4a")
|
||||
copy(sampleEntry[0:4], "mp4a")
|
||||
aacQualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
|
||||
if err := os.WriteFile(aacQualityPath, aacQualityFile, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality, err := GetM4AQuality(aacQualityPath); err != nil || quality.BitDepth != 0 || quality.SampleRate != 44100 || quality.Duration != 180 {
|
||||
t.Fatalf("GetM4AQuality AAC = %#v/%v", quality, err)
|
||||
}
|
||||
eac3QualityPath := filepath.Join(dir, "quality-eac3.m4a")
|
||||
zeroMvhd := make([]byte, 20)
|
||||
eac3SampleEntry := make([]byte, 32)
|
||||
copy(eac3SampleEntry[0:4], "ec-3")
|
||||
eac3SampleEntry[28] = 0xBB
|
||||
eac3SampleEntry[29] = 0x80
|
||||
mdhd := make([]byte, 20)
|
||||
binary.BigEndian.PutUint32(mdhd[12:16], 48000)
|
||||
binary.BigEndian.PutUint32(mdhd[16:20], 48000*123)
|
||||
eac3QualityFile := append(
|
||||
buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")),
|
||||
buildM4AAtom("moov", append(
|
||||
append(buildM4AAtom("mvhd", zeroMvhd), buildM4AAtom("trak", buildM4AAtom("mdia", buildM4AAtom("mdhd", mdhd)))...),
|
||||
eac3SampleEntry...,
|
||||
))...,
|
||||
)
|
||||
if err := os.WriteFile(eac3QualityPath, eac3QualityFile, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality, err := GetM4AQuality(eac3QualityPath); err != nil || quality.Codec != "eac3" || quality.Duration != 123 {
|
||||
t.Fatalf("GetM4AQuality EAC3 mdhd fallback = %#v/%v", quality, err)
|
||||
}
|
||||
if _, _, ok := parseALACSpecificConfig(make([]byte, 4)); ok {
|
||||
t.Fatal("short ALAC config should not parse")
|
||||
}
|
||||
alac := make([]byte, 24)
|
||||
alac[5] = 16
|
||||
binary.BigEndian.PutUint32(alac[20:24], 48000)
|
||||
if depth, rate, ok := parseALACSpecificConfig(alac); !ok || depth != 16 || rate != 48000 {
|
||||
t.Fatalf("ALAC config = %d/%d/%v", depth, rate, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opusHead := make([]byte, 19)
|
||||
copy(opusHead[0:8], "OpusHead")
|
||||
binary.LittleEndian.PutUint16(opusHead[10:12], 312)
|
||||
binary.LittleEndian.PutUint32(opusHead[12:16], 48000)
|
||||
|
||||
var comments bytes.Buffer
|
||||
binary.Write(&comments, binary.LittleEndian, uint32(6))
|
||||
comments.WriteString("vendor")
|
||||
entries := []string{
|
||||
"TITLE=Ogg Title",
|
||||
"ARTIST=Artist",
|
||||
"ALBUMARTIST=Album Artist",
|
||||
"TRACKNUMBER=2/9",
|
||||
"DISCNUMBER=1/2",
|
||||
"LYRICS=[00:00.00]Ogg Lyrics",
|
||||
}
|
||||
binary.Write(&comments, binary.LittleEndian, uint32(len(entries)))
|
||||
for _, entry := range entries {
|
||||
binary.Write(&comments, binary.LittleEndian, uint32(len(entry)))
|
||||
comments.WriteString(entry)
|
||||
}
|
||||
opusTags := append([]byte("OpusTags"), comments.Bytes()...)
|
||||
oggPath := filepath.Join(dir, "tagged.opus")
|
||||
oggData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, opusTags)...)
|
||||
if err := os.WriteFile(oggPath, oggData, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quality, err := GetOggQuality(oggPath)
|
||||
if err != nil || quality.SampleRate != 48000 || quality.Duration != 1 {
|
||||
t.Fatalf("GetOggQuality = %#v/%v", quality, err)
|
||||
}
|
||||
meta, err := ReadOggVorbisComments(oggPath)
|
||||
if err != nil || meta.Title != "Ogg Title" || meta.TrackNumber != 2 || meta.TotalTracks != 9 {
|
||||
t.Fatalf("ReadOggVorbisComments = %#v/%v", meta, err)
|
||||
}
|
||||
|
||||
picture := buildTestFLACPictureBlock([]byte{0x89, 0x50, 0x4E, 0x47}, "image/png")
|
||||
pictureComment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture)
|
||||
var coverComments bytes.Buffer
|
||||
binary.Write(&coverComments, binary.LittleEndian, uint32(6))
|
||||
coverComments.WriteString("vendor")
|
||||
binary.Write(&coverComments, binary.LittleEndian, uint32(1))
|
||||
binary.Write(&coverComments, binary.LittleEndian, uint32(len(pictureComment)))
|
||||
coverComments.WriteString(pictureComment)
|
||||
coverPath := filepath.Join(dir, "cover.opus")
|
||||
coverData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, append([]byte("OpusTags"), coverComments.Bytes()...))...)
|
||||
if err := os.WriteFile(coverPath, coverData, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if image, mime, err := extractOggCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 {
|
||||
t.Fatalf("extractOggCoverArt = %s/%#v/%v", mime, image, err)
|
||||
}
|
||||
if image, mime, err := extractAnyCoverArtWithHint(coverPath, "cover.opus"); err != nil || mime != "image/png" || len(image) == 0 {
|
||||
t.Fatalf("extractAnyCoverArtWithHint = %s/%#v/%v", mime, image, err)
|
||||
}
|
||||
if image, mime, err := extractAnyCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 {
|
||||
t.Fatalf("extractAnyCoverArt = %s/%#v/%v", mime, image, err)
|
||||
}
|
||||
extractedCoverPath := filepath.Join(dir, "extracted.png")
|
||||
if err := ExtractCoverToFile(coverPath, extractedCoverPath); err != nil {
|
||||
t.Fatalf("ExtractCoverToFile = %v", err)
|
||||
}
|
||||
if data := mustReadFile(t, extractedCoverPath); len(data) == 0 {
|
||||
t.Fatal("expected extracted cover data")
|
||||
}
|
||||
cachePath, err := SaveCoverToCacheWithHintAndKey(coverPath, "cover.opus", dir, "key")
|
||||
if err != nil || cachePath == "" {
|
||||
t.Fatalf("SaveCoverToCacheWithHintAndKey = %q/%v", cachePath, err)
|
||||
}
|
||||
cacheDir := filepath.Join(dir, "cache")
|
||||
if path, err := SaveCoverToCache(coverPath, cacheDir); err != nil || !strings.HasSuffix(path, ".png") {
|
||||
t.Fatalf("SaveCoverToCache = %q/%v", path, err)
|
||||
}
|
||||
if path, err := SaveCoverToCacheWithHint(coverPath, "cover.opus", cacheDir); err != nil || path == "" {
|
||||
t.Fatalf("SaveCoverToCacheWithHint = %q/%v", path, err)
|
||||
}
|
||||
hitPath, err := SaveCoverToCache(coverPath, cacheDir)
|
||||
if err != nil || hitPath == "" {
|
||||
t.Fatalf("SaveCoverToCache cache hit = %q/%v", hitPath, err)
|
||||
}
|
||||
if _, err := SaveCoverToCacheWithHintAndKey(filepath.Join(dir, "missing.opus"), "missing.opus", dir, "missing"); err == nil {
|
||||
t.Fatal("expected missing cover cache error")
|
||||
}
|
||||
|
||||
badPath := filepath.Join(dir, "bad.ogg")
|
||||
if err := os.WriteFile(badPath, []byte("bad"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := GetOggQuality(badPath); err == nil {
|
||||
t.Fatal("expected invalid Ogg quality error")
|
||||
}
|
||||
}
|
||||
|
||||
func buildM4ADataPayload(payload []byte) []byte {
|
||||
return append([]byte{0, 0, 0, 1, 0, 0, 0, 0}, payload...)
|
||||
}
|
||||
|
||||
func buildM4ATextTag(atomType, value string) []byte {
|
||||
return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload([]byte(value))))
|
||||
}
|
||||
|
||||
func buildM4AIndexTag(atomType string, number, total int) []byte {
|
||||
payload := []byte{0, 0, 0, byte(number), 0, byte(total), 0, 0}
|
||||
return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload(payload)))
|
||||
}
|
||||
|
||||
func buildM4AFileWithIlst(ilstPayload []byte, withUdta bool) []byte {
|
||||
ilst := buildM4AAtom("ilst", ilstPayload)
|
||||
meta := buildM4AAtom("meta", append([]byte{0, 0, 0, 0}, ilst...))
|
||||
moovPayload := meta
|
||||
if withUdta {
|
||||
moovPayload = buildM4AAtom("udta", meta)
|
||||
}
|
||||
return append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", moovPayload)...)
|
||||
}
|
||||
|
||||
func buildOggPage(headerType byte, granule uint64, packet []byte) []byte {
|
||||
header := make([]byte, 27)
|
||||
copy(header[0:4], "OggS")
|
||||
header[4] = 0
|
||||
header[5] = headerType
|
||||
binary.LittleEndian.PutUint64(header[6:14], granule)
|
||||
header[26] = 1
|
||||
return append(append(header, byte(len(packet))), packet...)
|
||||
}
|
||||
|
||||
func buildTestFLACPictureBlock(image []byte, mime string) []byte {
|
||||
var picture bytes.Buffer
|
||||
binary.Write(&picture, binary.BigEndian, uint32(3))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(len(mime)))
|
||||
picture.WriteString(mime)
|
||||
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(1))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(32))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(0))
|
||||
binary.Write(&picture, binary.BigEndian, uint32(len(image)))
|
||||
picture.Write(image)
|
||||
return picture.Bytes()
|
||||
}
|
||||
@@ -9,14 +9,23 @@ import (
|
||||
// ErrDownloadCancelled is returned when a download is cancelled by the user.
|
||||
var ErrDownloadCancelled = errors.New("download cancelled")
|
||||
|
||||
// ErrExtensionRequestCancelled is returned when a UI-driven extension request
|
||||
// is superseded by a newer home/search request.
|
||||
var ErrExtensionRequestCancelled = errors.New("extension request cancelled")
|
||||
|
||||
type cancelEntry struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
canceled bool
|
||||
refs int
|
||||
}
|
||||
|
||||
var (
|
||||
cancelMu sync.Mutex
|
||||
cancelMap = make(map[string]*cancelEntry)
|
||||
|
||||
extensionRequestCancelMu sync.Mutex
|
||||
extensionRequestCancelMap = make(map[string]*cancelEntry)
|
||||
)
|
||||
|
||||
func initDownloadCancel(itemID string) context.Context {
|
||||
@@ -27,10 +36,25 @@ func initDownloadCancel(itemID string) context.Context {
|
||||
cancelMu.Lock()
|
||||
defer cancelMu.Unlock()
|
||||
|
||||
if entry, ok := cancelMap[itemID]; ok {
|
||||
if entry.ctx == nil {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
entry.ctx = ctx
|
||||
entry.cancel = cancel
|
||||
if entry.canceled && entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
}
|
||||
entry.refs++
|
||||
return entry.ctx
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancelMap[itemID] = &cancelEntry{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
canceled: false,
|
||||
refs: 1,
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -73,6 +97,86 @@ func clearDownloadCancel(itemID string) {
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
delete(cancelMap, itemID)
|
||||
if entry, ok := cancelMap[itemID]; ok {
|
||||
entry.refs--
|
||||
if entry.refs <= 0 {
|
||||
delete(cancelMap, itemID)
|
||||
}
|
||||
}
|
||||
cancelMu.Unlock()
|
||||
}
|
||||
|
||||
func initExtensionRequestCancel(requestID string) context.Context {
|
||||
if requestID == "" {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
defer extensionRequestCancelMu.Unlock()
|
||||
|
||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
||||
if entry.ctx == nil {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
entry.ctx = ctx
|
||||
entry.cancel = cancel
|
||||
if entry.canceled && entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
}
|
||||
entry.refs++
|
||||
return entry.ctx
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
extensionRequestCancelMap[requestID] = &cancelEntry{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
canceled: false,
|
||||
refs: 1,
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func cancelExtensionRequest(requestID string) {
|
||||
if requestID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
||||
entry.canceled = true
|
||||
if entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
} else {
|
||||
extensionRequestCancelMap[requestID] = &cancelEntry{canceled: true}
|
||||
}
|
||||
extensionRequestCancelMu.Unlock()
|
||||
}
|
||||
|
||||
func isExtensionRequestCancelled(requestID string) bool {
|
||||
if requestID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
entry, ok := extensionRequestCancelMap[requestID]
|
||||
canceled := ok && entry.canceled
|
||||
extensionRequestCancelMu.Unlock()
|
||||
return canceled
|
||||
}
|
||||
|
||||
func clearExtensionRequestCancel(requestID string) {
|
||||
if requestID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
extensionRequestCancelMu.Lock()
|
||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
||||
entry.refs--
|
||||
if entry.refs <= 0 {
|
||||
delete(extensionRequestCancelMap, requestID)
|
||||
}
|
||||
}
|
||||
extensionRequestCancelMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ const (
|
||||
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||
|
||||
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
||||
|
||||
var qobuzSizeRegex = regexp.MustCompile(`_\d+\.jpg$`)
|
||||
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize300) {
|
||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||
@@ -40,7 +44,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
maxURL := upgradeToMaxQuality(downloadURL)
|
||||
if maxURL != downloadURL {
|
||||
downloadURL = maxURL
|
||||
// Log already printed by upgradeToMaxQuality for Deezer
|
||||
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
||||
}
|
||||
@@ -86,16 +89,22 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
}
|
||||
|
||||
func upgradeToMaxQuality(coverURL string) string {
|
||||
// Spotify CDN upgrade
|
||||
if strings.Contains(coverURL, spotifySize640) {
|
||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
}
|
||||
|
||||
// Deezer CDN upgrade
|
||||
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||
return upgradeDeezerCover(coverURL)
|
||||
}
|
||||
|
||||
if strings.Contains(coverURL, "resources.tidal.com") {
|
||||
return upgradeTidalCover(coverURL)
|
||||
}
|
||||
|
||||
if strings.Contains(coverURL, "static.qobuz.com") {
|
||||
return upgradeQobuzCover(coverURL)
|
||||
}
|
||||
|
||||
return coverURL
|
||||
}
|
||||
|
||||
@@ -104,7 +113,6 @@ func upgradeDeezerCover(coverURL string) string {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
// Replace any size pattern with 1800x1800
|
||||
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
||||
@@ -112,12 +120,35 @@ func upgradeDeezerCover(coverURL string) string {
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func upgradeTidalCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "resources.tidal.com") {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Tidal: upgraded to origin resolution")
|
||||
}
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func upgradeQobuzCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "static.qobuz.com") {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
upgraded := qobuzSizeRegex.ReplaceAllString(coverURL, "_max.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
||||
}
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||
if imageURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Always upgrade small to medium first
|
||||
result := convertSmallToMedium(imageURL)
|
||||
|
||||
if maxQuality {
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestLoadedExtension(t *testing.T, types ...ExtensionType) *loadedExtension {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.js"), []byte(testExtensionJS), 0600); err != nil {
|
||||
t.Fatalf("write index.js: %v", err)
|
||||
}
|
||||
return &loadedExtension{
|
||||
ID: "coverage-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "coverage-ext",
|
||||
Description: "Coverage extension",
|
||||
Version: "1.0.0",
|
||||
Types: types,
|
||||
Permissions: ExtensionPermissions{File: true, Network: []string{"example.test"}},
|
||||
SearchBehavior: &SearchBehaviorConfig{
|
||||
Enabled: true,
|
||||
Placeholder: "Search coverage",
|
||||
Primary: true,
|
||||
Icon: "search",
|
||||
},
|
||||
URLHandler: &URLHandlerConfig{Enabled: true, Patterns: []string{"https://example.test/"}},
|
||||
TrackMatching: &TrackMatchingConfig{CustomMatching: true},
|
||||
PostProcessing: &PostProcessingConfig{
|
||||
Enabled: true,
|
||||
Hooks: []PostProcessingHook{{ID: "hook", Name: "Hook", DefaultEnabled: true, SupportedFormats: []string{"flac"}}},
|
||||
},
|
||||
},
|
||||
Enabled: true,
|
||||
SourceDir: dir,
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
}
|
||||
|
||||
const testExtensionJS = `
|
||||
function track(id) {
|
||||
return {
|
||||
id: id,
|
||||
name: "Track " + id,
|
||||
artists: "Artist",
|
||||
albumName: "Album",
|
||||
albumArtist: "Album Artist",
|
||||
durationMs: 180000,
|
||||
coverUrl: "https://example.test/cover.jpg",
|
||||
releaseDate: "2026-05-04",
|
||||
trackNumber: 1,
|
||||
totalTracks: 10,
|
||||
discNumber: 1,
|
||||
totalDiscs: 1,
|
||||
isrc: "USRC17607839",
|
||||
itemType: "track",
|
||||
albumType: "album",
|
||||
tidalId: "tidal-1",
|
||||
qobuzId: "qobuz-1",
|
||||
deezerId: "deezer-1",
|
||||
spotifyId: "spotify:track:1",
|
||||
externalLinks: { tidal: "https://tidal.example/1" },
|
||||
label: "Label",
|
||||
copyright: "Copyright",
|
||||
genre: "Pop",
|
||||
composer: "Composer",
|
||||
audioQuality: "FLAC 24-bit",
|
||||
audioModes: "DOLBY_ATMOS"
|
||||
};
|
||||
}
|
||||
|
||||
registerExtension({
|
||||
searchTracks: function(query, limit) {
|
||||
return { tracks: [track("search-1")], total: 1 };
|
||||
},
|
||||
customSearch: function(query, options) {
|
||||
var t = track("custom-1");
|
||||
t.name = "Custom " + query;
|
||||
return [t];
|
||||
},
|
||||
getHomeFeed: function() {
|
||||
return [{ id: "home-1", title: "Home", tracks: [track("home-track")] }];
|
||||
},
|
||||
getBrowseCategories: function() {
|
||||
return [{ id: "cat-1", title: "Category" }];
|
||||
},
|
||||
getTrack: function(id) {
|
||||
return track(id);
|
||||
},
|
||||
getAlbum: function(id) {
|
||||
return {
|
||||
id: id,
|
||||
name: "Album " + id,
|
||||
artists: "Artist",
|
||||
artistId: "artist-1",
|
||||
coverUrl: "https://example.test/album.jpg",
|
||||
releaseDate: "2026-05-04",
|
||||
totalTracks: 1,
|
||||
albumType: "album",
|
||||
tracks: [track("album-track")]
|
||||
};
|
||||
},
|
||||
getPlaylist: function(id) {
|
||||
return {
|
||||
id: id,
|
||||
name: "Playlist " + id,
|
||||
artists: "Owner",
|
||||
coverUrl: "https://example.test/playlist.jpg",
|
||||
totalTracks: 1,
|
||||
tracks: [track("playlist-track")]
|
||||
};
|
||||
},
|
||||
getArtist: function(id) {
|
||||
return {
|
||||
id: id,
|
||||
name: "Artist",
|
||||
imageUrl: "https://example.test/artist.jpg",
|
||||
headerImage: "https://example.test/header.jpg",
|
||||
listeners: 123,
|
||||
albums: [{ id: "album-1", name: "Album", artists: "Artist", totalTracks: 1 }],
|
||||
releases: [{ id: "release-1", name: "Release", artists: "Artist", totalTracks: 1, tracks: [track("release-track")] }],
|
||||
topTracks: [track("top-track")]
|
||||
};
|
||||
},
|
||||
enrichTrack: function(input) {
|
||||
var t = track(input.id || "enriched");
|
||||
t.name = "Enriched";
|
||||
return t;
|
||||
},
|
||||
checkAvailability: function(isrc, name, artist, ids) {
|
||||
return { available: true, reason: "ok", trackId: "download-track", skipFallback: true };
|
||||
},
|
||||
getDownloadUrl: function(id, quality) {
|
||||
return { url: "https://example.test/audio.flac", format: "flac", bitDepth: 24, sampleRate: 96000 };
|
||||
},
|
||||
download: function(id, quality, outputPath, onProgress) {
|
||||
if (onProgress) onProgress(100);
|
||||
return {
|
||||
success: true,
|
||||
filePath: "EXISTS:" + outputPath,
|
||||
alreadyExists: false,
|
||||
bitDepth: 24,
|
||||
sampleRate: 96000,
|
||||
title: "Downloaded",
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
albumArtist: "Album Artist",
|
||||
trackNumber: 1,
|
||||
totalTracks: 10,
|
||||
discNumber: 1,
|
||||
totalDiscs: 1,
|
||||
releaseDate: "2026-05-04",
|
||||
coverUrl: "https://example.test/cover.jpg",
|
||||
isrc: "USRC17607839",
|
||||
genre: "Pop",
|
||||
label: "Label",
|
||||
copyright: "Copyright",
|
||||
composer: "Composer",
|
||||
lyricsLrc: "[00:00.00]Hello",
|
||||
decryptionKey: "001122",
|
||||
decryption: { strategy: "mp4_decryption_key", options: { kid: "1" } }
|
||||
};
|
||||
},
|
||||
fetchLyrics: function(name, artist, album, duration) {
|
||||
return { syncType: "LINE_SYNCED", provider: "coverage-ext", lines: [{ startTimeMs: 0, endTimeMs: 1000, words: "Hello" }] };
|
||||
},
|
||||
handleUrl: function(url) {
|
||||
return { type: "track", name: "Handled", coverUrl: "https://example.test/cover.jpg", track: track("url-track"), tracks: [track("url-track")], album: this.getAlbum("url-album"), artist: this.getArtist("url-artist") };
|
||||
},
|
||||
matchTrack: function(req) {
|
||||
return { matched: true, trackId: "download-track", confidence: 0.95, reason: "exact" };
|
||||
},
|
||||
postProcess: function(path, req) {
|
||||
return { success: true, newFilePath: path, bitDepth: 24, sampleRate: 96000 };
|
||||
},
|
||||
postProcessV2: function(input, metadata, hookId) {
|
||||
return { success: true, newFilePath: input.path || input.uri, newFileUri: input.uri || "", bitDepth: 24, sampleRate: 96000 };
|
||||
}
|
||||
});
|
||||
`
|
||||
|
||||
func mustReadFile(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read file: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func buildID3v23Tag(frames ...[]byte) []byte {
|
||||
body := bytes.Join(frames, nil)
|
||||
header := []byte{'I', 'D', '3', 3, 0, 0, 0, 0, 0, 0}
|
||||
copy(header[6:10], syncsafeBytes(len(body)))
|
||||
return append(header, body...)
|
||||
}
|
||||
|
||||
func id3TextFrame(id, value string) []byte {
|
||||
return id3v23Frame(id, append([]byte{3}, []byte(value)...))
|
||||
}
|
||||
|
||||
func id3CommentFrame(id, value string) []byte {
|
||||
payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...)
|
||||
return id3v23Frame(id, payload)
|
||||
}
|
||||
|
||||
func id3UserTextFrame(id, desc, value string) []byte {
|
||||
payload := append([]byte{3}, []byte(desc)...)
|
||||
payload = append(payload, 0)
|
||||
payload = append(payload, []byte(value)...)
|
||||
return id3v23Frame(id, payload)
|
||||
}
|
||||
|
||||
func id3v23Frame(id string, payload []byte) []byte {
|
||||
frame := make([]byte, 10+len(payload))
|
||||
copy(frame[0:4], id)
|
||||
binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload)))
|
||||
copy(frame[10:], payload)
|
||||
return frame
|
||||
}
|
||||
|
||||
func buildID3v22Tag(frames ...[]byte) []byte {
|
||||
body := bytes.Join(frames, nil)
|
||||
header := []byte{'I', 'D', '3', 2, 0, 0, 0, 0, 0, 0}
|
||||
copy(header[6:10], syncsafeBytes(len(body)))
|
||||
return append(header, body...)
|
||||
}
|
||||
|
||||
func id3v22TextFrame(id, value string) []byte {
|
||||
return id3v22Frame(id, append([]byte{3}, []byte(value)...))
|
||||
}
|
||||
|
||||
func id3v22CommentFrame(id, value string) []byte {
|
||||
payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...)
|
||||
return id3v22Frame(id, payload)
|
||||
}
|
||||
|
||||
func id3v22Frame(id string, payload []byte) []byte {
|
||||
frame := make([]byte, 6+len(payload))
|
||||
copy(frame[0:3], id)
|
||||
size := len(payload)
|
||||
frame[3] = byte(size >> 16)
|
||||
frame[4] = byte(size >> 8)
|
||||
frame[5] = byte(size)
|
||||
copy(frame[6:], payload)
|
||||
return frame
|
||||
}
|
||||
|
||||
func syncsafeBytes(size int) []byte {
|
||||
return []byte{
|
||||
byte((size >> 21) & 0x7f),
|
||||
byte((size >> 14) & 0x7f),
|
||||
byte((size >> 7) & 0x7f),
|
||||
byte(size & 0x7f),
|
||||
}
|
||||
}
|
||||
|
||||
func buildID3v1Tag(title, artist, album, year string, track, genre byte) []byte {
|
||||
tag := make([]byte, 128)
|
||||
copy(tag[0:3], "TAG")
|
||||
copyPadded(tag[3:33], title)
|
||||
copyPadded(tag[33:63], artist)
|
||||
copyPadded(tag[63:93], album)
|
||||
copyPadded(tag[93:97], year)
|
||||
tag[125] = 0
|
||||
tag[126] = track
|
||||
tag[127] = genre
|
||||
return tag
|
||||
}
|
||||
|
||||
func copyPadded(dst []byte, value string) {
|
||||
for i := range dst {
|
||||
dst[i] = ' '
|
||||
}
|
||||
copy(dst, value)
|
||||
}
|
||||
|
||||
func writeExportCueFixture(t *testing.T, dir string) (string, string) {
|
||||
t.Helper()
|
||||
audioPath := filepath.Join(dir, "exports.wav")
|
||||
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
|
||||
t.Fatalf("write export audio: %v", err)
|
||||
}
|
||||
cuePath := filepath.Join(dir, "exports.cue")
|
||||
cue := "PERFORMER \"Artist\"\nTITLE \"Album\"\nFILE \"exports.wav\" WAVE\n TRACK 01 AUDIO\n TITLE \"Song\"\n INDEX 01 00:00:00\n"
|
||||
if err := os.WriteFile(cuePath, []byte(cue), 0600); err != nil {
|
||||
t.Fatalf("write export cue: %v", err)
|
||||
}
|
||||
return cuePath, audioPath
|
||||
}
|
||||
|
||||
func escapeJSONPath(path string) string {
|
||||
data, _ := json.Marshal(path)
|
||||
return strings.Trim(string(data), `"`)
|
||||
}
|
||||
|
||||
func fakeDeezerResponse(path, rawQuery string) string {
|
||||
switch {
|
||||
case path == "/2.0/search/track":
|
||||
if strings.Contains(rawQuery, "MISSING") {
|
||||
return `{"data":[]}`
|
||||
}
|
||||
return `{"data":[` + fakeDeezerTrackJSON(101, true) + `]}`
|
||||
case path == "/2.0/search/artist":
|
||||
return `{"data":[{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123}]}`
|
||||
case path == "/2.0/search/album":
|
||||
return `{"data":[{"id":201,"title":"Album","cover_xl":"album-xl","nb_tracks":2,"release_date":"2026-05-04","record_type":"compile","artist":{"id":301,"name":"Artist"}}]}`
|
||||
case path == "/2.0/search/playlist":
|
||||
return `{"data":[{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"user":{"name":"Owner"}}]}`
|
||||
case path == "/2.0/track/101", path == "/2.0/track/isrc:USRC17607839":
|
||||
return fakeDeezerTrackJSON(101, true)
|
||||
case path == "/2.0/track/102":
|
||||
return fakeDeezerTrackJSON(102, true)
|
||||
case path == "/2.0/track/isrc:MISSING":
|
||||
return `{"id":0}`
|
||||
case path == "/2.0/album/201":
|
||||
return `{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","nb_tracks":2,"record_type":"compile","label":"Label","copyright":"Copyright","genres":{"data":[{"name":"Pop"},{"name":"Dance"}]},"artist":{"id":301,"name":"Album Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}`
|
||||
case path == "/2.0/artist/301":
|
||||
return `{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123,"nb_album":1}`
|
||||
case path == "/2.0/artist/301/albums":
|
||||
return `{"data":[{"id":201,"title":"Album","release_date":"2026-05-04","nb_tracks":0,"cover_xl":"album-xl","record_type":"compile"}]}`
|
||||
case path == "/2.0/artist/301/related":
|
||||
return `{"data":[{"id":302,"name":"Related","picture_xl":"related-xl","nb_fan":10}]}`
|
||||
case path == "/2.0/playlist/401":
|
||||
return `{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"creator":{"name":"Owner"},"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}`
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func fakeDeezerTrackJSON(id int, withISRC bool) string {
|
||||
isrc := ""
|
||||
if withISRC {
|
||||
isrc = `,"isrc":"USRC17607839"`
|
||||
if id == 102 {
|
||||
isrc = `,"isrc":"USRC17607840"`
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(`{"id":%d,"title":"Track %d","duration":180,"track_position":%d,"disk_number":1%s,"link":"https://deezer.test/track/%d","release_date":"2026-05-04","artist":{"id":301,"name":"Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"album":{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","record_type":"album"}}`, id, id, id-100, isrc, id)
|
||||
}
|
||||
|
||||
func createTestExtensionPackage(t *testing.T, path, name, version, js string, extraFiles map[string]string) {
|
||||
t.Helper()
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create extension package: %v", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
zw := zip.NewWriter(out)
|
||||
defer zw.Close()
|
||||
|
||||
manifest := fmt.Sprintf(`{
|
||||
"name": %q,
|
||||
"displayName": %q,
|
||||
"version": %q,
|
||||
"description": "Packaged test extension",
|
||||
"type": ["metadata_provider", "download_provider", "lyrics_provider"],
|
||||
"permissions": {"network": ["example.test"], "storage": true, "file": true},
|
||||
"icon": "icon.png",
|
||||
"settings": [{"key":"quality","type":"string","label":"Quality"}],
|
||||
"qualityOptions": [{"id":"lossless","label":"Lossless","description":"Lossless"}],
|
||||
"searchBehavior": {"enabled": true, "placeholder": "Search", "primary": true},
|
||||
"urlHandler": {"enabled": true, "patterns": ["https://example.test/"]},
|
||||
"trackMatching": {"customMatching": true},
|
||||
"postProcessing": {"enabled": true, "hooks": [{"id":"hook","name":"Hook"}]},
|
||||
"serviceHealth": [{"id":"main","url":"https://example.test/health"}],
|
||||
"capabilities": {"homeFeed": true}
|
||||
}`, name, name, version)
|
||||
|
||||
for fileName, content := range map[string]string{
|
||||
"manifest.json": manifest,
|
||||
"index.js": js,
|
||||
"icon.png": "png",
|
||||
} {
|
||||
writer, err := zw.Create(fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("zip create %s: %v", fileName, err)
|
||||
}
|
||||
if _, err := writer.Write([]byte(content)); err != nil {
|
||||
t.Fatalf("zip write %s: %v", fileName, err)
|
||||
}
|
||||
}
|
||||
for fileName, content := range extraFiles {
|
||||
writer, err := zw.Create(fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("zip create extra %s: %v", fileName, err)
|
||||
}
|
||||
if _, err := writer.Write([]byte(content)); err != nil {
|
||||
t.Fatalf("zip write extra %s: %v", fileName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type CrossExtensionShareResult struct {
|
||||
ExtensionID string `json:"extension_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Found bool `json:"found"`
|
||||
URL string `json:"url,omitempty"`
|
||||
ItemName string `json:"item_name,omitempty"`
|
||||
ItemArtists string `json:"item_artists,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
var crossExtensionShareResultCache = struct {
|
||||
sync.RWMutex
|
||||
entries map[string]string
|
||||
order []string
|
||||
}{
|
||||
entries: make(map[string]string),
|
||||
}
|
||||
|
||||
const crossExtensionShareResultCacheLimit = 128
|
||||
|
||||
func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
Type string `json:"type"`
|
||||
SourceExtensionID string `json:"source_extension_id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.Artists = strings.TrimSpace(req.Artists)
|
||||
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
||||
req.SourceExtensionID = strings.TrimSpace(req.SourceExtensionID)
|
||||
if req.Name == "" {
|
||||
return "[]", nil
|
||||
}
|
||||
if req.Type == "" {
|
||||
req.Type = "album"
|
||||
}
|
||||
|
||||
providers := getExtensionManager().GetMetadataProviders()
|
||||
work := make([]*extensionProviderWrapper, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
if provider == nil || provider.extension == nil {
|
||||
continue
|
||||
}
|
||||
if provider.extension.ID == req.SourceExtensionID {
|
||||
continue
|
||||
}
|
||||
work = append(work, provider)
|
||||
}
|
||||
cacheKey := crossExtensionShareCacheKey(req.Name, req.Artists, req.Type, req.SourceExtensionID, work)
|
||||
if cached := getCrossExtensionShareCache(cacheKey); cached != "" {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
query := req.Name
|
||||
if req.Artists != "" {
|
||||
query += " " + req.Artists
|
||||
}
|
||||
|
||||
results := make([]CrossExtensionShareResult, len(work))
|
||||
var wg sync.WaitGroup
|
||||
for i, provider := range work {
|
||||
wg.Add(1)
|
||||
go func(index int, p *extensionProviderWrapper) {
|
||||
defer wg.Done()
|
||||
results[index] = findCollectionForExtension(
|
||||
p,
|
||||
req.Type,
|
||||
req.Name,
|
||||
req.Artists,
|
||||
query,
|
||||
)
|
||||
}(i, provider)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
data, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
response := string(data)
|
||||
if crossExtensionShareResultsCacheable(results) {
|
||||
setCrossExtensionShareCache(cacheKey, response)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func crossExtensionShareCacheKey(name string, artists string, itemType string, sourceExtensionID string, providers []*extensionProviderWrapper) string {
|
||||
providerKeys := make([]string, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
if provider == nil || provider.extension == nil {
|
||||
continue
|
||||
}
|
||||
ext := provider.extension
|
||||
displayName := ""
|
||||
if ext.Manifest != nil {
|
||||
displayName = ext.Manifest.DisplayName
|
||||
}
|
||||
providerKeys = append(providerKeys, strings.Join([]string{
|
||||
strings.TrimSpace(ext.ID),
|
||||
strings.TrimSpace(displayName),
|
||||
strings.TrimSpace(ext.SourceDir),
|
||||
}, "\x1f"))
|
||||
}
|
||||
sort.Strings(providerKeys)
|
||||
|
||||
return strings.Join([]string{
|
||||
normalizeLooseTitle(itemType),
|
||||
normalizeLooseTitle(name),
|
||||
normalizeLooseArtistName(artists),
|
||||
strings.TrimSpace(sourceExtensionID),
|
||||
strings.Join(providerKeys, "\x1e"),
|
||||
}, "\x1d")
|
||||
}
|
||||
|
||||
func getCrossExtensionShareCache(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
crossExtensionShareResultCache.RLock()
|
||||
defer crossExtensionShareResultCache.RUnlock()
|
||||
return crossExtensionShareResultCache.entries[key]
|
||||
}
|
||||
|
||||
func setCrossExtensionShareCache(key string, value string) {
|
||||
if key == "" || value == "" {
|
||||
return
|
||||
}
|
||||
crossExtensionShareResultCache.Lock()
|
||||
defer crossExtensionShareResultCache.Unlock()
|
||||
|
||||
if _, exists := crossExtensionShareResultCache.entries[key]; !exists {
|
||||
crossExtensionShareResultCache.order = append(crossExtensionShareResultCache.order, key)
|
||||
}
|
||||
crossExtensionShareResultCache.entries[key] = value
|
||||
|
||||
for len(crossExtensionShareResultCache.order) > crossExtensionShareResultCacheLimit {
|
||||
oldest := crossExtensionShareResultCache.order[0]
|
||||
crossExtensionShareResultCache.order = crossExtensionShareResultCache.order[1:]
|
||||
delete(crossExtensionShareResultCache.entries, oldest)
|
||||
}
|
||||
}
|
||||
|
||||
func crossExtensionShareResultsCacheable(results []CrossExtensionShareResult) bool {
|
||||
for _, result := range results {
|
||||
if result.Found {
|
||||
continue
|
||||
}
|
||||
errText := strings.ToLower(strings.TrimSpace(result.Error))
|
||||
if errText == "" ||
|
||||
errText == "no results" ||
|
||||
errText == "unsupported collection type" ||
|
||||
strings.HasSuffix(errText, " not found") ||
|
||||
strings.Contains(errText, "found without shareable link") {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func findCollectionForExtension(
|
||||
provider *extensionProviderWrapper,
|
||||
itemType string,
|
||||
name string,
|
||||
artists string,
|
||||
query string,
|
||||
) CrossExtensionShareResult {
|
||||
result := CrossExtensionShareResult{
|
||||
ExtensionID: provider.extension.ID,
|
||||
}
|
||||
if provider.extension.Manifest != nil {
|
||||
result.DisplayName = provider.extension.Manifest.DisplayName
|
||||
}
|
||||
if result.DisplayName == "" {
|
||||
result.DisplayName = provider.extension.ID
|
||||
}
|
||||
|
||||
searchResult, err := searchCollectionCandidates(provider, itemType, query)
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
return result
|
||||
}
|
||||
if searchResult == nil || len(searchResult.Tracks) == 0 {
|
||||
result.Error = "no results"
|
||||
return result
|
||||
}
|
||||
|
||||
var best *ExtTrackMetadata
|
||||
switch itemType {
|
||||
case "artist":
|
||||
best = bestArtistTrack(searchResult.Tracks, name)
|
||||
case "album":
|
||||
best = bestAlbumTrack(searchResult.Tracks, name, artists)
|
||||
default:
|
||||
result.Error = "unsupported collection type"
|
||||
return result
|
||||
}
|
||||
if best == nil {
|
||||
result.Error = itemType + " not found"
|
||||
return result
|
||||
}
|
||||
|
||||
url := resolveCollectionShareURL(provider.extension, itemType, best)
|
||||
if url == "" {
|
||||
result.Error = itemType + " found without shareable link"
|
||||
return result
|
||||
}
|
||||
|
||||
result.Found = true
|
||||
result.URL = url
|
||||
if itemType == "artist" {
|
||||
result.ItemName = collectionArtistName(*best)
|
||||
} else {
|
||||
result.ItemName = collectionAlbumName(*best)
|
||||
result.ItemArtists = best.Artists
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func searchCollectionCandidates(provider *extensionProviderWrapper, itemType string, query string) (*ExtSearchResult, error) {
|
||||
filter := ""
|
||||
switch itemType {
|
||||
case "album":
|
||||
filter = "albums"
|
||||
case "artist":
|
||||
filter = "artists"
|
||||
}
|
||||
|
||||
if filter != "" {
|
||||
tracks, err := provider.CustomSearch(query, map[string]interface{}{
|
||||
"filter": filter,
|
||||
"limit": 10,
|
||||
})
|
||||
if err == nil && len(tracks) > 0 {
|
||||
return &ExtSearchResult{Tracks: tracks, Total: len(tracks)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return provider.SearchTracks(query, 10)
|
||||
}
|
||||
|
||||
func bestAlbumTrack(tracks []ExtTrackMetadata, albumName string, artists string) *ExtTrackMetadata {
|
||||
targetAlbum := normalizeLooseTitle(albumName)
|
||||
targetArtists := normalizeLooseArtistName(artists)
|
||||
bestScore := 0
|
||||
bestIndex := -1
|
||||
|
||||
for i := range tracks {
|
||||
track := tracks[i]
|
||||
album := normalizeLooseTitle(collectionAlbumName(track))
|
||||
trackArtists := normalizeLooseArtistName(track.Artists + " " + track.AlbumArtist)
|
||||
|
||||
score := 0
|
||||
if isCollectionItemType(track, "album") {
|
||||
score += 25
|
||||
}
|
||||
if album == targetAlbum {
|
||||
score += 100
|
||||
} else if album != "" && targetAlbum != "" && (strings.Contains(album, targetAlbum) || strings.Contains(targetAlbum, album)) {
|
||||
score += 50
|
||||
}
|
||||
if targetArtists != "" && (strings.Contains(trackArtists, targetArtists) || strings.Contains(targetArtists, trackArtists)) {
|
||||
score += 30
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
if bestIndex < 0 || bestScore < 50 {
|
||||
return nil
|
||||
}
|
||||
return &tracks[bestIndex]
|
||||
}
|
||||
|
||||
func bestArtistTrack(tracks []ExtTrackMetadata, artistName string) *ExtTrackMetadata {
|
||||
targetArtist := normalizeLooseArtistName(artistName)
|
||||
bestScore := 0
|
||||
bestIndex := -1
|
||||
|
||||
for i := range tracks {
|
||||
artist := normalizeLooseArtistName(collectionArtistName(tracks[i]))
|
||||
score := 0
|
||||
if isCollectionItemType(tracks[i], "artist") {
|
||||
score += 25
|
||||
}
|
||||
if artist == targetArtist {
|
||||
score += 100
|
||||
} else if artist != "" && targetArtist != "" && (strings.Contains(artist, targetArtist) || strings.Contains(targetArtist, artist)) {
|
||||
score += 60
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
if bestIndex < 0 || bestScore < 60 {
|
||||
return nil
|
||||
}
|
||||
return &tracks[bestIndex]
|
||||
}
|
||||
|
||||
func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *ExtTrackMetadata) string {
|
||||
if track == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if itemType == "album" {
|
||||
if isCollectionItemType(*track, "album") {
|
||||
if url := normalizeShareURL(track.ExternalURL); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
if url := normalizeShareURL(track.AlbumURL); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := templateShareURL(ext, "album", firstNonEmptyString(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" {
|
||||
return url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if isCollectionItemType(*track, "artist") {
|
||||
if url := normalizeShareURL(track.ExternalURL); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
if url := normalizeShareURL(track.ArtistURL); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := templateShareURL(ext, "artist", firstNonEmptyString(track.ArtistID, collectionID(*track, "artist"))); url != "" {
|
||||
return url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func collectionAlbumName(track ExtTrackMetadata) string {
|
||||
if isCollectionItemType(track, "album") {
|
||||
return track.Name
|
||||
}
|
||||
return track.AlbumName
|
||||
}
|
||||
|
||||
func collectionArtistName(track ExtTrackMetadata) string {
|
||||
if isCollectionItemType(track, "artist") {
|
||||
return track.Name
|
||||
}
|
||||
return track.Artists
|
||||
}
|
||||
|
||||
func collectionID(track ExtTrackMetadata, itemType string) string {
|
||||
if isCollectionItemType(track, itemType) {
|
||||
return track.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isCollectionItemType(track ExtTrackMetadata, itemType string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(track.ItemType), itemType)
|
||||
}
|
||||
|
||||
func normalizeShareURL(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
|
||||
return trimmed
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func urlFromExternalLinks(links map[string]string, preferredKey string) string {
|
||||
for key, value := range links {
|
||||
if strings.Contains(strings.ToLower(key), preferredKey) {
|
||||
if url := normalizeShareURL(value); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func templateShareURL(ext *loadedExtension, itemType string, id string) string {
|
||||
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
|
||||
return ""
|
||||
}
|
||||
id = stripProviderPrefix(strings.TrimSpace(id))
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
rawTemplate, ok := templates[itemType].(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
rawTemplate = strings.TrimSpace(rawTemplate)
|
||||
if rawTemplate == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(rawTemplate, "{id}", id)
|
||||
}
|
||||
|
||||
func stripProviderPrefix(id string) string {
|
||||
if index := strings.Index(id, ":"); index > 0 && index < len(id)-1 {
|
||||
return id[index+1:]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
Manifest: &ExtensionManifest{
|
||||
Capabilities: map[string]interface{}{
|
||||
"shareUrlTemplates": map[string]interface{}{
|
||||
"album": "https://music.apple.com/us/album/{id}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "1440783617",
|
||||
Name: "Nevermind",
|
||||
Artists: "Nirvana",
|
||||
ItemType: "album",
|
||||
},
|
||||
}
|
||||
|
||||
best := bestAlbumTrack(tracks, "Nevermind", "Nirvana")
|
||||
if best == nil {
|
||||
t.Fatal("expected album collection item to match")
|
||||
}
|
||||
if url := resolveCollectionShareURL(ext, "album", best); url != "https://music.apple.com/us/album/1440783617" {
|
||||
t.Fatalf("album share URL = %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
Manifest: &ExtensionManifest{
|
||||
Capabilities: map[string]interface{}{
|
||||
"shareUrlTemplates": map[string]interface{}{
|
||||
"artist": "https://music.youtube.com/browse/{id}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "UCrPe3hLA51968GwxHSZ1llw",
|
||||
Name: "Nirvana",
|
||||
ItemType: "artist",
|
||||
},
|
||||
}
|
||||
|
||||
best := bestArtistTrack(tracks, "Nirvana")
|
||||
if best == nil {
|
||||
t.Fatal("expected artist collection item to match")
|
||||
}
|
||||
if url := resolveCollectionShareURL(ext, "artist", best); url != "https://music.youtube.com/browse/UCrPe3hLA51968GwxHSZ1llw" {
|
||||
t.Fatalf("artist share URL = %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossExtensionShareCacheKeyIsProviderOrderStable(t *testing.T) {
|
||||
apple := &extensionProviderWrapper{
|
||||
extension: &loadedExtension{
|
||||
ID: "apple",
|
||||
SourceDir: "/extensions/apple",
|
||||
Manifest: &ExtensionManifest{DisplayName: "Apple Music"},
|
||||
},
|
||||
}
|
||||
qobuz := &extensionProviderWrapper{
|
||||
extension: &loadedExtension{
|
||||
ID: "qobuz",
|
||||
SourceDir: "/extensions/qobuz",
|
||||
Manifest: &ExtensionManifest{DisplayName: "Qobuz"},
|
||||
},
|
||||
}
|
||||
|
||||
first := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{apple, qobuz})
|
||||
second := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{qobuz, apple})
|
||||
if first != second {
|
||||
t.Fatalf("cache key should not depend on provider order:\n%s\n%s", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossExtensionShareCacheableSkipsTransientErrors(t *testing.T) {
|
||||
cacheable := []CrossExtensionShareResult{
|
||||
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
|
||||
{ExtensionID: "qobuz", Error: "album not found"},
|
||||
{ExtensionID: "tidal", Error: "no results"},
|
||||
}
|
||||
if !crossExtensionShareResultsCacheable(cacheable) {
|
||||
t.Fatal("expected found and deterministic not-found results to be cacheable")
|
||||
}
|
||||
|
||||
transient := []CrossExtensionShareResult{
|
||||
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
|
||||
{ExtensionID: "qobuz", Error: "request failed: timeout"},
|
||||
}
|
||||
if crossExtensionShareResultsCacheable(transient) {
|
||||
t.Fatal("expected transient extension errors to skip cache")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCueParserEndToEnd(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
audioPath := filepath.Join(dir, "album.wav")
|
||||
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
|
||||
t.Fatalf("write audio: %v", err)
|
||||
}
|
||||
cuePath := filepath.Join(dir, "album.cue")
|
||||
cue := "\ufeffREM GENRE \"Pop\"\n" +
|
||||
"REM DATE 2026\n" +
|
||||
"REM COMMENT \"comment\"\n" +
|
||||
"REM COMPOSER \"Album Composer\"\n" +
|
||||
"PERFORMER \"Album Artist\"\n" +
|
||||
"TITLE \"Album Title\"\n" +
|
||||
"FILE \"album.wav\" WAVE\n" +
|
||||
" TRACK 01 AUDIO\n" +
|
||||
" TITLE \"First\"\n" +
|
||||
" PERFORMER \"Track Artist\"\n" +
|
||||
" ISRC USRC17607839\n" +
|
||||
" INDEX 01 00:00:00\n" +
|
||||
" TRACK 02 AUDIO\n" +
|
||||
" TITLE \"Second\"\n" +
|
||||
" SONGWRITER \"Track Composer\"\n" +
|
||||
" INDEX 00 03:00:00\n" +
|
||||
" INDEX 01 03:05:00\n"
|
||||
if err := os.WriteFile(cuePath, []byte(cue), 0600); err != nil {
|
||||
t.Fatalf("write cue: %v", err)
|
||||
}
|
||||
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCueFile: %v", err)
|
||||
}
|
||||
if sheet.Performer != "Album Artist" || sheet.Title != "Album Title" || len(sheet.Tracks) != 2 {
|
||||
t.Fatalf("sheet = %#v", sheet)
|
||||
}
|
||||
if got := parseCueTimestamp("01:02:37"); got <= 62 || got >= 63 {
|
||||
t.Fatalf("timestamp = %f", got)
|
||||
}
|
||||
if got := formatCueTimestamp(3723.5); got != "01:02:03.500" {
|
||||
t.Fatalf("format timestamp = %q", got)
|
||||
}
|
||||
if got := unquoteCue(" \"quoted\" "); got != "quoted" {
|
||||
t.Fatalf("unquote = %q", got)
|
||||
}
|
||||
fileName, fileType := parseCueFileLine("unquoted album.flac FLAC")
|
||||
if fileName != "unquoted album.flac" || fileType != "FLAC" {
|
||||
t.Fatalf("file line = %q/%q", fileName, fileType)
|
||||
}
|
||||
|
||||
if resolved := ResolveCueAudioPath(cuePath, "album.flac"); resolved != audioPath {
|
||||
t.Fatalf("resolved = %q want %q", resolved, audioPath)
|
||||
}
|
||||
info, err := BuildCueSplitInfo(cuePath, sheet, "")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildCueSplitInfo: %v", err)
|
||||
}
|
||||
if info.Tracks[0].EndSec != 180 || info.Tracks[1].Composer != "Track Composer" {
|
||||
t.Fatalf("split info = %#v", info.Tracks)
|
||||
}
|
||||
|
||||
jsonText, err := ParseCueFileJSON(cuePath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCueFileJSON: %v", err)
|
||||
}
|
||||
var decoded CueSplitInfo
|
||||
if err := json.Unmarshal([]byte(jsonText), &decoded); err != nil {
|
||||
t.Fatalf("decode cue json: %v", err)
|
||||
}
|
||||
if decoded.AudioPath != audioPath {
|
||||
t.Fatalf("decoded audio path = %q", decoded.AudioPath)
|
||||
}
|
||||
|
||||
results, err := ScanCueFileForLibraryExt(cuePath, "", "virtual/album.cue", 1234, "scan-time")
|
||||
if err != nil {
|
||||
t.Fatalf("ScanCueFileForLibraryExt: %v", err)
|
||||
}
|
||||
if len(results) != 2 || results[0].TrackName != "First" || results[0].Duration != 180 {
|
||||
t.Fatalf("scan results = %#v", results)
|
||||
}
|
||||
if results[0].FilePath != "virtual/album.cue#track01" || results[0].Format != "cue+wav" {
|
||||
t.Fatalf("scan path/format = %q/%q", results[0].FilePath, results[0].Format)
|
||||
}
|
||||
|
||||
if _, err := ParseCueFile(filepath.Join(dir, "missing.cue")); err == nil {
|
||||
t.Fatal("expected missing cue error")
|
||||
}
|
||||
emptyCue := filepath.Join(dir, "empty.cue")
|
||||
if err := os.WriteFile(emptyCue, []byte("TITLE \"No tracks\""), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ParseCueFile(emptyCue); err == nil {
|
||||
t.Fatal("expected no tracks error")
|
||||
}
|
||||
missingDir := t.TempDir()
|
||||
missingCuePath := filepath.Join(missingDir, "missing.cue")
|
||||
if err := os.WriteFile(missingCuePath, []byte(cue), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := BuildCueSplitInfo(missingCuePath, &CueSheet{FileName: "missing.wav"}, ""); err == nil {
|
||||
t.Fatal("expected missing audio error")
|
||||
}
|
||||
if _, err := resolveCueAudioPathForLibrary(cuePath, nil, ""); err == nil {
|
||||
t.Fatal("expected nil sheet error")
|
||||
}
|
||||
if _, err := scanCueSheetForLibrary(cuePath, nil, audioPath, "", 0, "", ""); err == nil {
|
||||
t.Fatal("expected nil scan sheet error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateIndexAndParallelExistence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "song.flac")
|
||||
if err := os.WriteFile(filePath, []byte("audio"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
idx := &ISRCIndex{index: map[string]string{}, outputDir: dir, buildTime: time.Now()}
|
||||
idx.Add("usrc17607839", filePath)
|
||||
if got, ok := idx.lookup("USRC17607839"); !ok || got != filePath {
|
||||
t.Fatalf("lookup = %q/%v", got, ok)
|
||||
}
|
||||
if got, err := idx.Lookup("usrc17607839"); err != nil || got != filePath {
|
||||
t.Fatalf("Lookup = %q/%v", got, err)
|
||||
}
|
||||
idx.remove("usrc17607839")
|
||||
if _, ok := idx.lookup("usrc17607839"); ok {
|
||||
t.Fatal("expected removed ISRC")
|
||||
}
|
||||
|
||||
isrcIndexCacheMu.Lock()
|
||||
isrcIndexCache[dir] = idx
|
||||
isrcIndexCacheMu.Unlock()
|
||||
defer InvalidateISRCCache(dir)
|
||||
|
||||
AddToISRCIndex(dir, "USRC17607839", filePath)
|
||||
if found, err := CheckISRCExists(dir, "USRC17607839"); err != nil || found != filePath {
|
||||
t.Fatalf("CheckISRCExists = %q/%v", found, err)
|
||||
}
|
||||
if !CheckFileExists(filePath) || CheckFileExists(dir) || CheckFileExists(filepath.Join(dir, "missing.flac")) {
|
||||
t.Fatal("unexpected file existence result")
|
||||
}
|
||||
|
||||
tracksJSON := `[{"isrc":"USRC17607839","track_name":"Song","artist_name":"Artist"},{"isrc":"MISSING","track_name":"Other","artist_name":"Artist"}]`
|
||||
resultJSON, err := CheckFilesExistParallel(dir, tracksJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("CheckFilesExistParallel: %v", err)
|
||||
}
|
||||
var results []FileExistenceResult
|
||||
if err := json.Unmarshal([]byte(resultJSON), &results); err != nil {
|
||||
t.Fatalf("decode results: %v", err)
|
||||
}
|
||||
if !results[0].Exists || results[0].FilePath != filePath || results[1].Exists {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
if _, err := CheckFilesExistParallel(dir, `not-json`); err == nil {
|
||||
t.Fatal("expected invalid json error")
|
||||
}
|
||||
if err := PreBuildISRCIndex(""); err == nil {
|
||||
t.Fatal("expected empty dir error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CueSheet struct {
|
||||
Performer string `json:"performer"`
|
||||
Title string `json:"title"`
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"` // WAVE, FLAC, MP3, AIFF, etc.
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
Tracks []CueTrack `json:"tracks"`
|
||||
}
|
||||
|
||||
type CueTrack struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Performer string `json:"performer"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
||||
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
|
||||
}
|
||||
|
||||
type CueSplitInfo struct {
|
||||
CuePath string `json:"cue_path"`
|
||||
AudioPath string `json:"audio_path"`
|
||||
Album string `json:"album"`
|
||||
Artist string `json:"artist"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Tracks []CueSplitTrack `json:"tracks"`
|
||||
}
|
||||
|
||||
type CueSplitTrack struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
StartSec float64 `json:"start_sec"`
|
||||
EndSec float64 `json:"end_sec"` // -1 means until end of file
|
||||
}
|
||||
|
||||
var (
|
||||
reRemCommand = regexp.MustCompile(`^REM\s+(\S+)\s+(.+)$`)
|
||||
reQuoted = regexp.MustCompile(`"([^"]*)"`)
|
||||
)
|
||||
|
||||
func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
f, err := os.Open(cuePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open cue file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sheet := &CueSheet{}
|
||||
var currentTrack *CueTrack
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "\xef\xbb\xbf") {
|
||||
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
||||
line = strings.TrimSpace(line)
|
||||
}
|
||||
|
||||
upper := strings.ToUpper(line)
|
||||
|
||||
if strings.HasPrefix(upper, "REM ") {
|
||||
matches := reRemCommand.FindStringSubmatch(line)
|
||||
if len(matches) == 3 {
|
||||
key := strings.ToUpper(matches[1])
|
||||
value := unquoteCue(matches[2])
|
||||
switch key {
|
||||
case "GENRE":
|
||||
sheet.Genre = value
|
||||
case "DATE":
|
||||
sheet.Date = value
|
||||
case "COMMENT":
|
||||
sheet.Comment = value
|
||||
case "COMPOSER":
|
||||
if currentTrack != nil {
|
||||
currentTrack.Composer = value
|
||||
} else {
|
||||
sheet.Composer = value
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "PERFORMER ") {
|
||||
value := unquoteCue(line[len("PERFORMER "):])
|
||||
if currentTrack != nil {
|
||||
currentTrack.Performer = value
|
||||
} else {
|
||||
sheet.Performer = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "TITLE ") {
|
||||
value := unquoteCue(line[len("TITLE "):])
|
||||
if currentTrack != nil {
|
||||
currentTrack.Title = value
|
||||
} else {
|
||||
sheet.Title = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "FILE ") {
|
||||
rest := line[len("FILE "):]
|
||||
fname, ftype := parseCueFileLine(rest)
|
||||
sheet.FileName = fname
|
||||
sheet.FileType = ftype
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "TRACK ") {
|
||||
if currentTrack != nil {
|
||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||
}
|
||||
|
||||
parts := strings.Fields(line)
|
||||
trackNum := 0
|
||||
if len(parts) >= 2 {
|
||||
trackNum, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
|
||||
currentTrack = &CueTrack{
|
||||
Number: trackNum,
|
||||
PreGap: -1,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "INDEX ") && currentTrack != nil {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 3 {
|
||||
indexNum, _ := strconv.Atoi(parts[1])
|
||||
timeSec := parseCueTimestamp(parts[2])
|
||||
switch indexNum {
|
||||
case 0:
|
||||
currentTrack.PreGap = timeSec
|
||||
case 1:
|
||||
currentTrack.StartTime = timeSec
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "ISRC ") && currentTrack != nil {
|
||||
currentTrack.ISRC = strings.TrimSpace(line[len("ISRC "):])
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "SONGWRITER ") {
|
||||
value := unquoteCue(line[len("SONGWRITER "):])
|
||||
if currentTrack != nil {
|
||||
currentTrack.Composer = value
|
||||
} else {
|
||||
sheet.Composer = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if currentTrack != nil {
|
||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error reading cue file: %w", err)
|
||||
}
|
||||
|
||||
if len(sheet.Tracks) == 0 {
|
||||
return nil, fmt.Errorf("no tracks found in cue file")
|
||||
}
|
||||
|
||||
return sheet, nil
|
||||
}
|
||||
|
||||
func parseCueTimestamp(ts string) float64 {
|
||||
parts := strings.Split(ts, ":")
|
||||
if len(parts) != 3 {
|
||||
return 0
|
||||
}
|
||||
|
||||
minutes, _ := strconv.Atoi(parts[0])
|
||||
seconds, _ := strconv.Atoi(parts[1])
|
||||
frames, _ := strconv.Atoi(parts[2])
|
||||
|
||||
return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0
|
||||
}
|
||||
|
||||
func formatCueTimestamp(seconds float64) string {
|
||||
if seconds < 0 {
|
||||
return "0"
|
||||
}
|
||||
hours := int(seconds) / 3600
|
||||
mins := (int(seconds) % 3600) / 60
|
||||
secs := seconds - float64(hours*3600) - float64(mins*60)
|
||||
return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs)
|
||||
}
|
||||
|
||||
func unquoteCue(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 {
|
||||
return matches[1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseCueFileLine(rest string) (string, string) {
|
||||
rest = strings.TrimSpace(rest)
|
||||
|
||||
var filename, ftype string
|
||||
|
||||
if strings.HasPrefix(rest, "\"") {
|
||||
endQuote := strings.Index(rest[1:], "\"")
|
||||
if endQuote >= 0 {
|
||||
filename = rest[1 : endQuote+1]
|
||||
remaining := strings.TrimSpace(rest[endQuote+2:])
|
||||
ftype = remaining
|
||||
} else {
|
||||
filename = rest
|
||||
}
|
||||
} else {
|
||||
parts := strings.Fields(rest)
|
||||
if len(parts) >= 2 {
|
||||
ftype = parts[len(parts)-1]
|
||||
filename = strings.Join(parts[:len(parts)-1], " ")
|
||||
} else if len(parts) == 1 {
|
||||
filename = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
return filename, strings.TrimSpace(ftype)
|
||||
}
|
||||
|
||||
func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||
cueDir := filepath.Dir(cuePath)
|
||||
|
||||
candidate := filepath.Join(cueDir, cueFileName)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
|
||||
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
||||
commonExts := []string{".flac", ".wav", ".aiff", ".aif", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
||||
for _, ext := range commonExts {
|
||||
candidate = filepath.Join(cueDir, baseName+ext)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext))
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath))
|
||||
for _, ext := range commonExts {
|
||||
candidate = filepath.Join(cueDir, cueBase+ext)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(cueDir)
|
||||
if err == nil {
|
||||
audioExts := map[string]bool{
|
||||
".flac": true, ".wav": true, ".ape": true, ".mp3": true,
|
||||
".ogg": true, ".wv": true, ".m4a": true, ".aiff": true,
|
||||
}
|
||||
var audioFiles []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(entry.Name()))
|
||||
if audioExts[ext] {
|
||||
audioFiles = append(audioFiles, filepath.Join(cueDir, entry.Name()))
|
||||
}
|
||||
}
|
||||
if len(audioFiles) == 1 {
|
||||
return audioFiles[0]
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) {
|
||||
resolveDir := cuePath
|
||||
if audioDir != "" {
|
||||
resolveDir = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||
}
|
||||
audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName)
|
||||
if audioPath == "" {
|
||||
return nil, fmt.Errorf("audio file not found for cue sheet: %s (referenced: %s)", cuePath, sheet.FileName)
|
||||
}
|
||||
|
||||
info := &CueSplitInfo{
|
||||
CuePath: cuePath,
|
||||
AudioPath: audioPath,
|
||||
Album: sheet.Title,
|
||||
Artist: sheet.Performer,
|
||||
Genre: sheet.Genre,
|
||||
Date: sheet.Date,
|
||||
}
|
||||
|
||||
for i, track := range sheet.Tracks {
|
||||
performer := track.Performer
|
||||
if performer == "" {
|
||||
performer = sheet.Performer
|
||||
}
|
||||
|
||||
composer := track.Composer
|
||||
if composer == "" {
|
||||
composer = sheet.Composer
|
||||
}
|
||||
|
||||
endSec := float64(-1)
|
||||
if i+1 < len(sheet.Tracks) {
|
||||
nextTrack := sheet.Tracks[i+1]
|
||||
if nextTrack.PreGap >= 0 {
|
||||
endSec = nextTrack.PreGap
|
||||
} else {
|
||||
endSec = nextTrack.StartTime
|
||||
}
|
||||
}
|
||||
|
||||
info.Tracks = append(info.Tracks, CueSplitTrack{
|
||||
Number: track.Number,
|
||||
Title: track.Title,
|
||||
Artist: performer,
|
||||
ISRC: track.ISRC,
|
||||
Composer: composer,
|
||||
StartSec: track.StartTime,
|
||||
EndSec: endSec,
|
||||
})
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse cue file: %w", err)
|
||||
}
|
||||
|
||||
info, err := BuildCueSplitInfo(cuePath, sheet, audioDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal cue split info: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) {
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, "", scanTime)
|
||||
}
|
||||
|
||||
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
||||
return ScanCueFileForLibraryExtWithCoverCacheKey(
|
||||
cuePath,
|
||||
audioDir,
|
||||
virtualPathPrefix,
|
||||
fileModTime,
|
||||
"",
|
||||
scanTime,
|
||||
)
|
||||
}
|
||||
|
||||
func ScanCueFileForLibraryExtWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
audioPath, err := resolveCueAudioPathForLibrary(cuePath, sheet, audioDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanCueSheetForLibrary(
|
||||
cuePath,
|
||||
sheet,
|
||||
audioPath,
|
||||
virtualPathPrefix,
|
||||
fileModTime,
|
||||
coverCacheKey,
|
||||
scanTime,
|
||||
)
|
||||
}
|
||||
|
||||
func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
|
||||
if sheet == nil {
|
||||
return "", fmt.Errorf("cue sheet is nil for %s", cuePath)
|
||||
}
|
||||
resolveBase := cuePath
|
||||
if audioDir != "" {
|
||||
resolveBase = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||
}
|
||||
audioPath := ResolveCueAudioPath(resolveBase, sheet.FileName)
|
||||
if audioPath == "" {
|
||||
return "", fmt.Errorf("audio file not found for cue: %s (referenced: %s)", cuePath, sheet.FileName)
|
||||
}
|
||||
return audioPath, nil
|
||||
}
|
||||
|
||||
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
|
||||
if sheet == nil {
|
||||
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
|
||||
}
|
||||
|
||||
var bitDepth, sampleRate int
|
||||
var totalDurationSec float64
|
||||
audioExt := strings.ToLower(filepath.Ext(audioPath))
|
||||
switch audioExt {
|
||||
case ".flac":
|
||||
quality, qErr := GetAudioQuality(audioPath)
|
||||
if qErr == nil {
|
||||
bitDepth = quality.BitDepth
|
||||
sampleRate = quality.SampleRate
|
||||
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||
totalDurationSec = float64(quality.TotalSamples) / float64(quality.SampleRate)
|
||||
}
|
||||
}
|
||||
case ".mp3":
|
||||
quality, qErr := GetMP3Quality(audioPath)
|
||||
if qErr == nil {
|
||||
sampleRate = quality.SampleRate
|
||||
totalDurationSec = float64(quality.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
var coverPath string
|
||||
libraryCoverCacheMu.RLock()
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
libraryCoverCacheMu.RUnlock()
|
||||
if coverCacheDir != "" {
|
||||
cp, err := SaveCoverToCacheWithHintAndKey(
|
||||
audioPath,
|
||||
"",
|
||||
coverCacheDir,
|
||||
coverCacheKey,
|
||||
)
|
||||
if err == nil && cp != "" {
|
||||
coverPath = cp
|
||||
}
|
||||
}
|
||||
|
||||
pathBase := cuePath
|
||||
if virtualPathPrefix != "" {
|
||||
pathBase = virtualPathPrefix
|
||||
}
|
||||
|
||||
modTime := fileModTime
|
||||
if modTime <= 0 {
|
||||
if info, err := os.Stat(cuePath); err == nil {
|
||||
modTime = info.ModTime().UnixMilli()
|
||||
}
|
||||
}
|
||||
|
||||
var results []LibraryScanResult
|
||||
for i, track := range sheet.Tracks {
|
||||
performer := track.Performer
|
||||
if performer == "" {
|
||||
performer = sheet.Performer
|
||||
}
|
||||
if performer == "" {
|
||||
performer = "Unknown Artist"
|
||||
}
|
||||
|
||||
title := track.Title
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("Track %02d", track.Number)
|
||||
}
|
||||
|
||||
album := sheet.Title
|
||||
if album == "" {
|
||||
album = "Unknown Album"
|
||||
}
|
||||
|
||||
composer := track.Composer
|
||||
if composer == "" {
|
||||
composer = sheet.Composer
|
||||
}
|
||||
|
||||
var duration int
|
||||
if i+1 < len(sheet.Tracks) {
|
||||
nextStart := sheet.Tracks[i+1].StartTime
|
||||
if sheet.Tracks[i+1].PreGap >= 0 {
|
||||
nextStart = sheet.Tracks[i+1].PreGap
|
||||
}
|
||||
duration = int(nextStart - track.StartTime)
|
||||
} else if totalDurationSec > 0 {
|
||||
duration = int(totalDurationSec - track.StartTime)
|
||||
}
|
||||
|
||||
id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
|
||||
|
||||
virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number)
|
||||
|
||||
result := LibraryScanResult{
|
||||
ID: id,
|
||||
TrackName: title,
|
||||
ArtistName: performer,
|
||||
AlbumName: album,
|
||||
AlbumArtist: sheet.Performer,
|
||||
FilePath: virtualFilePath,
|
||||
CoverPath: coverPath,
|
||||
ScannedAt: scanTime,
|
||||
ISRC: track.ISRC,
|
||||
TrackNumber: track.Number,
|
||||
TotalTracks: len(sheet.Tracks),
|
||||
DiscNumber: 1,
|
||||
TotalDiscs: 1,
|
||||
Duration: duration,
|
||||
ReleaseDate: sheet.Date,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Genre: sheet.Genre,
|
||||
Composer: composer,
|
||||
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
|
||||
}
|
||||
|
||||
result.FileModTime = modTime
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package gobackend
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -196,15 +197,22 @@ type deezerAlbumSimple struct {
|
||||
RecordType string `json:"record_type"`
|
||||
}
|
||||
|
||||
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||
artistName := track.Artist.Name
|
||||
// deezerTrackArtistDisplay returns the display artist string for a track,
|
||||
// preferring the Contributors list (comma-joined) when available, falling
|
||||
// back to the primary Artist.Name.
|
||||
func deezerTrackArtistDisplay(track deezerTrack) string {
|
||||
if len(track.Contributors) > 0 {
|
||||
names := make([]string, len(track.Contributors))
|
||||
for i, a := range track.Contributors {
|
||||
names[i] = a.Name
|
||||
}
|
||||
artistName = strings.Join(names, ", ")
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
return track.Artist.Name
|
||||
}
|
||||
|
||||
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||
artistName := deezerTrackArtistDisplay(track)
|
||||
|
||||
albumImage := track.Album.CoverXL
|
||||
if albumImage == "" {
|
||||
@@ -256,6 +264,7 @@ type deezerAlbumFull struct {
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
RecordType string `json:"record_type"`
|
||||
Label string `json:"label"`
|
||||
Copyright string `json:"copyright"`
|
||||
Genres struct {
|
||||
Data []deezerGenre `json:"data"`
|
||||
} `json:"genres"`
|
||||
@@ -622,6 +631,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
}
|
||||
|
||||
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
||||
totalDiscs := 0
|
||||
for _, track := range allTracks {
|
||||
if track.DiskNumber > totalDiscs {
|
||||
totalDiscs = track.DiskNumber
|
||||
}
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
||||
albumType := album.RecordType
|
||||
@@ -640,7 +655,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
|
||||
tracks = append(tracks, AlbumTrackMetadata{
|
||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||
Artists: track.Artist.Name,
|
||||
Artists: deezerTrackArtistDisplay(track),
|
||||
Name: track.Title,
|
||||
AlbumName: album.Title,
|
||||
AlbumArtist: artistName,
|
||||
@@ -650,6 +665,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
TrackNumber: trackNum,
|
||||
TotalTracks: album.NbTracks,
|
||||
DiscNumber: track.DiskNumber,
|
||||
TotalDiscs: totalDiscs,
|
||||
ExternalURL: track.Link,
|
||||
ISRC: isrc,
|
||||
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||
@@ -740,6 +756,10 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
||||
Artists: artist.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// The Deezer /artist/{id}/albums endpoint does not return nb_tracks.
|
||||
// Fetch track counts in parallel from individual /album/{id} endpoints.
|
||||
c.fetchAlbumTrackCounts(ctx, albums)
|
||||
}
|
||||
|
||||
result := &ArtistResponsePayload{
|
||||
@@ -759,6 +779,62 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fetchAlbumTrackCounts fetches nb_tracks for each album in parallel using
|
||||
// individual /album/{id} calls, since the /artist/{id}/albums endpoint does
|
||||
// not include this field. Albums whose track count is already known (non-zero)
|
||||
// are skipped.
|
||||
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
|
||||
type indexedID struct {
|
||||
idx int
|
||||
albumID string
|
||||
}
|
||||
var toFetch []indexedID
|
||||
for i, a := range albums {
|
||||
if a.TotalTracks == 0 {
|
||||
rawID := strings.TrimPrefix(a.ID, "deezer:")
|
||||
if rawID != "" {
|
||||
toFetch = append(toFetch, indexedID{idx: i, albumID: rawID})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(toFetch) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
const maxParallel = 10
|
||||
sem := make(chan struct{}, maxParallel)
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, item := range toFetch {
|
||||
wg.Add(1)
|
||||
go func(it indexedID) {
|
||||
defer wg.Done()
|
||||
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
albumURL := fmt.Sprintf(deezerAlbumURL, it.albumID)
|
||||
var resp struct {
|
||||
NbTracks int `json:"nb_tracks"`
|
||||
}
|
||||
if err := c.getJSON(ctx, albumURL, &resp); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
albums[it.idx].TotalTracks = resp.NbTracks
|
||||
mu.Unlock()
|
||||
}(item)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (c *DeezerClient) GetRelatedArtists(ctx context.Context, artistID string, limit int) ([]SearchArtistResult, error) {
|
||||
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "deezer:"))
|
||||
if normalizedArtistID == "" {
|
||||
@@ -891,7 +967,7 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
||||
|
||||
tracks = append(tracks, AlbumTrackMetadata{
|
||||
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
|
||||
Artists: track.Artist.Name,
|
||||
Artists: deezerTrackArtistDisplay(track),
|
||||
Name: track.Title,
|
||||
AlbumName: track.Album.Title,
|
||||
AlbumArtist: track.Artist.Name,
|
||||
@@ -1084,8 +1160,9 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
||||
}
|
||||
|
||||
type AlbumExtendedMetadata struct {
|
||||
Genre string
|
||||
Label string
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
}
|
||||
|
||||
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
||||
@@ -1116,8 +1193,9 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
||||
}
|
||||
|
||||
result := &AlbumExtendedMetadata{
|
||||
Genre: strings.Join(genres, ", "),
|
||||
Label: album.Label,
|
||||
Genre: strings.Join(genres, ", "),
|
||||
Label: album.Label,
|
||||
Copyright: album.Copyright,
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
@@ -1129,7 +1207,7 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
|
||||
c.maybeCleanupCachesLocked(now)
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
||||
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s, Copyright: %s\n", result.Genre, result.Label, result.Copyright)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1178,7 +1256,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
||||
|
||||
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
||||
delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
|
||||
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
@@ -1189,17 +1267,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
errStr := err.Error()
|
||||
|
||||
// Check if error is retryable
|
||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "EOF") ||
|
||||
strings.Contains(errStr, "status 5") ||
|
||||
strings.Contains(errStr, "status 429")
|
||||
|
||||
if !isRetryable {
|
||||
if !isDeezerRetryableError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1209,6 +1277,26 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
||||
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
type deezerAPIError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *deezerAPIError) Error() string {
|
||||
return fmt.Sprintf("deezer API returned status %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
func isDeezerRetryableError(err error) bool {
|
||||
if isConnectivityFailure(err) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return true
|
||||
}
|
||||
var apiErr *deezerAPIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
@@ -1229,7 +1317,7 @@ func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst inter
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body))
|
||||
return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||
}
|
||||
|
||||
return json.Unmarshal(body, dst)
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const deezerYoinkifyURL = "https://yoinkify.lol/api/download"
|
||||
|
||||
type YoinkifyRequest struct {
|
||||
URL string `json:"url"`
|
||||
Format string `json:"format"`
|
||||
GenreSource string `json:"genreSource"`
|
||||
}
|
||||
|
||||
type DeezerDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
}
|
||||
|
||||
func resolveSpotifyURLForYoinkify(req DownloadRequest) (string, error) {
|
||||
rawSpotify := strings.TrimSpace(req.SpotifyID)
|
||||
if rawSpotify != "" {
|
||||
if isLikelySpotifyTrackID(rawSpotify) {
|
||||
return fmt.Sprintf("https://open.spotify.com/track/%s", rawSpotify), nil
|
||||
}
|
||||
|
||||
if parsed, err := parseSpotifyURI(rawSpotify); err == nil && parsed.Type == "track" && parsed.ID != "" {
|
||||
return fmt.Sprintf("https://open.spotify.com/track/%s", parsed.ID), nil
|
||||
}
|
||||
}
|
||||
|
||||
deezerID := strings.TrimSpace(req.DeezerID)
|
||||
if deezerID == "" {
|
||||
if prefixed, found := strings.CutPrefix(rawSpotify, "deezer:"); found {
|
||||
deezerID = strings.TrimSpace(prefixed)
|
||||
}
|
||||
}
|
||||
|
||||
if deezerID != "" {
|
||||
songlink := NewSongLinkClient()
|
||||
spotifyID, err := songlink.GetSpotifyIDFromDeezer(deezerID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to map deezer:%s to Spotify ID: %w", deezerID, err)
|
||||
}
|
||||
spotifyID = strings.TrimSpace(spotifyID)
|
||||
if spotifyID == "" {
|
||||
return "", fmt.Errorf("SongLink returned empty Spotify ID for deezer:%s", deezerID)
|
||||
}
|
||||
return fmt.Sprintf("https://open.spotify.com/track/%s", spotifyID), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("missing Spotify track ID for Deezer Yoinkify")
|
||||
}
|
||||
|
||||
func isLikelySpotifyTrackID(value string) bool {
|
||||
if len(value) != 22 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= '0' && r <= '9':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outputFD int, itemID string) error {
|
||||
payload := YoinkifyRequest{
|
||||
URL: spotifyURL,
|
||||
Format: "flac",
|
||||
GenreSource: "spotify",
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode Yoinkify request: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, deezerYoinkifyURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create Yoinkify request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("failed to call Yoinkify: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
contentType := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type")))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
bodyText := strings.TrimSpace(string(bodyBytes))
|
||||
if bodyText != "" {
|
||||
return fmt.Errorf("Yoinkify returned status %d: %s", resp.StatusCode, bodyText)
|
||||
}
|
||||
return fmt.Errorf("Yoinkify returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if strings.Contains(contentType, "application/json") {
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
bodyText := strings.TrimSpace(string(bodyBytes))
|
||||
if bodyText == "" {
|
||||
bodyText = "empty JSON payload"
|
||||
}
|
||||
return fmt.Errorf("Yoinkify returned JSON instead of audio: %s", bodyText)
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush output: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close output: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Downloaded via Yoinkify: %.2f MB\n", float64(written)/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
||||
deezerClient := GetDeezerClient()
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
spotifyURL, err := resolveSpotifyURLForYoinkify(req)
|
||||
if err != nil {
|
||||
return DeezerDownloadResult{}, err
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
|
||||
var outputPath string
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var parallelResult *ParallelDownloadResult
|
||||
parallelDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(parallelDone)
|
||||
coverURL := req.CoverURL
|
||||
embedLyrics := req.EmbedLyrics
|
||||
if !req.EmbedMetadata {
|
||||
coverURL = ""
|
||||
embedLyrics = false
|
||||
}
|
||||
parallelResult = FetchCoverAndLyricsParallel(
|
||||
coverURL,
|
||||
req.EmbedMaxQualityCover,
|
||||
req.SpotifyID,
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
embedLyrics,
|
||||
int64(req.DurationMS),
|
||||
)
|
||||
}()
|
||||
|
||||
if err := deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
return DeezerDownloadResult{}, fmt.Errorf("deezer yoinkify failed: %w", err)
|
||||
}
|
||||
|
||||
<-parallelDone
|
||||
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
}
|
||||
|
||||
if isSafOutput || !req.EmbedMetadata {
|
||||
if !req.EmbedMetadata {
|
||||
GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
|
||||
} else {
|
||||
GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||
}
|
||||
} else {
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Deezer] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isSafOutput {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
}
|
||||
|
||||
bitDepth, sampleRate := 0, 0
|
||||
if quality, qErr := GetAudioQuality(outputPath); qErr == nil {
|
||||
bitDepth = quality.BitDepth
|
||||
sampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
return DeezerDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDeezerClientWithFakeHTTP(t *testing.T) {
|
||||
client := &DeezerClient{
|
||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
|
||||
status := http.StatusOK
|
||||
if body == "" {
|
||||
status = http.StatusNotFound
|
||||
body = `{"error":"missing"}`
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})},
|
||||
searchCache: map[string]*cacheEntry{},
|
||||
albumCache: map[string]*cacheEntry{},
|
||||
artistCache: map[string]*cacheEntry{},
|
||||
isrcCache: map[string]string{},
|
||||
cacheCleanupInterval: time.Millisecond,
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
search, err := client.SearchAll(ctx, "artist song", 2, 2, "")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchAll: %v", err)
|
||||
}
|
||||
if len(search.Tracks) != 1 || len(search.Artists) != 1 || len(search.Albums) != 1 || len(search.Playlists) != 1 {
|
||||
t.Fatalf("search = %#v", search)
|
||||
}
|
||||
cached, err := client.SearchAll(ctx, "artist song", 2, 2, "")
|
||||
if err != nil || cached != search {
|
||||
t.Fatalf("cached SearchAll = %#v/%v", cached, err)
|
||||
}
|
||||
if filtered, err := client.SearchAll(ctx, "artist song", 1, 1, "track"); err != nil || len(filtered.Tracks) != 1 || len(filtered.Artists) != 0 {
|
||||
t.Fatalf("filtered search = %#v/%v", filtered, err)
|
||||
}
|
||||
|
||||
track, err := client.GetTrack(ctx, "101")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTrack: %v", err)
|
||||
}
|
||||
if track.Track.SpotifyID != "deezer:101" || track.Track.Artists != "Contributor A, Contributor B" {
|
||||
t.Fatalf("track = %#v", track)
|
||||
}
|
||||
|
||||
album, err := client.GetAlbum(ctx, "201")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlbum: %v", err)
|
||||
}
|
||||
if album.AlbumInfo.Name != "Album" || len(album.TrackList) != 2 || album.TrackList[1].ISRC == "" {
|
||||
t.Fatalf("album = %#v", album)
|
||||
}
|
||||
if cachedAlbum, err := client.GetAlbum(ctx, "201"); err != nil || cachedAlbum != album {
|
||||
t.Fatalf("cached album = %#v/%v", cachedAlbum, err)
|
||||
}
|
||||
|
||||
artist, err := client.GetArtist(ctx, "301")
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtist: %v", err)
|
||||
}
|
||||
if artist.ArtistInfo.Name != "Artist" || len(artist.Albums) != 1 || artist.Albums[0].TotalTracks == 0 {
|
||||
t.Fatalf("artist = %#v", artist)
|
||||
}
|
||||
if cachedArtist, err := client.GetArtist(ctx, "301"); err != nil || cachedArtist != artist {
|
||||
t.Fatalf("cached artist = %#v/%v", cachedArtist, err)
|
||||
}
|
||||
|
||||
related, err := client.GetRelatedArtists(ctx, "deezer:301", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRelatedArtists: %v", err)
|
||||
}
|
||||
if len(related) != 1 || related[0].ID != "deezer:302" {
|
||||
t.Fatalf("related = %#v", related)
|
||||
}
|
||||
if _, err := client.GetRelatedArtists(ctx, "", 0); err == nil {
|
||||
t.Fatal("expected invalid related artist ID")
|
||||
}
|
||||
|
||||
playlist, err := client.GetPlaylist(ctx, "401")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPlaylist: %v", err)
|
||||
}
|
||||
if playlist.PlaylistInfo.Tracks.Total != 2 || len(playlist.TrackList) != 2 {
|
||||
t.Fatalf("playlist = %#v", playlist)
|
||||
}
|
||||
|
||||
byISRC, err := client.SearchByISRC(ctx, "USRC17607839")
|
||||
if err != nil {
|
||||
t.Fatalf("SearchByISRC: %v", err)
|
||||
}
|
||||
if byISRC.SpotifyID != "deezer:101" {
|
||||
t.Fatalf("by ISRC = %#v", byISRC)
|
||||
}
|
||||
if _, err := client.SearchByISRC(ctx, "MISSING"); err == nil {
|
||||
t.Fatal("expected missing ISRC error")
|
||||
}
|
||||
|
||||
isrc, err := client.GetTrackISRC(ctx, "102")
|
||||
if err != nil || isrc != "USRC17607840" {
|
||||
t.Fatalf("GetTrackISRC = %q/%v", isrc, err)
|
||||
}
|
||||
albumID, err := client.GetTrackAlbumID(ctx, "101")
|
||||
if err != nil || albumID != "201" {
|
||||
t.Fatalf("GetTrackAlbumID = %q/%v", albumID, err)
|
||||
}
|
||||
extended, err := client.GetAlbumExtendedMetadata(ctx, "201")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlbumExtendedMetadata: %v", err)
|
||||
}
|
||||
if extended.Genre != "Pop, Dance" || extended.Label != "Label" {
|
||||
t.Fatalf("extended = %#v", extended)
|
||||
}
|
||||
if byTrack, err := client.GetExtendedMetadataByTrackID(ctx, "101"); err != nil || byTrack.Label != "Label" {
|
||||
t.Fatalf("metadata by track = %#v/%v", byTrack, err)
|
||||
}
|
||||
if byISRCMeta, err := client.GetExtendedMetadataByISRC(ctx, "USRC17607839"); err != nil || byISRCMeta.Label != "Label" {
|
||||
t.Fatalf("metadata by isrc = %#v/%v", byISRCMeta, err)
|
||||
}
|
||||
if _, err := client.GetExtendedMetadataByISRC(ctx, ""); err == nil {
|
||||
t.Fatal("expected empty ISRC metadata error")
|
||||
}
|
||||
|
||||
if typ, id, err := parseDeezerURL("https://www.deezer.com/us/track/101"); err != nil || typ != "track" || id != "101" {
|
||||
t.Fatalf("parseDeezerURL = %q/%q/%v", typ, id, err)
|
||||
}
|
||||
if _, _, err := parseDeezerURL("https://example.com/track/101"); err == nil {
|
||||
t.Fatal("expected non-Deezer URL error")
|
||||
}
|
||||
|
||||
client.cacheMu.Lock()
|
||||
client.searchCache["expired"] = &cacheEntry{expiresAt: time.Now().Add(-time.Hour)}
|
||||
client.searchCache["keep1"] = &cacheEntry{expiresAt: time.Now().Add(time.Hour)}
|
||||
client.searchCache["keep2"] = &cacheEntry{expiresAt: time.Now().Add(2 * time.Hour)}
|
||||
client.pruneExpiredCacheEntriesLocked(client.searchCache, time.Now())
|
||||
client.trimCacheEntriesLocked(client.searchCache, 1)
|
||||
client.isrcCache["1"] = "A"
|
||||
client.isrcCache["2"] = "B"
|
||||
client.trimStringCacheEntriesLocked(client.isrcCache, 1)
|
||||
client.cacheMu.Unlock()
|
||||
}
|
||||
@@ -25,7 +25,6 @@ var (
|
||||
)
|
||||
|
||||
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
// Fast path: check cache first
|
||||
isrcIndexCacheMu.RLock()
|
||||
idx, exists := isrcIndexCache[outputDir]
|
||||
isrcIndexCacheMu.RUnlock()
|
||||
@@ -34,14 +33,11 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
return idx
|
||||
}
|
||||
|
||||
// Slow path: need to build index
|
||||
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
|
||||
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
||||
mu := buildLock.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// Double-check cache after acquiring lock (another goroutine may have built it)
|
||||
isrcIndexCacheMu.RLock()
|
||||
idx, exists = isrcIndexCache[outputDir]
|
||||
isrcIndexCacheMu.RUnlock()
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) {
|
||||
result := buildDeezerExtendedMetadataResult(nil)
|
||||
|
||||
if result["genre"] != "" {
|
||||
t.Fatalf("expected empty genre, got %q", result["genre"])
|
||||
}
|
||||
if result["label"] != "" {
|
||||
t.Fatalf("expected empty label, got %q", result["label"])
|
||||
}
|
||||
if result["copyright"] != "" {
|
||||
t.Fatalf("expected empty copyright, got %q", result["copyright"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) {
|
||||
result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{
|
||||
Genre: "Rock",
|
||||
Label: "EMI",
|
||||
Copyright: "(C) Queen",
|
||||
})
|
||||
|
||||
if result["genre"] != "Rock" {
|
||||
t.Fatalf("unexpected genre: %q", result["genre"])
|
||||
}
|
||||
if result["label"] != "EMI" {
|
||||
t.Fatalf("unexpected label: %q", result["label"])
|
||||
}
|
||||
if result["copyright"] != "(C) Queen" {
|
||||
t.Fatalf("unexpected copyright: %q", result["copyright"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) {
|
||||
result := buildDeezerISRCSearchResult(&TrackMetadata{
|
||||
SpotifyID: "deezer:3135556",
|
||||
Name: "Love Of My Life",
|
||||
Artists: "Queen",
|
||||
AlbumName: "A Night at the Opera",
|
||||
ISRC: "GBUM71029604",
|
||||
ReleaseDate: "1975-11-21",
|
||||
})
|
||||
|
||||
if result["spotify_id"] != "deezer:3135556" {
|
||||
t.Fatalf("unexpected spotify_id: %v", result["spotify_id"])
|
||||
}
|
||||
if result["id"] != "3135556" {
|
||||
t.Fatalf("unexpected id: %v", result["id"])
|
||||
}
|
||||
if result["track_id"] != "3135556" {
|
||||
t.Fatalf("unexpected track_id: %v", result["track_id"])
|
||||
}
|
||||
if result["success"] != true {
|
||||
t.Fatalf("expected success=true, got %v", result["success"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtensionPackageExportWrappers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
extensionsDir := filepath.Join(dir, "extensions")
|
||||
dataDir := filepath.Join(dir, "data")
|
||||
if err := InitExtensionSystem(extensionsDir, dataDir); err != nil {
|
||||
t.Fatalf("InitExtensionSystem: %v", err)
|
||||
}
|
||||
CleanupExtensions()
|
||||
defer CleanupExtensions()
|
||||
|
||||
js := `
|
||||
registerExtension({
|
||||
initialize: function(settings) { this.settings = settings || {}; },
|
||||
cleanup: function() {},
|
||||
doAction: function() { return { message: "wrapped", setting_updates: { quality: "lossless" } }; },
|
||||
searchTracks: function() { return { tracks: [], total: 0 }; },
|
||||
fetchLyrics: function() { return { syncType: "UNSYNCED", lines: [{ words: "hello" }] }; },
|
||||
getDownloadUrl: function() { return { url: "https://example.test/a.flac" }; }
|
||||
});
|
||||
`
|
||||
pkgV1 := filepath.Join(dir, "wrapper-ext-v1.spotiflac-ext")
|
||||
pkgV2 := filepath.Join(dir, "wrapper-ext-v2.spotiflac-ext")
|
||||
createTestExtensionPackage(t, pkgV1, "wrapper-ext", "1.0.0", js, nil)
|
||||
createTestExtensionPackage(t, pkgV2, "wrapper-ext", "1.1.0", js, nil)
|
||||
|
||||
loadedJSON, err := LoadExtensionFromPath(pkgV1)
|
||||
if err != nil || !strings.Contains(loadedJSON, "wrapper-ext") {
|
||||
t.Fatalf("LoadExtensionFromPath = %q/%v", loadedJSON, err)
|
||||
}
|
||||
if installedJSON, err := GetInstalledExtensions(); err != nil || !strings.Contains(installedJSON, "wrapper-ext") {
|
||||
t.Fatalf("GetInstalledExtensions = %q/%v", installedJSON, err)
|
||||
}
|
||||
if err := SetExtensionEnabledByID("wrapper-ext", true); err != nil {
|
||||
t.Fatalf("SetExtensionEnabledByID true: %v", err)
|
||||
}
|
||||
if actionJSON, err := InvokeExtensionActionJSON("wrapper-ext", "doAction"); err != nil || !strings.Contains(actionJSON, "wrapped") {
|
||||
t.Fatalf("InvokeExtensionActionJSON = %q/%v", actionJSON, err)
|
||||
}
|
||||
if upgradeJSON, err := CheckExtensionUpgradeFromPath(pkgV2); err != nil || !strings.Contains(upgradeJSON, `"can_upgrade":true`) {
|
||||
t.Fatalf("CheckExtensionUpgradeFromPath = %q/%v", upgradeJSON, err)
|
||||
}
|
||||
if upgradedJSON, err := UpgradeExtensionFromPath(pkgV2); err != nil || !strings.Contains(upgradedJSON, "1.1.0") {
|
||||
t.Fatalf("UpgradeExtensionFromPath = %q/%v", upgradedJSON, err)
|
||||
}
|
||||
if err := SetExtensionEnabledByID("wrapper-ext", false); err != nil {
|
||||
t.Fatalf("SetExtensionEnabledByID false: %v", err)
|
||||
}
|
||||
if err := UnloadExtensionByID("wrapper-ext"); err != nil {
|
||||
t.Fatalf("UnloadExtensionByID: %v", err)
|
||||
}
|
||||
|
||||
dirExt := filepath.Join(extensionsDir, "wrapper-dir-ext")
|
||||
if err := createDirectoryExtension(dirExt, "wrapper-dir-ext", "1.0.0"); err != nil {
|
||||
t.Fatalf("create directory extension: %v", err)
|
||||
}
|
||||
if loadedDirJSON, err := LoadExtensionsFromDir(extensionsDir); err != nil || !strings.Contains(loadedDirJSON, "wrapper-dir-ext") {
|
||||
t.Fatalf("LoadExtensionsFromDir = %q/%v", loadedDirJSON, err)
|
||||
}
|
||||
if err := RemoveExtensionByID("wrapper-dir-ext"); err != nil {
|
||||
t.Fatalf("RemoveExtensionByID: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createDirectoryExtension(dir, name, version string) error {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
manifest := fmt.Sprintf(`{"name":%q,"displayName":%q,"version":%q,"description":"Directory wrapper extension","type":["metadata_provider"],"permissions":{}}`, name, name, version)
|
||||
if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, "index.js"), []byte(`registerExtension({searchTracks:function(){return {tracks:[], total:0};}});`), 0600)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLyricsExportWrappersWithoutNetwork(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
audioPath := filepath.Join(dir, "sidecar.mp3")
|
||||
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "sidecar.lrc"), []byte("[00:00.00]Sidecar lyric"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if jsonText, err := FetchLyrics("spotify-1", "Song Instrumental", "Artist", 180000); err != nil || !strings.Contains(jsonText, `"instrumental":true`) {
|
||||
t.Fatalf("FetchLyrics instrumental = %q/%v", jsonText, err)
|
||||
}
|
||||
if lrc, err := GetLyricsLRC("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || lrc != "[instrumental:true]" {
|
||||
t.Fatalf("GetLyricsLRC instrumental = %q/%v", lrc, err)
|
||||
}
|
||||
if jsonText, err := GetLyricsLRCWithSource("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || !strings.Contains(jsonText, `"instrumental":true`) {
|
||||
t.Fatalf("GetLyricsLRCWithSource instrumental = %q/%v", jsonText, err)
|
||||
}
|
||||
if lrc, err := GetLyricsLRC("", "", "", audioPath, 0); err != nil || !strings.Contains(lrc, "Sidecar lyric") {
|
||||
t.Fatalf("GetLyricsLRC sidecar = %q/%v", lrc, err)
|
||||
}
|
||||
if jsonText, err := GetLyricsLRCWithSource("", "", "", audioPath, 0); err != nil || !strings.Contains(jsonText, "Sidecar lyric") {
|
||||
t.Fatalf("GetLyricsLRCWithSource sidecar = %q/%v", jsonText, err)
|
||||
}
|
||||
|
||||
outPath := filepath.Join(dir, "lyrics.lrc")
|
||||
if err := FetchAndSaveLyrics("Song", "Artist", "", 0, outPath, audioPath); err != nil {
|
||||
t.Fatalf("FetchAndSaveLyrics sidecar: %v", err)
|
||||
}
|
||||
if data := string(mustReadFile(t, outPath)); !strings.Contains(data, "Sidecar lyric") {
|
||||
t.Fatalf("saved lyrics = %q", data)
|
||||
}
|
||||
if response, err := EmbedLyricsToFile(filepath.Join(dir, "not-flac.mp3"), "lyrics"); err != nil || !strings.Contains(response, `"success":false`) {
|
||||
t.Fatalf("EmbedLyricsToFile error = %q/%v", response, err)
|
||||
}
|
||||
if response, err := RewriteSplitArtistTagsExport(filepath.Join(dir, "not-flac.mp3"), "A;B", "A"); err != nil || !strings.Contains(response, `"success":false`) {
|
||||
t.Fatalf("RewriteSplitArtistTagsExport error = %q/%v", response, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
|
||||
origClient := globalSongLinkClient
|
||||
origRetryConfig := songLinkRetryConfig
|
||||
origSearchByISRC := songLinkSearchByISRC
|
||||
origCheckFromDeezer := songLinkCheckAvailabilityFromDeezer
|
||||
defer func() {
|
||||
globalSongLinkClient = origClient
|
||||
songLinkRetryConfig = origRetryConfig
|
||||
songLinkSearchByISRC = origSearchByISRC
|
||||
songLinkCheckAvailabilityFromDeezer = origCheckFromDeezer
|
||||
SetSongLinkNetworkOptions(false, false)
|
||||
}()
|
||||
songLinkRetryConfig = func() RetryConfig {
|
||||
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
|
||||
}
|
||||
globalSongLinkClient = &SongLinkClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
var body string
|
||||
if req.URL.Host == "api.zarz.moe" {
|
||||
body = `{"success":true,"songUrls":{"Spotify":"https://open.spotify.com/track/spotify-1","Deezer":"https://www.deezer.com/track/101","Tidal":"https://listen.tidal.com/track/202","YouTube":"https://youtu.be/yt1","AmazonMusic":"https://music.amazon.com/tracks/amz1","Qobuz":"https://open.qobuz.com/track/303"}}`
|
||||
} else if req.URL.Host == "api.song.link" {
|
||||
body = `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/spotify-1"},"deezer":{"url":"https://www.deezer.com/track/101"},"tidal":{"url":"https://listen.tidal.com/track/202"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=ytm1"},"amazonMusic":{"url":"https://music.amazon.com/tracks/amz1"},"qobuz":{"url":"https://open.qobuz.com/track/303"}}}`
|
||||
} else {
|
||||
t.Fatalf("unexpected SongLink request: %s", req.URL.String())
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
||||
})}}
|
||||
songLinkClientOnce.Do(func() {})
|
||||
|
||||
SetSongLinkNetworkOptions(true, true)
|
||||
if availabilityJSON, err := CheckAvailability("spotify-1", ""); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
|
||||
t.Fatalf("CheckAvailability = %q/%v", availabilityJSON, err)
|
||||
}
|
||||
if availabilityJSON, err := CheckAvailabilityFromDeezerID("101"); err != nil || !strings.Contains(availabilityJSON, `"spotify_id":"spotify-1"`) {
|
||||
t.Fatalf("CheckAvailabilityFromDeezerID = %q/%v", availabilityJSON, err)
|
||||
}
|
||||
if availabilityJSON, err := CheckAvailabilityByPlatformID("deezer", "song", "101"); err != nil || !strings.Contains(availabilityJSON, `"tidal_url"`) {
|
||||
t.Fatalf("CheckAvailabilityByPlatformID = %q/%v", availabilityJSON, err)
|
||||
}
|
||||
if spotifyID, err := GetSpotifyIDFromDeezerTrack("101"); err != nil || spotifyID != "spotify-1" {
|
||||
t.Fatalf("GetSpotifyIDFromDeezerTrack = %q/%v", spotifyID, err)
|
||||
}
|
||||
if tidalURL, err := GetTidalURLFromDeezerTrack("101"); err != nil || !strings.Contains(tidalURL, "tidal") {
|
||||
t.Fatalf("GetTidalURLFromDeezerTrack = %q/%v", tidalURL, err)
|
||||
}
|
||||
if urls, err := NewSongLinkClient().GetStreamingURLs("spotify-1"); err != nil || urls["tidal"] == "" || urls["amazon"] == "" {
|
||||
t.Fatalf("GetStreamingURLs = %#v/%v", urls, err)
|
||||
}
|
||||
if youtubeURL, err := NewSongLinkClient().GetYouTubeURLFromSpotify("spotify-1"); err != nil || !strings.Contains(youtubeURL, "youtu") {
|
||||
t.Fatalf("GetYouTubeURLFromSpotify = %q/%v", youtubeURL, err)
|
||||
}
|
||||
if amazonURL, err := NewSongLinkClient().GetAmazonURLFromDeezer("101"); err != nil || !strings.Contains(amazonURL, "amazon") {
|
||||
t.Fatalf("GetAmazonURLFromDeezer = %q/%v", amazonURL, err)
|
||||
}
|
||||
if youtubeURL, err := NewSongLinkClient().GetYouTubeURLFromDeezer("101"); err != nil || !strings.Contains(youtubeURL, "youtube") {
|
||||
t.Fatalf("GetYouTubeURLFromDeezer = %q/%v", youtubeURL, err)
|
||||
}
|
||||
if deezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify("spotify-1"); err != nil || deezerID != "101" {
|
||||
t.Fatalf("GetDeezerIDFromSpotify = %q/%v", deezerID, err)
|
||||
}
|
||||
if album, err := NewSongLinkClient().CheckAlbumAvailability("album-1"); err != nil || !album.Deezer || album.DeezerID == "" {
|
||||
t.Fatalf("CheckAlbumAvailability = %#v/%v", album, err)
|
||||
}
|
||||
if albumID, err := NewSongLinkClient().GetDeezerAlbumIDFromSpotify("album-1"); err != nil || albumID == "" {
|
||||
t.Fatalf("GetDeezerAlbumIDFromSpotify = %q/%v", albumID, err)
|
||||
}
|
||||
if availability, err := NewSongLinkClient().CheckAvailabilityFromURL("https://www.deezer.com/track/101"); err != nil || !availability.Deezer {
|
||||
t.Fatalf("CheckAvailabilityFromURL = %#v/%v", availability, err)
|
||||
}
|
||||
|
||||
songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
||||
return &TrackMetadata{SpotifyID: "deezer:101", ExternalURL: "https://www.deezer.com/track/101"}, nil
|
||||
}
|
||||
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
||||
return &TrackAvailability{SpotifyID: "spotify-1", Deezer: true, DeezerID: deezerTrackID}, nil
|
||||
}
|
||||
if availabilityJSON, err := CheckAvailability("", "USRC17607839"); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
|
||||
t.Fatalf("CheckAvailability by ISRC = %q/%v", availabilityJSON, err)
|
||||
}
|
||||
if songLinkExtractDeezerTrackID(nil) != "" || songLinkExtractDeezerTrackID(&TrackMetadata{ExternalURL: "https://www.deezer.com/track/202"}) != "202" {
|
||||
t.Fatal("songLinkExtractDeezerTrackID mismatch")
|
||||
}
|
||||
|
||||
deezerClient = &DeezerClient{
|
||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
|
||||
if body == "" {
|
||||
body = `{"error":"missing"}`
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
||||
})},
|
||||
searchCache: map[string]*cacheEntry{},
|
||||
albumCache: map[string]*cacheEntry{},
|
||||
artistCache: map[string]*cacheEntry{},
|
||||
isrcCache: map[string]string{},
|
||||
cacheCleanupInterval: time.Hour,
|
||||
}
|
||||
deezerClientOnce.Do(func() {})
|
||||
if jsonText, err := ConvertSpotifyToDeezer("track", "spotify-1"); err != nil || !strings.Contains(jsonText, `"spotify_id":"deezer:101"`) {
|
||||
t.Fatalf("ConvertSpotifyToDeezer track = %q/%v", jsonText, err)
|
||||
}
|
||||
if jsonText, err := ConvertSpotifyToDeezer("album", "album-1"); err != nil || jsonText == "" {
|
||||
t.Fatalf("ConvertSpotifyToDeezer album = %q/%v", jsonText, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
|
||||
got := classifyDownloadErrorType("All providers failed. Last error: HTTP status 429: too many requests")
|
||||
if got != "rate_limit" {
|
||||
t.Fatalf("expected rate_limit, got %q", got)
|
||||
}
|
||||
|
||||
responseJSON, err := errorResponse("All services failed. Last error: rate limit exceeded")
|
||||
if err != nil {
|
||||
t.Fatalf("errorResponse returned error: %v", err)
|
||||
}
|
||||
|
||||
var response DownloadResponse
|
||||
if err := json.Unmarshal([]byte(responseJSON), &response); err != nil {
|
||||
t.Fatalf("invalid response JSON: %v", err)
|
||||
}
|
||||
if response.ErrorType != "rate_limit" {
|
||||
t.Fatalf("expected rate_limit response, got %q", response.ErrorType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
|
||||
cases := []string{
|
||||
"HTTP 401 for /tickets",
|
||||
"HTTP status 428: precondition required",
|
||||
"Verification required",
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := classifyDownloadErrorType(tc); got != "verification_required" {
|
||||
t.Fatalf("classifyDownloadErrorType(%q) = %q, want verification_required", tc, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProviderMetadataPrefersEnabledDeezerExtension(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
|
||||
t.Fatalf("InitExtensionSystem: %v", err)
|
||||
}
|
||||
CleanupExtensions()
|
||||
defer CleanupExtensions()
|
||||
|
||||
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
|
||||
ext.ID = "deezer"
|
||||
ext.Manifest.Name = "deezer"
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
manager.extensions = map[string]*loadedExtension{ext.ID: ext}
|
||||
manager.mu.Unlock()
|
||||
|
||||
jsonText, err := GetProviderMetadataJSON("deezer", "album", "201")
|
||||
if err != nil {
|
||||
t.Fatalf("GetProviderMetadataJSON deezer album: %v", err)
|
||||
}
|
||||
if !strings.Contains(jsonText, "album-track") {
|
||||
t.Fatalf("expected enabled deezer extension metadata, got %s", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataDir := filepath.Join(dir, "data")
|
||||
extensionsDir := filepath.Join(dir, "extensions")
|
||||
if err := InitExtensionSystem(extensionsDir, dataDir); err != nil {
|
||||
t.Fatalf("InitExtensionSystem: %v", err)
|
||||
}
|
||||
|
||||
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider, ExtensionTypeDownloadProvider, ExtensionTypeLyricsProvider)
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
if manager.extensions == nil {
|
||||
manager.extensions = map[string]*loadedExtension{}
|
||||
}
|
||||
manager.extensions[ext.ID] = ext
|
||||
manager.mu.Unlock()
|
||||
defer func() {
|
||||
manager.mu.Lock()
|
||||
delete(manager.extensions, ext.ID)
|
||||
manager.mu.Unlock()
|
||||
}()
|
||||
|
||||
if response, err := DownloadTrack(`{}`); err != nil || !strings.Contains(response, "retired") {
|
||||
t.Fatalf("DownloadTrack = %q/%v", response, err)
|
||||
}
|
||||
if response, err := DownloadByStrategy(`not-json`); err != nil || !strings.Contains(response, "Invalid request") {
|
||||
t.Fatalf("DownloadByStrategy invalid = %q/%v", response, err)
|
||||
}
|
||||
if response, err := DownloadByStrategy(`{"use_extensions":false}`); err != nil || !strings.Contains(response, "disabled") {
|
||||
t.Fatalf("DownloadByStrategy disabled = %q/%v", response, err)
|
||||
}
|
||||
if response, err := DownloadWithFallback(`{}`); err != nil || !strings.Contains(response, "retired") {
|
||||
t.Fatalf("DownloadWithFallback = %q/%v", response, err)
|
||||
}
|
||||
|
||||
InitItemProgress("item-1")
|
||||
FinishItemProgress("item-1")
|
||||
ClearItemProgress("item-1")
|
||||
CancelDownload("item-1")
|
||||
if GetDownloadProgress() == "" || GetAllDownloadProgress() == "" || GetAllDownloadProgressDelta(0) == "" {
|
||||
t.Fatal("expected progress JSON")
|
||||
}
|
||||
CleanupConnections()
|
||||
|
||||
cuePath, audioPath := writeExportCueFixture(t, dir)
|
||||
if jsonText, err := ParseCueSheet(cuePath, ""); err != nil {
|
||||
t.Fatalf("ParseCueSheet = %q/%v", jsonText, err)
|
||||
} else {
|
||||
var parsed CueSplitInfo
|
||||
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
|
||||
t.Fatalf("decode ParseCueSheet: %v", err)
|
||||
}
|
||||
if parsed.AudioPath != audioPath {
|
||||
t.Fatalf("ParseCueSheet audio path = %q want %q", parsed.AudioPath, audioPath)
|
||||
}
|
||||
}
|
||||
if jsonText, err := ScanCueSheetForLibrary(cuePath, "", "virtual.cue", 111); err != nil || !strings.Contains(jsonText, "cue+wav") {
|
||||
t.Fatalf("ScanCueSheetForLibrary = %q/%v", jsonText, err)
|
||||
}
|
||||
if jsonText, err := ScanCueSheetForLibraryWithCoverCacheKey(cuePath, "", "virtual.cue", 111, "cover-key"); err != nil || !strings.Contains(jsonText, "cue+wav") {
|
||||
t.Fatalf("ScanCueSheetForLibraryWithCoverCacheKey = %q/%v", jsonText, err)
|
||||
}
|
||||
|
||||
apePath := filepath.Join(dir, "edit.ape")
|
||||
if err := os.WriteFile(apePath, []byte("audio"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
editJSON := `{"title":"Edited","artist":"Artist","track_number":"1","track_total":"2","disc_number":"1","disc_total":"1"}`
|
||||
if response, err := EditFileMetadata(apePath, editJSON); err != nil || !strings.Contains(response, "native_ape") {
|
||||
t.Fatalf("EditFileMetadata ape = %q/%v", response, err)
|
||||
}
|
||||
if response, err := EditFileMetadata(filepath.Join(dir, "edit.mp3"), editJSON); err != nil || !strings.Contains(response, "ffmpeg") {
|
||||
t.Fatalf("EditFileMetadata ffmpeg = %q/%v", response, err)
|
||||
}
|
||||
misnamedM4APath := filepath.Join(dir, "misnamed.flac")
|
||||
if err := os.WriteFile(misnamedM4APath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "Misnamed"), true), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replayGainJSON := `{"replaygain_track_gain":"-1 dB","replaygain_track_peak":"0.9"}`
|
||||
if response, err := EditFileMetadata(misnamedM4APath, replayGainJSON); err != nil || !strings.Contains(response, "native_m4a_replaygain") {
|
||||
t.Fatalf("EditFileMetadata misnamed m4a replaygain = %q/%v", response, err)
|
||||
}
|
||||
if _, err := EditFileMetadata(apePath, `not-json`); err == nil {
|
||||
t.Fatal("expected invalid metadata JSON")
|
||||
}
|
||||
if !hasOnlyM4AReplayGainFields(map[string]string{"replaygain_track_gain": "-1 dB"}) {
|
||||
t.Fatal("expected replaygain-only fields")
|
||||
}
|
||||
if hasOnlyM4AReplayGainFields(map[string]string{"title": "Song"}) {
|
||||
t.Fatal("expected non-replaygain field rejection")
|
||||
}
|
||||
|
||||
AllowDownloadDir(dir)
|
||||
if err := SetDownloadDirectory(dir); err != nil {
|
||||
t.Fatalf("SetDownloadDirectory: %v", err)
|
||||
}
|
||||
if duplicateJSON, err := CheckDuplicate(dir, ""); err != nil || !strings.Contains(duplicateJSON, "exists") {
|
||||
t.Fatalf("CheckDuplicate = %q/%v", duplicateJSON, err)
|
||||
}
|
||||
if batchJSON, err := CheckDuplicatesBatch(dir, `[{"isrc":"","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(batchJSON, "Song") {
|
||||
t.Fatalf("CheckDuplicatesBatch = %q/%v", batchJSON, err)
|
||||
}
|
||||
_ = PreBuildDuplicateIndex(dir)
|
||||
InvalidateDuplicateIndex(dir)
|
||||
if filename, err := BuildFilename("{artist} - {title}", `{"artist":"A/B","title":"Song?"}`); err != nil || filename == "" {
|
||||
t.Fatalf("BuildFilename = %q/%v", filename, err)
|
||||
}
|
||||
if _, err := BuildFilename("{title}", `not-json`); err == nil {
|
||||
t.Fatal("expected BuildFilename JSON error")
|
||||
}
|
||||
if got := SanitizeFilename(`A/B:C*D?`); strings.ContainsAny(got, `/:*?`) {
|
||||
t.Fatalf("SanitizeFilename = %q", got)
|
||||
}
|
||||
|
||||
if response, err := PreWarmTrackCacheJSON(`not-json`); err != nil || !strings.Contains(response, "Invalid JSON") {
|
||||
t.Fatalf("PreWarmTrackCacheJSON invalid = %q/%v", response, err)
|
||||
}
|
||||
if response, err := PreWarmTrackCacheJSON(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(response, "success") {
|
||||
t.Fatalf("PreWarmTrackCacheJSON = %q/%v", response, err)
|
||||
}
|
||||
if GetTrackCacheSize() != 0 {
|
||||
t.Fatal("expected empty track cache")
|
||||
}
|
||||
ClearTrackIDCache()
|
||||
|
||||
if err := SetLyricsProvidersJSON(`["lrclib","apple_music"]`); err != nil {
|
||||
t.Fatalf("SetLyricsProvidersJSON: %v", err)
|
||||
}
|
||||
if providers, err := GetLyricsProvidersJSON(); err != nil || !strings.Contains(providers, "lrclib") {
|
||||
t.Fatalf("GetLyricsProvidersJSON = %q/%v", providers, err)
|
||||
}
|
||||
if available, err := GetAvailableLyricsProvidersJSON(); err != nil || available == "" {
|
||||
t.Fatalf("GetAvailableLyricsProvidersJSON = %q/%v", available, err)
|
||||
}
|
||||
if err := SetLyricsFetchOptionsJSON(`{"include_translation_netease":true}`); err != nil {
|
||||
t.Fatalf("SetLyricsFetchOptionsJSON: %v", err)
|
||||
}
|
||||
if opts, err := GetLyricsFetchOptionsJSON(); err != nil || opts == "" {
|
||||
t.Fatalf("GetLyricsFetchOptionsJSON = %q/%v", opts, err)
|
||||
}
|
||||
|
||||
if err := SetProviderPriorityJSON(`["coverage-ext"]`); err != nil {
|
||||
t.Fatalf("SetProviderPriorityJSON: %v", err)
|
||||
}
|
||||
if jsonText, err := GetProviderPriorityJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
||||
t.Fatalf("GetProviderPriorityJSON = %q/%v", jsonText, err)
|
||||
}
|
||||
if err := SetExtensionFallbackProviderIDsJSON(`["coverage-ext"]`); err != nil {
|
||||
t.Fatalf("SetExtensionFallbackProviderIDsJSON: %v", err)
|
||||
}
|
||||
if jsonText, err := GetExtensionFallbackProviderIDsJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
||||
t.Fatalf("GetExtensionFallbackProviderIDsJSON = %q/%v", jsonText, err)
|
||||
}
|
||||
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
|
||||
t.Fatalf("reset extension fallback IDs: %v", err)
|
||||
}
|
||||
if err := SetMetadataProviderPriorityJSON(`["coverage-ext"]`); err != nil {
|
||||
t.Fatalf("SetMetadataProviderPriorityJSON: %v", err)
|
||||
}
|
||||
if jsonText, err := GetMetadataProviderPriorityJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
||||
t.Fatalf("GetMetadataProviderPriorityJSON = %q/%v", jsonText, err)
|
||||
}
|
||||
|
||||
if err := SetExtensionSettingsJSON(ext.ID, `{"quality":"lossless","_secret":"hidden"}`); err != nil {
|
||||
t.Fatalf("SetExtensionSettingsJSON: %v", err)
|
||||
}
|
||||
if settingsJSON, err := GetExtensionSettingsJSON(ext.ID); err != nil || !strings.Contains(settingsJSON, "quality") {
|
||||
t.Fatalf("GetExtensionSettingsJSON = %q/%v", settingsJSON, err)
|
||||
}
|
||||
if err := SetExtensionSettingsJSON(ext.ID, `not-json`); err == nil {
|
||||
t.Fatal("expected settings JSON error")
|
||||
}
|
||||
|
||||
if jsonText, err := SearchTracksWithExtensionsJSON("song", 5); err != nil || !strings.Contains(jsonText, "search-1") {
|
||||
t.Fatalf("SearchTracksWithExtensionsJSON = %q/%v", jsonText, err)
|
||||
}
|
||||
if jsonText, err := SearchTracksWithMetadataProvidersJSON("song", 5, true); err != nil || !strings.Contains(jsonText, "search-1") {
|
||||
t.Fatalf("SearchTracksWithMetadataProvidersJSON = %q/%v", jsonText, err)
|
||||
}
|
||||
if jsonText, err := GetProviderMetadataJSON(ext.ID, "track", "track-1"); err != nil || !strings.Contains(jsonText, "Track track-1") {
|
||||
t.Fatalf("GetProviderMetadataJSON track = %q/%v", jsonText, err)
|
||||
}
|
||||
for _, resourceType := range []string{"album", "playlist", "artist"} {
|
||||
if jsonText, err := GetProviderMetadataJSON(ext.ID, resourceType, resourceType+"-1"); err != nil || jsonText == "" {
|
||||
t.Fatalf("GetProviderMetadataJSON %s = %q/%v", resourceType, jsonText, err)
|
||||
}
|
||||
}
|
||||
if _, err := GetProviderMetadataJSON("", "track", "id"); err == nil {
|
||||
t.Fatal("expected empty provider ID error")
|
||||
}
|
||||
if _, err := GetProviderMetadataJSON(ext.ID, "unsupported", "id"); err == nil {
|
||||
t.Fatal("expected unsupported provider type")
|
||||
}
|
||||
if firstNonEmptyTrimmed(" ", " value ") != "value" {
|
||||
t.Fatal("expected first trimmed value")
|
||||
}
|
||||
requestJSON := `{"use_extensions":true,"use_fallback":false,"service":"coverage-ext","source":"coverage-ext","track_name":"Song","artist_name":"Artist","album_name":"Album","output_dir":"` + escapeJSONPath(dir) + `","output_ext":".flac","quality":"LOSSLESS"}`
|
||||
if jsonText, err := DownloadWithExtensionsJSON(requestJSON); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
||||
t.Fatalf("DownloadWithExtensionsJSON = %q/%v", jsonText, err)
|
||||
}
|
||||
if _, err := DownloadWithExtensionsJSON(`not-json`); err == nil {
|
||||
t.Fatal("expected DownloadWithExtensionsJSON JSON error")
|
||||
}
|
||||
|
||||
SetExtensionAuthCodeByID(ext.ID, "code")
|
||||
SetExtensionTokensByID(ext.ID, "access", "refresh", 60)
|
||||
if !IsExtensionAuthenticatedByID(ext.ID) {
|
||||
t.Fatal("expected authenticated extension")
|
||||
}
|
||||
if pending, err := GetExtensionPendingAuthJSON(ext.ID); err != nil || pending != "" {
|
||||
t.Fatalf("GetExtensionPendingAuthJSON = %q/%v", pending, err)
|
||||
}
|
||||
ClearExtensionPendingAuthByID(ext.ID)
|
||||
if all, err := GetAllPendingAuthRequestsJSON(); err != nil || all == "" {
|
||||
t.Fatalf("GetAllPendingAuthRequestsJSON = %q/%v", all, err)
|
||||
}
|
||||
|
||||
ffmpegCommandsMu.Lock()
|
||||
ffmpegCommands["cmd-1"] = &FFmpegCommand{ExtensionID: ext.ID, Command: "ffmpeg -version", InputPath: "in", OutputPath: "out"}
|
||||
ffmpegCommandsMu.Unlock()
|
||||
if cmdJSON, err := GetPendingFFmpegCommandJSON("cmd-1"); err != nil || !strings.Contains(cmdJSON, "cmd-1") {
|
||||
t.Fatalf("GetPendingFFmpegCommandJSON = %q/%v", cmdJSON, err)
|
||||
}
|
||||
if all, err := GetAllPendingFFmpegCommandsJSON(); err != nil || !strings.Contains(all, "cmd-1") {
|
||||
t.Fatalf("GetAllPendingFFmpegCommandsJSON = %q/%v", all, err)
|
||||
}
|
||||
SetFFmpegCommandResultByID("cmd-1", true, "ok", "")
|
||||
ClearFFmpegCommand("cmd-1")
|
||||
if empty, err := GetPendingFFmpegCommandJSON("missing"); err != nil || empty != "" {
|
||||
t.Fatalf("missing ffmpeg = %q/%v", empty, err)
|
||||
}
|
||||
|
||||
enrichedJSON, err := EnrichTrackWithExtensionJSON(ext.ID, `{"id":"track-1","name":"Old","artists":"Artist"}`)
|
||||
if err != nil || !strings.Contains(enrichedJSON, "Enriched") {
|
||||
t.Fatalf("EnrichTrackWithExtensionJSON = %q/%v", enrichedJSON, err)
|
||||
}
|
||||
if sameJSON, err := EnrichTrackWithExtensionJSON("missing", `{"name":"Old"}`); err != nil || !strings.Contains(sameJSON, "Old") {
|
||||
t.Fatalf("missing EnrichTrackWithExtensionJSON = %q/%v", sameJSON, err)
|
||||
}
|
||||
|
||||
deezerClient = &DeezerClient{
|
||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
|
||||
status := http.StatusOK
|
||||
if body == "" {
|
||||
status = http.StatusNotFound
|
||||
body = `{"error":"missing"}`
|
||||
}
|
||||
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
||||
})},
|
||||
searchCache: map[string]*cacheEntry{},
|
||||
albumCache: map[string]*cacheEntry{},
|
||||
artistCache: map[string]*cacheEntry{},
|
||||
isrcCache: map[string]string{},
|
||||
cacheCleanupInterval: time.Hour,
|
||||
}
|
||||
deezerClientOnce.Do(func() {})
|
||||
for _, item := range []struct {
|
||||
typ string
|
||||
id string
|
||||
}{
|
||||
{"track", "101"},
|
||||
{"album", "201"},
|
||||
{"artist", "301"},
|
||||
{"playlist", "401"},
|
||||
} {
|
||||
if jsonText, err := GetDeezerMetadata(item.typ, item.id); err != nil || jsonText == "" {
|
||||
t.Fatalf("GetDeezerMetadata %s = %q/%v", item.typ, jsonText, err)
|
||||
}
|
||||
}
|
||||
if _, err := GetDeezerMetadata("bad", "1"); err == nil {
|
||||
t.Fatal("expected unsupported Deezer metadata type")
|
||||
}
|
||||
if jsonText, err := GetDeezerRelatedArtists("301", 2); err != nil || !strings.Contains(jsonText, "Related") {
|
||||
t.Fatalf("GetDeezerRelatedArtists = %q/%v", jsonText, err)
|
||||
}
|
||||
if jsonText, err := GetDeezerExtendedMetadata("101"); err != nil || !strings.Contains(jsonText, "Label") {
|
||||
t.Fatalf("GetDeezerExtendedMetadata = %q/%v", jsonText, err)
|
||||
}
|
||||
if _, err := GetDeezerExtendedMetadata(""); err == nil {
|
||||
t.Fatal("expected empty Deezer metadata ID error")
|
||||
}
|
||||
if jsonText, err := SearchDeezerByISRC("USRC17607839"); err != nil || !strings.Contains(jsonText, "deezer:101") {
|
||||
t.Fatalf("SearchDeezerByISRC = %q/%v", jsonText, err)
|
||||
}
|
||||
if jsonText, err := SearchDeezerByISRCForItemID("USRC17607839", "item-isrc"); err != nil || !strings.Contains(jsonText, "deezer:101") {
|
||||
t.Fatalf("SearchDeezerByISRCForItemID = %q/%v", jsonText, err)
|
||||
}
|
||||
|
||||
customJSON, err := CustomSearchWithExtensionJSON(ext.ID, "needle", `{"filter":"tracks"}`)
|
||||
if err != nil || !strings.Contains(customJSON, "Custom needle") {
|
||||
t.Fatalf("CustomSearchWithExtensionJSON = %q/%v", customJSON, err)
|
||||
}
|
||||
if customJSON, err := CustomSearchWithExtensionJSONWithRequestID(ext.ID, "needle", `not-json`, "req-custom"); err != nil || !strings.Contains(customJSON, "custom-1") {
|
||||
t.Fatalf("CustomSearchWithExtensionJSONWithRequestID = %q/%v", customJSON, err)
|
||||
}
|
||||
if providersJSON, err := GetSearchProvidersJSON(); err != nil || !strings.Contains(providersJSON, "coverage-ext") {
|
||||
t.Fatalf("GetSearchProvidersJSON = %q/%v", providersJSON, err)
|
||||
}
|
||||
if found := FindURLHandlerJSON("https://example.test/track/1"); found != ext.ID {
|
||||
t.Fatalf("FindURLHandlerJSON = %q", found)
|
||||
}
|
||||
if handlersJSON, err := GetURLHandlersJSON(); err != nil || !strings.Contains(handlersJSON, "coverage-ext") {
|
||||
t.Fatalf("GetURLHandlersJSON = %q/%v", handlersJSON, err)
|
||||
}
|
||||
if handledJSON, err := HandleURLWithExtensionJSON("https://example.test/track/1"); err != nil || !strings.Contains(handledJSON, "url-track") {
|
||||
t.Fatalf("HandleURLWithExtensionJSON = %q/%v", handledJSON, err)
|
||||
}
|
||||
if postJSON, err := RunPostProcessingJSON(filepath.Join(dir, "song.flac"), `{"title":"Song"}`); err != nil || !strings.Contains(postJSON, "success") {
|
||||
t.Fatalf("RunPostProcessingJSON = %q/%v", postJSON, err)
|
||||
}
|
||||
v2Input := `{"path":"` + escapeJSONPath(filepath.Join(dir, "song.flac")) + `","uri":"content://song","name":"song.flac","mime_type":"audio/flac","size":10}`
|
||||
if postJSON, err := RunPostProcessingV2JSON(v2Input, `not-json`); err != nil || !strings.Contains(postJSON, "success") {
|
||||
t.Fatalf("RunPostProcessingV2JSON = %q/%v", postJSON, err)
|
||||
}
|
||||
if postProviders, err := GetPostProcessingProvidersJSON(); err != nil || !strings.Contains(postProviders, "hook") {
|
||||
t.Fatalf("GetPostProcessingProvidersJSON = %q/%v", postProviders, err)
|
||||
}
|
||||
if feedJSON, err := GetExtensionHomeFeedJSON(ext.ID); err != nil || !strings.Contains(feedJSON, "home-1") {
|
||||
t.Fatalf("GetExtensionHomeFeedJSON = %q/%v", feedJSON, err)
|
||||
}
|
||||
if feedJSON, err := GetExtensionHomeFeedJSONWithRequestID(ext.ID, "req-home"); err != nil || !strings.Contains(feedJSON, "home-1") {
|
||||
t.Fatalf("GetExtensionHomeFeedJSONWithRequestID = %q/%v", feedJSON, err)
|
||||
}
|
||||
if categoriesJSON, err := GetExtensionBrowseCategoriesJSON(ext.ID); err != nil || !strings.Contains(categoriesJSON, "cat-1") {
|
||||
t.Fatalf("GetExtensionBrowseCategoriesJSON = %q/%v", categoriesJSON, err)
|
||||
}
|
||||
CancelExtensionRequestJSON("req-home")
|
||||
|
||||
storeDir := filepath.Join(dir, "store")
|
||||
if err := InitExtensionStoreJSON(storeDir); err != nil {
|
||||
t.Fatalf("InitExtensionStoreJSON: %v", err)
|
||||
}
|
||||
if err := SetStoreRegistryURLJSON("https://registry.example.com/index.json"); err != nil {
|
||||
t.Fatalf("SetStoreRegistryURLJSON: %v", err)
|
||||
}
|
||||
store := getExtensionStore()
|
||||
store.cache = &storeRegistry{Extensions: []storeExtension{{
|
||||
ID: "coverage-ext",
|
||||
Name: "coverage-ext",
|
||||
Version: "1.0.0",
|
||||
Description: "Coverage",
|
||||
Category: CategoryMetadata,
|
||||
Tags: []string{"metadata"},
|
||||
DownloadURL: "https://registry.example.com/coverage.spotiflac-ext",
|
||||
}}}
|
||||
store.cacheTime = time.Now()
|
||||
if registryURL, err := GetStoreRegistryURLJSON(); err != nil || registryURL == "" {
|
||||
t.Fatalf("GetStoreRegistryURLJSON = %q/%v", registryURL, err)
|
||||
}
|
||||
if storeJSON, err := GetStoreExtensionsJSON(false); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
|
||||
t.Fatalf("GetStoreExtensionsJSON = %q/%v", storeJSON, err)
|
||||
}
|
||||
if storeJSON, err := SearchStoreExtensionsJSON("coverage", CategoryMetadata); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
|
||||
t.Fatalf("SearchStoreExtensionsJSON = %q/%v", storeJSON, err)
|
||||
}
|
||||
if catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
|
||||
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
|
||||
}
|
||||
if dest, err := buildStoreExtensionDestPath(
|
||||
dir,
|
||||
"coverage/ext",
|
||||
"https://registry.example.com/coverage.spotiflac-ext",
|
||||
); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
|
||||
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
|
||||
}
|
||||
if dest, err := buildStoreExtensionDestPath(
|
||||
dir,
|
||||
"coverage/ext",
|
||||
"https://registry.example.com/coverage.sflx",
|
||||
); err != nil || !strings.HasSuffix(dest, ".sflx") {
|
||||
t.Fatalf("buildStoreExtensionDestPath sflx = %q/%v", dest, err)
|
||||
}
|
||||
if _, err := buildStoreExtensionDestPath(
|
||||
dir,
|
||||
" ",
|
||||
"https://registry.example.com/coverage.sflx",
|
||||
); err == nil {
|
||||
t.Fatal("expected invalid extension id")
|
||||
}
|
||||
if err := ClearStoreCacheJSON(); err != nil {
|
||||
t.Fatalf("ClearStoreCacheJSON: %v", err)
|
||||
}
|
||||
if err := ClearStoreRegistryURLJSON(); err != nil {
|
||||
t.Fatalf("ClearStoreRegistryURLJSON: %v", err)
|
||||
}
|
||||
|
||||
SetLibraryCoverCacheDirJSON(filepath.Join(dir, "covers"))
|
||||
libraryDir := filepath.Join(dir, "library")
|
||||
if err := os.MkdirAll(libraryDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(libraryDir, "Artist - Song.mp3"), []byte("not mp3"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scanJSON, err := ScanLibraryFolderJSON(libraryDir); err != nil || !strings.Contains(scanJSON, "Song") {
|
||||
t.Fatalf("ScanLibraryFolderJSON = %q/%v", scanJSON, err)
|
||||
}
|
||||
if scanJSON, err := ScanLibraryFolderIncrementalJSON(libraryDir, `[]`); err != nil || !strings.Contains(scanJSON, "Song") {
|
||||
t.Fatalf("ScanLibraryFolderIncrementalJSON = %q/%v", scanJSON, err)
|
||||
}
|
||||
snapshotPath := filepath.Join(dir, "snapshot.json")
|
||||
if err := os.WriteFile(snapshotPath, []byte(`[]`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scanJSON, err := ScanLibraryFolderIncrementalFromSnapshotJSON(libraryDir, snapshotPath); err != nil || !strings.Contains(scanJSON, "Song") {
|
||||
t.Fatalf("ScanLibraryFolderIncrementalFromSnapshotJSON = %q/%v", scanJSON, err)
|
||||
}
|
||||
if GetLibraryScanProgressJSON() == "" {
|
||||
t.Fatal("expected scan progress JSON")
|
||||
}
|
||||
CancelLibraryScanJSON()
|
||||
if metadataJSON, err := ReadAudioMetadataJSON(filepath.Join(libraryDir, "missing.mp3")); err != nil || metadataJSON == "" {
|
||||
t.Fatalf("ReadAudioMetadataJSON = %q/%v", metadataJSON, err)
|
||||
}
|
||||
if metadataJSON, err := ReadAudioMetadataWithHintJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing"); err != nil || metadataJSON == "" {
|
||||
t.Fatalf("ReadAudioMetadataWithHintJSON = %q/%v", metadataJSON, err)
|
||||
}
|
||||
if metadataJSON, err := ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing", "key"); err != nil || metadataJSON == "" {
|
||||
t.Fatalf("ReadAudioMetadataWithHintAndCoverCacheKeyJSON = %q/%v", metadataJSON, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs([]string{"custom-ext"})
|
||||
|
||||
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
|
||||
t.Fatalf("SetExtensionFallbackProviderIDsJSON returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := GetExtensionFallbackProviderIDs(); got != nil {
|
||||
t.Fatalf("expected nil fallback provider list after reset, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Bonus Track",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album (Deluxe)",
|
||||
AlbumArtist: "Artist",
|
||||
ReleaseDate: "2024-01-01",
|
||||
TrackNumber: 14,
|
||||
DiscNumber: 1,
|
||||
ISRC: "REQ123",
|
||||
CoverURL: "https://example.com/cover.jpg",
|
||||
Genre: "Pop",
|
||||
Label: "Label",
|
||||
Copyright: "Copyright",
|
||||
}
|
||||
|
||||
result := DownloadResult{
|
||||
Title: "Bonus Track",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
ReleaseDate: "2023-12-01",
|
||||
TrackNumber: 2,
|
||||
DiscNumber: 9,
|
||||
ISRC: "RES456",
|
||||
}
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
"tidal",
|
||||
"ok",
|
||||
"/tmp/test.flac",
|
||||
false,
|
||||
)
|
||||
|
||||
if resp.Album != req.AlbumName {
|
||||
t.Fatalf("album = %q, want %q", resp.Album, req.AlbumName)
|
||||
}
|
||||
if resp.ReleaseDate != req.ReleaseDate {
|
||||
t.Fatalf("release date = %q, want %q", resp.ReleaseDate, req.ReleaseDate)
|
||||
}
|
||||
if resp.TrackNumber != req.TrackNumber {
|
||||
t.Fatalf("track number = %d, want %d", resp.TrackNumber, req.TrackNumber)
|
||||
}
|
||||
if resp.DiscNumber != req.DiscNumber {
|
||||
t.Fatalf("disc number = %d, want %d", resp.DiscNumber, req.DiscNumber)
|
||||
}
|
||||
if resp.Artist != result.Artist {
|
||||
t.Fatalf("artist = %q, want provider artist %q", resp.Artist, result.Artist)
|
||||
}
|
||||
if resp.ISRC != result.ISRC {
|
||||
t.Fatalf("isrc = %q, want provider isrc %q", resp.ISRC, result.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
|
||||
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
||||
DownloadRequest{
|
||||
AlbumName: "Album (Deluxe Edition)",
|
||||
ReleaseDate: "2024-01-01",
|
||||
TrackNumber: 13,
|
||||
DiscNumber: 2,
|
||||
},
|
||||
"Album",
|
||||
"2023-01-01",
|
||||
3,
|
||||
1,
|
||||
)
|
||||
|
||||
if album != "Album (Deluxe Edition)" {
|
||||
t.Fatalf("album = %q", album)
|
||||
}
|
||||
if releaseDate != "2024-01-01" {
|
||||
t.Fatalf("release date = %q", releaseDate)
|
||||
}
|
||||
if trackNumber != 13 {
|
||||
t.Fatalf("track number = %d", trackNumber)
|
||||
}
|
||||
if discNumber != 2 {
|
||||
t.Fatalf("disc number = %d", discNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Track",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album",
|
||||
AlbumArtist: "Artist",
|
||||
}
|
||||
|
||||
result := DownloadResult{
|
||||
Title: "Track",
|
||||
Artist: "Artist",
|
||||
Album: "Album",
|
||||
CoverURL: "https://cdn.qobuz.test/cover.jpg",
|
||||
}
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
"qobuz",
|
||||
"ok",
|
||||
"/tmp/test.flac",
|
||||
false,
|
||||
)
|
||||
|
||||
if resp.CoverURL != result.CoverURL {
|
||||
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadSuccessResponseNormalizesDecryptionDescriptor(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Track",
|
||||
ArtistName: "Artist",
|
||||
}
|
||||
|
||||
result := DownloadResult{
|
||||
Title: "Track",
|
||||
Artist: "Artist",
|
||||
DecryptionKey: "00112233",
|
||||
}
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
"amazon",
|
||||
"ok",
|
||||
"/tmp/test.m4a",
|
||||
false,
|
||||
)
|
||||
|
||||
if resp.Decryption == nil {
|
||||
t.Fatal("expected decryption descriptor to be present")
|
||||
}
|
||||
if resp.Decryption.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||
t.Fatalf("strategy = %q", resp.Decryption.Strategy)
|
||||
}
|
||||
if resp.Decryption.Key != result.DecryptionKey {
|
||||
t.Fatalf("key = %q, want %q", resp.Decryption.Key, result.DecryptionKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMusicBrainzGenrePrefersHighestCountTag(t *testing.T) {
|
||||
got := formatMusicBrainzGenre([]musicBrainzTag{
|
||||
{Name: "art pop", Count: 3},
|
||||
{Name: "pop", Count: 8},
|
||||
{Name: "dance pop", Count: 5},
|
||||
})
|
||||
|
||||
if got != "Pop" {
|
||||
t.Fatalf("genre = %q, want %q", got, "Pop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMusicBrainzAlbumArtistPrefersMatchingRelease(t *testing.T) {
|
||||
releases := []musicBrainzRelease{
|
||||
{
|
||||
Title: "Other Album",
|
||||
ArtistCredit: []musicBrainzArtistCredit{
|
||||
{Name: "Wrong Artist"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Target Album",
|
||||
ArtistCredit: []musicBrainzArtistCredit{
|
||||
{Name: "Artist A", JoinPhrase: " & "},
|
||||
{Name: "Artist B"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := selectMusicBrainzAlbumArtist(releases, "Target Album")
|
||||
if got != "Artist A & Artist B" {
|
||||
t.Fatalf("album artist = %q, want matching release artist credit", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichRequestExtendedMetadataUsesMusicBrainzAlbumArtist(t *testing.T) {
|
||||
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
||||
origMusicBrainzGenreFetcher := fetchMusicBrainzGenreByISRC
|
||||
origMusicBrainzAlbumArtistFetcher := fetchMusicBrainzAlbumArtistByISRC
|
||||
defer func() {
|
||||
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
||||
fetchMusicBrainzGenreByISRC = origMusicBrainzGenreFetcher
|
||||
fetchMusicBrainzAlbumArtistByISRC = origMusicBrainzAlbumArtistFetcher
|
||||
}()
|
||||
|
||||
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||
return &AlbumExtendedMetadata{}, nil
|
||||
}
|
||||
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
||||
return "", fmt.Errorf("no genre")
|
||||
}
|
||||
fetchMusicBrainzAlbumArtistByISRC = func(isrc string, albumName string) (string, error) {
|
||||
if isrc != "TESTISRC" || albumName != "Target Album" {
|
||||
t.Fatalf("unexpected MusicBrainz args: %q / %q", isrc, albumName)
|
||||
}
|
||||
return "MusicBrainz Album Artist", nil
|
||||
}
|
||||
|
||||
req := DownloadRequest{
|
||||
ISRC: "TESTISRC",
|
||||
ArtistName: "Track Artist",
|
||||
AlbumName: "Target Album",
|
||||
}
|
||||
|
||||
enrichRequestExtendedMetadata(&req)
|
||||
|
||||
if req.AlbumArtist != "MusicBrainz Album Artist" {
|
||||
t.Fatalf("album artist = %q, want MusicBrainz value", req.AlbumArtist)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichRequestExtendedMetadataDoesNotFallbackAlbumArtistToTrackArtist(t *testing.T) {
|
||||
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
||||
origMusicBrainzGenreFetcher := fetchMusicBrainzGenreByISRC
|
||||
origMusicBrainzAlbumArtistFetcher := fetchMusicBrainzAlbumArtistByISRC
|
||||
defer func() {
|
||||
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
||||
fetchMusicBrainzGenreByISRC = origMusicBrainzGenreFetcher
|
||||
fetchMusicBrainzAlbumArtistByISRC = origMusicBrainzAlbumArtistFetcher
|
||||
}()
|
||||
|
||||
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||
return &AlbumExtendedMetadata{}, nil
|
||||
}
|
||||
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
||||
return "", fmt.Errorf("no genre")
|
||||
}
|
||||
fetchMusicBrainzAlbumArtistByISRC = func(isrc string, albumName string) (string, error) {
|
||||
return "", fmt.Errorf("no album artist")
|
||||
}
|
||||
|
||||
req := DownloadRequest{
|
||||
ISRC: "TESTISRC",
|
||||
ArtistName: "Track Artist",
|
||||
AlbumName: "Target Album",
|
||||
}
|
||||
|
||||
enrichRequestExtendedMetadata(&req)
|
||||
|
||||
if req.AlbumArtist != "" {
|
||||
t.Fatalf("album artist = %q, want empty when MusicBrainz has no value", req.AlbumArtist)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichExtraMetadataByISRCFallsBackToMusicBrainzGenre(t *testing.T) {
|
||||
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
||||
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
|
||||
defer func() {
|
||||
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
||||
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
|
||||
}()
|
||||
|
||||
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||
return nil, nil
|
||||
}
|
||||
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
||||
if isrc != "TEST123" {
|
||||
t.Fatalf("unexpected isrc: %q", isrc)
|
||||
}
|
||||
return "Alternative Rock", nil
|
||||
}
|
||||
|
||||
genre := ""
|
||||
label := ""
|
||||
copyright := ""
|
||||
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST123", &genre, &label, ©right)
|
||||
|
||||
if genre != "Alternative Rock" {
|
||||
t.Fatalf("genre = %q, want fallback genre", genre)
|
||||
}
|
||||
if label != "" {
|
||||
t.Fatalf("label = %q, want empty", label)
|
||||
}
|
||||
if copyright != "" {
|
||||
t.Fatalf("copyright = %q, want empty", copyright)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichExtraMetadataByISRCPrefersDeezerGenre(t *testing.T) {
|
||||
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
||||
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
|
||||
defer func() {
|
||||
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
||||
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
|
||||
}()
|
||||
|
||||
musicBrainzCalled := false
|
||||
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||
return &AlbumExtendedMetadata{
|
||||
Genre: "Synthpop",
|
||||
Label: "EMI",
|
||||
Copyright: "(C) Test",
|
||||
}, nil
|
||||
}
|
||||
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
||||
musicBrainzCalled = true
|
||||
return "Rock", nil
|
||||
}
|
||||
|
||||
genre := ""
|
||||
label := ""
|
||||
copyright := ""
|
||||
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST456", &genre, &label, ©right)
|
||||
|
||||
if genre != "Synthpop" {
|
||||
t.Fatalf("genre = %q, want Deezer genre", genre)
|
||||
}
|
||||
if label != "EMI" {
|
||||
t.Fatalf("label = %q, want Deezer label", label)
|
||||
}
|
||||
if copyright != "(C) Test" {
|
||||
t.Fatalf("copyright = %q, want Deezer copyright", copyright)
|
||||
}
|
||||
if musicBrainzCalled {
|
||||
t.Fatal("expected MusicBrainz not to be called when Deezer already provides genre")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
SpotifyID: "spotify-track-id",
|
||||
AlbumName: "Original Album",
|
||||
ReleaseDate: "2024-01-01",
|
||||
ISRC: "REQ123",
|
||||
}
|
||||
|
||||
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||
AlbumName: "Resolved Album",
|
||||
ReleaseDate: "",
|
||||
ISRC: "",
|
||||
})
|
||||
|
||||
if req.ReleaseDate != "2024-01-01" {
|
||||
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
|
||||
}
|
||||
if req.AlbumName != "Resolved Album" {
|
||||
t.Fatalf("album = %q, want updated album", req.AlbumName)
|
||||
}
|
||||
if req.ISRC != "REQ123" {
|
||||
t.Fatalf("isrc = %q, want existing value preserved", req.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song Title",
|
||||
ArtistName: "Artist Name",
|
||||
AlbumName: "Album Name",
|
||||
ReleaseDate: "",
|
||||
DurationMs: 180000,
|
||||
}
|
||||
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "first",
|
||||
Name: "Song Title",
|
||||
Artists: "Artist Name",
|
||||
AlbumName: "Album Name",
|
||||
DurationMS: 180000,
|
||||
ReleaseDate: "",
|
||||
ProviderID: "spotify",
|
||||
},
|
||||
{
|
||||
ID: "second",
|
||||
Name: "Song Title",
|
||||
Artists: "Artist Name",
|
||||
AlbumName: "Album Name",
|
||||
DurationMS: 180000,
|
||||
ReleaseDate: "2024-03-09",
|
||||
ProviderID: "deezer",
|
||||
},
|
||||
}
|
||||
|
||||
best := selectBestReEnrichTrack(req, tracks)
|
||||
if best == nil {
|
||||
t.Fatal("expected a selected track")
|
||||
}
|
||||
if best.ID != "second" {
|
||||
t.Fatalf("selected track = %q, want candidate with release date", best.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBestReEnrichTrackRejectsMismatchedSearchResults(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song Title",
|
||||
ArtistName: "Artist Name",
|
||||
AlbumName: "Album Name",
|
||||
DurationMs: 180000,
|
||||
}
|
||||
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "wrong-rich-metadata",
|
||||
Name: "Different Song",
|
||||
Artists: "Different Artist",
|
||||
AlbumName: "Album Name",
|
||||
DurationMS: 180000,
|
||||
ReleaseDate: "2024-03-09",
|
||||
TrackNumber: 4,
|
||||
DiscNumber: 1,
|
||||
ISRC: "WRONG1234567",
|
||||
ProviderID: "deezer",
|
||||
},
|
||||
}
|
||||
|
||||
if best := selectBestReEnrichTrack(req, tracks); best != nil {
|
||||
t.Fatalf("selected track = %q, want no match", best.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBestReEnrichTrackAllowsExactISRCDespiteMetadataMismatch(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song Title",
|
||||
ArtistName: "Artist Name",
|
||||
ISRC: "USRC17607839",
|
||||
DurationMs: 999999000,
|
||||
}
|
||||
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "same-isrc",
|
||||
Name: "Different Song",
|
||||
Artists: "Different Artist",
|
||||
DurationMS: 180000,
|
||||
ISRC: "USRC17607839",
|
||||
ProviderID: "deezer",
|
||||
},
|
||||
}
|
||||
|
||||
best := selectBestReEnrichTrack(req, tracks)
|
||||
if best == nil {
|
||||
t.Fatal("expected exact ISRC candidate to be selected")
|
||||
}
|
||||
if best.ID != "same-isrc" {
|
||||
t.Fatalf("selected track = %q, want exact ISRC candidate", best.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBestReEnrichTrackPlaceholderFallsBackToAlbum(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Unknown Title",
|
||||
ArtistName: "Unknown Artist",
|
||||
AlbumName: "Harry Styles",
|
||||
DurationMs: 180000,
|
||||
}
|
||||
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "album-match",
|
||||
Name: "Sign of the Times",
|
||||
Artists: "Harry Styles",
|
||||
AlbumName: "Harry Styles",
|
||||
DurationMS: 180000,
|
||||
ProviderID: "deezer",
|
||||
},
|
||||
}
|
||||
|
||||
best := selectBestReEnrichTrack(req, tracks)
|
||||
if best == nil {
|
||||
t.Fatal("expected album-matching candidate to be selected when title/artist are placeholders")
|
||||
}
|
||||
if best.ID != "album-match" {
|
||||
t.Fatalf("selected track = %q, want album-match", best.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album",
|
||||
AlbumArtist: "",
|
||||
ReleaseDate: "",
|
||||
TrackNumber: 0,
|
||||
DiscNumber: 0,
|
||||
ISRC: "",
|
||||
Genre: "",
|
||||
Label: "",
|
||||
Copyright: "",
|
||||
}
|
||||
|
||||
metadata := buildReEnrichFFmpegMetadata(&req, "")
|
||||
|
||||
if metadata["TITLE"] != "Song" {
|
||||
t.Fatalf("title = %q", metadata["TITLE"])
|
||||
}
|
||||
if metadata["ARTIST"] != "Artist" {
|
||||
t.Fatalf("artist = %q", metadata["ARTIST"])
|
||||
}
|
||||
if metadata["ALBUM"] != "Album" {
|
||||
t.Fatalf("album = %q", metadata["ALBUM"])
|
||||
}
|
||||
|
||||
for _, key := range []string{
|
||||
"ALBUMARTIST",
|
||||
"DATE",
|
||||
"TRACKNUMBER",
|
||||
"DISCNUMBER",
|
||||
"ISRC",
|
||||
"GENRE",
|
||||
"ORGANIZATION",
|
||||
"COPYRIGHT",
|
||||
"LYRICS",
|
||||
"UNSYNCEDLYRICS",
|
||||
} {
|
||||
if _, exists := metadata[key]; exists {
|
||||
t.Fatalf("did not expect key %s in metadata: %#v", key, metadata)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReEnrichSearchQuerySkipsPlaceholderArtist(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Sign of the Times",
|
||||
ArtistName: "Unknown Artist",
|
||||
AlbumName: "Harry Styles",
|
||||
}
|
||||
|
||||
query := buildReEnrichSearchQuery(req)
|
||||
if query != "Sign of the Times" {
|
||||
t.Fatalf("query = %q", query)
|
||||
}
|
||||
|
||||
req = reEnrichRequest{
|
||||
TrackName: "Unknown Title",
|
||||
ArtistName: "Unknown Artist",
|
||||
AlbumName: "Harry Styles",
|
||||
}
|
||||
query = buildReEnrichSearchQuery(req)
|
||||
if query != "Harry Styles" {
|
||||
t.Fatalf("fallback album query = %q", query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
|
||||
req := reEnrichRequest{}
|
||||
|
||||
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||
Name: "Resolved Song",
|
||||
Artists: "Resolved Artist",
|
||||
TrackNumber: 7,
|
||||
TotalTracks: 12,
|
||||
DiscNumber: 2,
|
||||
TotalDiscs: 3,
|
||||
Composer: "Composer",
|
||||
})
|
||||
|
||||
if req.TrackNumber != 7 || req.TotalTracks != 12 {
|
||||
t.Fatalf("track metadata = %d/%d", req.TrackNumber, req.TotalTracks)
|
||||
}
|
||||
if req.DiscNumber != 2 || req.TotalDiscs != 3 {
|
||||
t.Fatalf("disc metadata = %d/%d", req.DiscNumber, req.TotalDiscs)
|
||||
}
|
||||
if req.TrackName != "Resolved Song" || req.ArtistName != "Resolved Artist" {
|
||||
t.Fatalf("basic tags = %q / %q", req.TrackName, req.ArtistName)
|
||||
}
|
||||
if req.Composer != "Composer" {
|
||||
t.Fatalf("composer = %q", req.Composer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReEnrichFFmpegMetadataFormatsTotalsAndComposer(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackNumber: 7,
|
||||
TotalTracks: 12,
|
||||
DiscNumber: 2,
|
||||
TotalDiscs: 3,
|
||||
Composer: "Composer",
|
||||
}
|
||||
|
||||
metadata := buildReEnrichFFmpegMetadata(&req, "")
|
||||
|
||||
if metadata["TRACKNUMBER"] != "7/12" {
|
||||
t.Fatalf("TRACKNUMBER = %q", metadata["TRACKNUMBER"])
|
||||
}
|
||||
if metadata["DISCNUMBER"] != "2/3" {
|
||||
t.Fatalf("DISCNUMBER = %q", metadata["DISCNUMBER"])
|
||||
}
|
||||
if metadata["COMPOSER"] != "Composer" {
|
||||
t.Fatalf("COMPOSER = %q", metadata["COMPOSER"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
extensionHealthDefaultTimeout = 4 * time.Second
|
||||
extensionHealthMaxBodyBytes = 64 * 1024
|
||||
extensionHealthDefaultCache = 10 * time.Minute
|
||||
extensionHealthMinCache = 60 * time.Second
|
||||
extensionHealthUnknownCache = 2 * time.Minute
|
||||
)
|
||||
|
||||
type ExtensionHealthResult struct {
|
||||
ExtensionID string `json:"extension_id"`
|
||||
Status string `json:"status"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
Checks []ExtensionHealthCheckResult `json:"checks"`
|
||||
}
|
||||
|
||||
type ExtensionHealthCheckResult struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method"`
|
||||
ServiceKey string `json:"service_key,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Status string `json:"status"`
|
||||
HTTPStatus int `json:"http_status,omitempty"`
|
||||
LatencyMs int64 `json:"latency_ms"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
}
|
||||
|
||||
type cachedExtensionHealthResult struct {
|
||||
result ExtensionHealthResult
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
extensionHealthCacheMu sync.Mutex
|
||||
extensionHealthCache = map[string]cachedExtensionHealthResult{}
|
||||
)
|
||||
|
||||
func CheckExtensionHealthJSON(extensionID string) (string, error) {
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := CheckExtensionHealth(ext)
|
||||
cacheExtensionHealthResult(ext, result)
|
||||
bytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
|
||||
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
|
||||
return CheckExtensionHealth(ext)
|
||||
}
|
||||
|
||||
cacheKey := strings.TrimSpace(ext.ID)
|
||||
if cacheKey == "" {
|
||||
return CheckExtensionHealth(ext)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
extensionHealthCacheMu.Lock()
|
||||
cached, ok := extensionHealthCache[cacheKey]
|
||||
if ok && now.Before(cached.expiresAt) {
|
||||
extensionHealthCacheMu.Unlock()
|
||||
return cached.result
|
||||
}
|
||||
extensionHealthCacheMu.Unlock()
|
||||
|
||||
result := CheckExtensionHealth(ext)
|
||||
cacheExtensionHealthResult(ext, result)
|
||||
return result
|
||||
}
|
||||
|
||||
func cacheExtensionHealthResult(ext *loadedExtension, result ExtensionHealthResult) {
|
||||
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := strings.TrimSpace(ext.ID)
|
||||
if cacheKey == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
|
||||
if result.Status == "unknown" && ttl > extensionHealthUnknownCache {
|
||||
ttl = extensionHealthUnknownCache
|
||||
}
|
||||
|
||||
extensionHealthCacheMu.Lock()
|
||||
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
|
||||
result: result,
|
||||
expiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
extensionHealthCacheMu.Unlock()
|
||||
}
|
||||
|
||||
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
result := ExtensionHealthResult{
|
||||
ExtensionID: "",
|
||||
Status: "unsupported",
|
||||
CheckedAt: now,
|
||||
Checks: []ExtensionHealthCheckResult{},
|
||||
}
|
||||
if ext == nil || ext.Manifest == nil {
|
||||
result.Status = "offline"
|
||||
return result
|
||||
}
|
||||
|
||||
result.ExtensionID = ext.ID
|
||||
checks := ext.Manifest.ServiceHealth
|
||||
if len(checks) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
result.Status = "online"
|
||||
for _, check := range checks {
|
||||
checkResult := runExtensionHealthCheck(ext.Manifest, check)
|
||||
result.Checks = append(result.Checks, checkResult)
|
||||
|
||||
switch checkResult.Status {
|
||||
case "offline":
|
||||
if check.Required {
|
||||
result.Status = "offline"
|
||||
} else if result.Status == "online" {
|
||||
result.Status = "degraded"
|
||||
}
|
||||
case "degraded":
|
||||
if result.Status == "online" {
|
||||
result.Status = "degraded"
|
||||
}
|
||||
case "unknown":
|
||||
if result.Status == "online" {
|
||||
result.Status = "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
|
||||
ttl := extensionHealthDefaultCache
|
||||
for _, check := range checks {
|
||||
if check.CacheTTLSeconds <= 0 {
|
||||
continue
|
||||
}
|
||||
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
|
||||
if checkTTL < extensionHealthMinCache {
|
||||
checkTTL = extensionHealthMinCache
|
||||
}
|
||||
if checkTTL < ttl {
|
||||
ttl = checkTTL
|
||||
}
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
|
||||
func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthCheck) ExtensionHealthCheckResult {
|
||||
method := strings.ToUpper(strings.TrimSpace(check.Method))
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
result := ExtensionHealthCheckResult{
|
||||
ID: check.ID,
|
||||
Label: check.Label,
|
||||
URL: check.URL,
|
||||
Method: method,
|
||||
ServiceKey: strings.TrimSpace(check.ServiceKey),
|
||||
Required: check.Required,
|
||||
Status: "unknown",
|
||||
CheckedAt: now,
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(check.URL)
|
||||
if err != nil {
|
||||
result.Status = "offline"
|
||||
result.Error = fmt.Sprintf("invalid health URL: %v", err)
|
||||
return result
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
result.Status = "offline"
|
||||
result.Error = "health check must use https"
|
||||
return result
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
result.Status = "offline"
|
||||
result.Error = "health check URL hostname is required"
|
||||
return result
|
||||
}
|
||||
if isPrivateIP(host) {
|
||||
result.Status = "offline"
|
||||
result.Error = "private/local health check host is not allowed"
|
||||
return result
|
||||
}
|
||||
if manifest == nil || !manifest.IsDomainAllowed(host) {
|
||||
result.Status = "offline"
|
||||
result.Error = fmt.Sprintf("health check host '%s' is not in extension network permissions", host)
|
||||
return result
|
||||
}
|
||||
if method != http.MethodGet && method != http.MethodHead {
|
||||
result.Status = "offline"
|
||||
result.Error = "health check method must be GET or HEAD"
|
||||
return result
|
||||
}
|
||||
|
||||
timeout := extensionHealthDefaultTimeout
|
||||
if check.TimeoutMs > 0 {
|
||||
timeout = time.Duration(check.TimeoutMs) * time.Millisecond
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, check.URL, nil)
|
||||
if err != nil {
|
||||
result.Status = "offline"
|
||||
result.Error = err.Error()
|
||||
return result
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", userAgentForURL(parsed))
|
||||
|
||||
start := time.Now()
|
||||
resp, err := NewMetadataHTTPClient(timeout).Do(req)
|
||||
result.LatencyMs = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
if isTransientExtensionHealthError(err) {
|
||||
result.Status = "unknown"
|
||||
} else {
|
||||
result.Status = "offline"
|
||||
}
|
||||
result.Error = err.Error()
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
result.HTTPStatus = resp.StatusCode
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
result.Status = "offline"
|
||||
result.Message = resp.Status
|
||||
return result
|
||||
}
|
||||
|
||||
if method == http.MethodHead {
|
||||
result.Status = "online"
|
||||
result.Message = resp.Status
|
||||
return result
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, extensionHealthMaxBodyBytes))
|
||||
if err != nil {
|
||||
result.Status = "degraded"
|
||||
result.Error = err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
status, message := classifyExtensionHealthBody(body, check.ServiceKey)
|
||||
result.Status = status
|
||||
if message == "" {
|
||||
result.Message = resp.Status
|
||||
} else {
|
||||
result.Message = message
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func isTransientExtensionHealthError(err error) bool {
|
||||
return isTransientNetworkError(err) || isConnectivityFailure(err)
|
||||
}
|
||||
|
||||
func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) {
|
||||
if len(strings.TrimSpace(string(body))) == 0 {
|
||||
return "online", ""
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "online", ""
|
||||
}
|
||||
|
||||
serviceKey = strings.TrimSpace(serviceKey)
|
||||
if serviceKey != "" {
|
||||
if status, message, ok := classifyExtensionHealthService(payload, serviceKey); ok {
|
||||
return status, message
|
||||
}
|
||||
}
|
||||
|
||||
rawStatus, _ := payload["status"].(string)
|
||||
normalized := strings.ToLower(strings.TrimSpace(rawStatus))
|
||||
switch normalized {
|
||||
case "", "ok", "up", "online", "healthy", "operational", "pass", "passing":
|
||||
return "online", rawStatus
|
||||
case "degraded", "partial", "warning", "warn":
|
||||
return "degraded", rawStatus
|
||||
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
||||
if isTransientHealthStatusMessage(string(body)) {
|
||||
return "unknown", rawStatus
|
||||
}
|
||||
return "offline", rawStatus
|
||||
default:
|
||||
return "online", rawStatus
|
||||
}
|
||||
}
|
||||
|
||||
func classifyExtensionHealthService(payload map[string]interface{}, serviceKey string) (string, string, bool) {
|
||||
rawServices, ok := payload["services"]
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
services, ok := rawServices.(map[string]interface{})
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
rawService, ok := services[serviceKey]
|
||||
if !ok {
|
||||
return "unknown", fmt.Sprintf("service '%s' not found", serviceKey), true
|
||||
}
|
||||
service, ok := rawService.(map[string]interface{})
|
||||
if !ok {
|
||||
return "unknown", fmt.Sprintf("service '%s' has invalid health payload", serviceKey), true
|
||||
}
|
||||
|
||||
label, _ := service["label"].(string)
|
||||
detail, _ := service["detail"].(string)
|
||||
errText, _ := service["error"].(string)
|
||||
messageParts := []string{}
|
||||
if strings.TrimSpace(label) != "" {
|
||||
messageParts = append(messageParts, strings.TrimSpace(label))
|
||||
}
|
||||
if strings.TrimSpace(detail) != "" {
|
||||
messageParts = append(messageParts, strings.TrimSpace(detail))
|
||||
}
|
||||
if strings.TrimSpace(errText) != "" {
|
||||
messageParts = append(messageParts, strings.TrimSpace(errText))
|
||||
}
|
||||
|
||||
rawStatus, hasStatus := service["status"]
|
||||
okValue, hasOK := service["ok"].(bool)
|
||||
joinedMessage := strings.Join(messageParts, ": ")
|
||||
transient := isTransientHealthStatusMessage(detail) ||
|
||||
isTransientHealthStatusMessage(errText) ||
|
||||
isTransientHealthStatusMessage(label)
|
||||
|
||||
if statusCode, ok := healthNumber(rawStatus); ok {
|
||||
if statusCode >= 200 && statusCode < 300 {
|
||||
return "online", joinedMessage, true
|
||||
}
|
||||
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
|
||||
return "degraded", joinedMessage, true
|
||||
}
|
||||
if statusCode == http.StatusInternalServerError && hasOK && okValue {
|
||||
return "online", joinedMessage, true
|
||||
}
|
||||
if transient || isTransientHealthStatusCode(statusCode) {
|
||||
return "unknown", joinedMessage, true
|
||||
}
|
||||
return "offline", joinedMessage, true
|
||||
}
|
||||
|
||||
if isExtensionHealthAuthRequired(detail) {
|
||||
return "degraded", joinedMessage, true
|
||||
}
|
||||
if transient {
|
||||
return "unknown", joinedMessage, true
|
||||
}
|
||||
if hasOK {
|
||||
if okValue {
|
||||
return "online", joinedMessage, true
|
||||
}
|
||||
return "offline", joinedMessage, true
|
||||
}
|
||||
if !hasStatus {
|
||||
return "unknown", joinedMessage, true
|
||||
}
|
||||
|
||||
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
|
||||
switch statusString {
|
||||
case "ok", "up", "online", "healthy", "operational":
|
||||
return "online", joinedMessage, true
|
||||
case "degraded", "partial", "warning", "warn":
|
||||
return "degraded", joinedMessage, true
|
||||
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
||||
return "offline", joinedMessage, true
|
||||
default:
|
||||
return "unknown", joinedMessage, true
|
||||
}
|
||||
}
|
||||
|
||||
func isExtensionHealthAuthRequired(detail string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(detail)) {
|
||||
case "auth_required", "authorization_required", "login_required", "unauthorized":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isTransientHealthStatusMessage(text string) bool {
|
||||
t := strings.ToLower(strings.TrimSpace(text))
|
||||
if t == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(t, "context deadline exceeded") ||
|
||||
strings.Contains(t, "deadline exceeded") ||
|
||||
strings.Contains(t, "timeout") ||
|
||||
strings.Contains(t, "timed out") ||
|
||||
strings.Contains(t, "temporarily unavailable") ||
|
||||
strings.Contains(t, "try again")
|
||||
}
|
||||
|
||||
func isTransientHealthStatusCode(code int) bool {
|
||||
switch code {
|
||||
case http.StatusRequestTimeout,
|
||||
http.StatusTooManyRequests,
|
||||
http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable,
|
||||
http.StatusGatewayTimeout:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func healthNumber(value interface{}) (int, bool) {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return int(v), true
|
||||
case int:
|
||||
return v, true
|
||||
case json.Number:
|
||||
n, err := v.Int64()
|
||||
return int(n), err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtensionHealthClassificationAndValidation(t *testing.T) {
|
||||
if status, msg := classifyExtensionHealthBody([]byte(`{"status":"degraded"}`), ""); status != "degraded" || msg != "degraded" {
|
||||
t.Fatalf("status/message = %q/%q", status, msg)
|
||||
}
|
||||
if status, _ := classifyExtensionHealthBody([]byte(`not-json`), ""); status != "online" {
|
||||
t.Fatalf("invalid JSON status = %q", status)
|
||||
}
|
||||
if status, msg := classifyExtensionHealthBody([]byte(`{"services":{"tidal":{"status":401,"label":"Tidal","detail":"auth_required"}}}`), "tidal"); status != "degraded" || !strings.Contains(msg, "Tidal") {
|
||||
t.Fatalf("service status/message = %q/%q", status, msg)
|
||||
}
|
||||
if status, msg, ok := classifyExtensionHealthService(map[string]interface{}{"services": map[string]interface{}{}}, "missing"); !ok || status != "unknown" || !strings.Contains(msg, "missing") {
|
||||
t.Fatalf("missing service = %q/%q/%v", status, msg, ok)
|
||||
}
|
||||
if n, ok := healthNumber(json.Number("503")); !ok || n != 503 {
|
||||
t.Fatalf("health number = %d/%v", n, ok)
|
||||
}
|
||||
if !isExtensionHealthAuthRequired(" unauthorized ") {
|
||||
t.Fatal("expected auth required")
|
||||
}
|
||||
if !isTransientExtensionHealthError(context.DeadlineExceeded) || !isTransientExtensionHealthError(&net.DNSError{IsTimeout: true}) {
|
||||
t.Fatal("expected timeout health errors to be transient")
|
||||
}
|
||||
if !isTransientExtensionHealthError(&net.DNSError{IsNotFound: true}) {
|
||||
t.Fatal("expected health transport lookup errors to be indeterminate")
|
||||
}
|
||||
|
||||
if result := CheckExtensionHealth(nil); result.Status != "offline" {
|
||||
t.Fatalf("nil health = %#v", result)
|
||||
}
|
||||
manifest := &ExtensionManifest{Permissions: ExtensionPermissions{Network: []string{"status.example.com"}}}
|
||||
invalidURL := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "bad", URL: "://bad"})
|
||||
if invalidURL.Status != "offline" {
|
||||
t.Fatalf("invalid URL = %#v", invalidURL)
|
||||
}
|
||||
insecure := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "http", URL: "http://status.example.com"})
|
||||
if insecure.Status != "offline" || !strings.Contains(insecure.Error, "https") {
|
||||
t.Fatalf("insecure = %#v", insecure)
|
||||
}
|
||||
disallowedHost := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "host", URL: "https://other.example.com"})
|
||||
if disallowedHost.Status != "offline" || !strings.Contains(disallowedHost.Error, "permissions") {
|
||||
t.Fatalf("host = %#v", disallowedHost)
|
||||
}
|
||||
badMethod := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "method", URL: "https://status.example.com", Method: "POST"})
|
||||
if badMethod.Status != "offline" || !strings.Contains(badMethod.Error, "method") {
|
||||
t.Fatalf("method = %#v", badMethod)
|
||||
}
|
||||
|
||||
ext := &loadedExtension{
|
||||
ID: "health-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
ServiceHealth: []ExtensionHealthCheck{
|
||||
{ID: "required", URL: "http://status.example.com", Required: true},
|
||||
{ID: "optional", URL: "http://status.example.com", Required: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
if result := CheckExtensionHealth(ext); result.Status != "offline" || len(result.Checks) != 2 {
|
||||
t.Fatalf("extension health = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverRomajiParallelAndIDHSHelpers(t *testing.T) {
|
||||
spotify := "https://i.scdn.co/image/ab67616d00001e02abcdef"
|
||||
if got := GetCoverFromSpotify(spotify, true); !strings.Contains(got, spotifySizeMax) {
|
||||
t.Fatalf("spotify cover = %q", got)
|
||||
}
|
||||
if got := upgradeToMaxQuality("https://cdn-images.dzcdn.net/images/cover/abc/500x500-000000-80-0-0.jpg"); !strings.Contains(got, "1800x1800") {
|
||||
t.Fatalf("deezer cover = %q", got)
|
||||
}
|
||||
if got := upgradeToMaxQuality("https://resources.tidal.com/images/id/320x320.jpg"); !strings.Contains(got, "origin.jpg") {
|
||||
t.Fatalf("tidal cover = %q", got)
|
||||
}
|
||||
if got := upgradeToMaxQuality("https://static.qobuz.com/images/covers/ab/cd/foo_600.jpg"); !strings.Contains(got, "_max.jpg") {
|
||||
t.Fatalf("qobuz cover = %q", got)
|
||||
}
|
||||
if data, err := downloadCoverToMemory("", false); err == nil || data != nil {
|
||||
t.Fatalf("expected empty cover error")
|
||||
}
|
||||
|
||||
if !ContainsJapanese("カタカナ") || ContainsJapanese("abc") {
|
||||
t.Fatal("unexpected Japanese detection")
|
||||
}
|
||||
if got := JapaneseToRomaji("きゃット"); got != "kyatto" {
|
||||
t.Fatalf("romaji = %q", got)
|
||||
}
|
||||
if got := BuildSearchQuery("きゃ! song", "アーティスト"); got != "atisuto kya song" {
|
||||
t.Fatalf("query = %q", got)
|
||||
}
|
||||
if got := CleanToASCII("A, B. C!"); got != "A B C" {
|
||||
t.Fatalf("ascii = %q", got)
|
||||
}
|
||||
|
||||
if err := PreWarmCache(`not-json`); err == nil {
|
||||
t.Fatal("expected prewarm JSON error")
|
||||
}
|
||||
if err := PreWarmCache(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist","spotify_id":"sp","service":"tidal"}]`); err != nil {
|
||||
t.Fatalf("PreWarmCache: %v", err)
|
||||
}
|
||||
if result := FetchCoverAndLyricsParallel("", false, "", "", "", false, 0); result == nil || result.CoverErr != nil || result.LyricsErr != nil {
|
||||
t.Fatalf("parallel result = %#v", result)
|
||||
}
|
||||
if ClearTrackCache(); GetCacheSize() != 0 {
|
||||
t.Fatal("expected empty cache size")
|
||||
}
|
||||
|
||||
client := &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s", req.Method)
|
||||
}
|
||||
body := `{"id":"1","type":"song","title":"Song","links":[{"type":"tidal","url":"https://tidal.com/browse/track/7"},{"type":"deezer","url":"https://www.deezer.com/track/9"},{"type":"spotify","url":"https://open.spotify.com/track/abc"}]}`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}}
|
||||
availability, err := client.GetAvailabilityFromSpotify("spotify-track")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAvailabilityFromSpotify: %v", err)
|
||||
}
|
||||
if !availability.Tidal || !availability.Deezer || availability.DeezerID != "9" {
|
||||
t.Fatalf("spotify availability = %#v", availability)
|
||||
}
|
||||
deezerAvailability, err := client.GetAvailabilityFromDeezer("9")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAvailabilityFromDeezer: %v", err)
|
||||
}
|
||||
if deezerAvailability.SpotifyID != "abc" || !deezerAvailability.Tidal {
|
||||
t.Fatalf("deezer availability = %#v", deezerAvailability)
|
||||
}
|
||||
|
||||
errorClient := &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 429, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil
|
||||
})}}
|
||||
if _, err := errorClient.Search("bad", nil); err == nil {
|
||||
t.Fatal("expected rate limit error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtensionManagerPackageLifecycle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
extensionsDir := filepath.Join(dir, "extensions")
|
||||
dataDir := filepath.Join(dir, "data")
|
||||
manager := &extensionManager{extensions: map[string]*loadedExtension{}}
|
||||
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
|
||||
t.Fatalf("SetDirectories: %v", err)
|
||||
}
|
||||
if err := GetExtensionSettingsStore().SetDataDir(dataDir); err != nil {
|
||||
t.Fatalf("settings data dir: %v", err)
|
||||
}
|
||||
|
||||
js := `
|
||||
var cleaned = false;
|
||||
registerExtension({
|
||||
initialize: function(settings) { this.settings = settings || {}; },
|
||||
cleanup: function() { cleaned = true; },
|
||||
doAction: function() { return { message: "done", setting_updates: { quality: "lossless" } }; },
|
||||
getHomeFeed: function() { return [{ id: "home", title: "Home" }]; },
|
||||
getBrowseCategories: function() { return [{ id: "cat", title: "Category" }]; },
|
||||
searchTracks: function() { return { tracks: [], total: 0 }; },
|
||||
fetchLyrics: function() { return { syncType: "UNSYNCED", lines: [{ words: "hello" }] }; },
|
||||
getDownloadUrl: function() { return { url: "https://example.test/a.flac" }; }
|
||||
});
|
||||
`
|
||||
pkgV1 := filepath.Join(dir, "manager-ext-v1.spotiflac-ext")
|
||||
createTestExtensionPackage(t, pkgV1, "manager-ext", "1.0.0", js, map[string]string{"../unsafe.txt": "skip"})
|
||||
pkgV2 := filepath.Join(dir, "manager-ext-v2.spotiflac-ext")
|
||||
createTestExtensionPackage(t, pkgV2, "manager-ext", "1.1.0", js, nil)
|
||||
|
||||
if compareVersions("v1.2.0", "1.1.9") <= 0 || compareVersions("1.0.0", "1.0") != 0 || compareVersions("1.0.0", "1.0.1") >= 0 {
|
||||
t.Fatal("compareVersions mismatch")
|
||||
}
|
||||
if _, err := manager.LoadExtensionFromFile(filepath.Join(dir, "bad.txt")); err == nil {
|
||||
t.Fatal("expected bad extension suffix error")
|
||||
}
|
||||
if _, err := manager.LoadExtensionFromFile(filepath.Join(dir, "missing.spotiflac-ext")); err == nil {
|
||||
t.Fatal("expected invalid package error")
|
||||
}
|
||||
|
||||
ext, err := manager.LoadExtensionFromFile(pkgV1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadExtensionFromFile: %v", err)
|
||||
}
|
||||
if ext.ID != "manager-ext" || ext.Enabled || ext.SourceDir == "" {
|
||||
t.Fatalf("loaded extension = %#v", ext)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(ext.SourceDir, "unsafe.txt")); err == nil {
|
||||
t.Fatal("unsafe archive path should not be extracted")
|
||||
}
|
||||
if _, err := manager.LoadExtensionFromFile(pkgV1); err == nil {
|
||||
t.Fatal("expected duplicate version error")
|
||||
}
|
||||
|
||||
installedJSON, err := manager.GetInstalledExtensionsJSON()
|
||||
if err != nil || !strings.Contains(installedJSON, "manager-ext") || !strings.Contains(installedJSON, "icon_path") {
|
||||
t.Fatalf("GetInstalledExtensionsJSON = %q/%v", installedJSON, err)
|
||||
}
|
||||
var installed []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(installedJSON), &installed); err != nil || len(installed) != 1 {
|
||||
t.Fatalf("decode installed = %#v/%v", installed, err)
|
||||
}
|
||||
|
||||
if err := GetExtensionSettingsStore().Set("manager-ext", "quality", "lossless"); err != nil {
|
||||
t.Fatalf("settings Set: %v", err)
|
||||
}
|
||||
if err := manager.SetExtensionEnabled("manager-ext", true); err != nil {
|
||||
t.Fatalf("enable extension: %v", err)
|
||||
}
|
||||
if !ext.Enabled || ext.VM == nil || !ext.initialized {
|
||||
t.Fatalf("enabled extension = %#v", ext)
|
||||
}
|
||||
if err := manager.InitializeExtension("manager-ext", map[string]interface{}{"quality": "hires"}); err != nil {
|
||||
t.Fatalf("InitializeExtension: %v", err)
|
||||
}
|
||||
action, err := manager.InvokeAction("manager-ext", "doAction")
|
||||
if err != nil || action["success"] != true || action["message"] != "done" {
|
||||
t.Fatalf("InvokeAction = %#v/%v", action, err)
|
||||
}
|
||||
if err := manager.CleanupExtension("manager-ext"); err != nil {
|
||||
t.Fatalf("CleanupExtension: %v", err)
|
||||
}
|
||||
if err := manager.SetExtensionEnabled("manager-ext", false); err != nil {
|
||||
t.Fatalf("disable extension: %v", err)
|
||||
}
|
||||
if ext.VM != nil || ext.initialized {
|
||||
t.Fatalf("expected VM teardown, got %#v", ext)
|
||||
}
|
||||
if _, err := manager.InvokeAction("manager-ext", "doAction"); err == nil {
|
||||
t.Fatal("expected disabled action error")
|
||||
}
|
||||
|
||||
upgradeJSON, err := manager.CheckExtensionUpgradeJSON(pkgV2)
|
||||
if err != nil || !strings.Contains(upgradeJSON, `"can_upgrade":true`) {
|
||||
t.Fatalf("CheckExtensionUpgradeJSON = %q/%v", upgradeJSON, err)
|
||||
}
|
||||
upgraded, err := manager.UpgradeExtension(pkgV2)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeExtension: %v", err)
|
||||
}
|
||||
if upgraded.Manifest.Version != "1.1.0" {
|
||||
t.Fatalf("upgraded = %#v", upgraded.Manifest)
|
||||
}
|
||||
if _, err := manager.UpgradeExtension(pkgV1); err == nil {
|
||||
t.Fatal("expected downgrade error")
|
||||
}
|
||||
if err := manager.RemoveExtension("manager-ext"); err != nil {
|
||||
t.Fatalf("RemoveExtension: %v", err)
|
||||
}
|
||||
if _, err := manager.GetExtension("manager-ext"); err == nil {
|
||||
t.Fatal("expected removed extension missing")
|
||||
}
|
||||
|
||||
dirExt := filepath.Join(extensionsDir, "dir-ext")
|
||||
if err := os.MkdirAll(dirExt, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := `{"name":"dir-ext","displayName":"dir-ext","version":"1.0.0","description":"Directory extension","type":["metadata_provider"],"permissions":{}}`
|
||||
if err := os.WriteFile(filepath.Join(dirExt, "manifest.json"), []byte(manifest), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dirExt, "index.js"), []byte(`registerExtension({searchTracks:function(){return {tracks:[], total:0};}});`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, loadErrs := manager.LoadExtensionsFromDirectory(extensionsDir)
|
||||
if len(loadErrs) != 0 || len(loaded) != 1 || loaded[0] != "dir-ext" {
|
||||
t.Fatalf("LoadExtensionsFromDirectory = %#v/%#v", loaded, loadErrs)
|
||||
}
|
||||
manager.UnloadAllExtensions()
|
||||
if len(manager.GetAllExtensions()) != 0 {
|
||||
t.Fatal("expected all extensions unloaded")
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
// Package gobackend provides extension manifest parsing and validation
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -26,9 +26,10 @@ const (
|
||||
)
|
||||
|
||||
type ExtensionPermissions struct {
|
||||
Network []string `json:"network"`
|
||||
Storage bool `json:"storage"`
|
||||
File bool `json:"file"`
|
||||
Network []string `json:"network"`
|
||||
Storage bool `json:"storage"`
|
||||
File bool `json:"file"`
|
||||
AllowHTTP bool `json:"allowHttp,omitempty"`
|
||||
}
|
||||
|
||||
type ExtensionSetting struct {
|
||||
@@ -102,26 +103,60 @@ type PostProcessingConfig struct {
|
||||
Hooks []PostProcessingHook `json:"hooks,omitempty"`
|
||||
}
|
||||
|
||||
type ExtensionHealthCheck struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method,omitempty"`
|
||||
ServiceKey string `json:"serviceKey,omitempty"`
|
||||
TimeoutMs int `json:"timeoutMs,omitempty"`
|
||||
CacheTTLSeconds int `json:"cacheTtlSeconds,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
type SignedSessionEndpoints struct {
|
||||
Bootstrap string `json:"bootstrap,omitempty"`
|
||||
Challenge string `json:"challenge,omitempty"`
|
||||
Exchange string `json:"exchange,omitempty"`
|
||||
Refresh string `json:"refresh,omitempty"`
|
||||
}
|
||||
|
||||
type SignedSessionConfig struct {
|
||||
Namespace string `json:"namespace"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
AppVersion string `json:"appVersion,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
CallbackURL string `json:"callbackUrl,omitempty"`
|
||||
SchemeLabel string `json:"schemeLabel,omitempty"`
|
||||
HeaderPrefix string `json:"headerPrefix,omitempty"`
|
||||
TimeWindowSeconds int `json:"timeWindowSeconds,omitempty"`
|
||||
Endpoints SignedSessionEndpoints `json:"endpoints,omitempty"`
|
||||
}
|
||||
|
||||
type ExtensionManifest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
||||
SignedSession *SignedSessionConfig `json:"signedSession,omitempty"`
|
||||
RequiredRuntimeFeatures []string `json:"requiredRuntimeFeatures,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestValidationError struct {
|
||||
@@ -155,10 +190,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
return &ManifestValidationError{Field: "version", Message: "version is required"}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.Author) == "" {
|
||||
return &ManifestValidationError{Field: "author", Message: "author is required"}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.Description) == "" {
|
||||
return &ManifestValidationError{Field: "description", Message: "description is required"}
|
||||
}
|
||||
@@ -191,7 +222,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Select type requires options
|
||||
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].options", i),
|
||||
@@ -207,6 +237,48 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
for i, check := range m.ServiceHealth {
|
||||
if strings.TrimSpace(check.ID) == "" {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("serviceHealth[%d].id", i),
|
||||
Message: "health check id is required",
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(check.URL) == "" {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("serviceHealth[%d].url", i),
|
||||
Message: "health check url is required",
|
||||
}
|
||||
}
|
||||
method := strings.ToUpper(strings.TrimSpace(check.Method))
|
||||
if method != "" && method != "GET" && method != "HEAD" {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("serviceHealth[%d].method", i),
|
||||
Message: "health check method must be GET or HEAD",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.SignedSession != nil {
|
||||
if strings.TrimSpace(m.SignedSession.Namespace) == "" {
|
||||
return &ManifestValidationError{Field: "signedSession.namespace", Message: "namespace is required"}
|
||||
}
|
||||
baseURL := strings.TrimSpace(m.SignedSession.BaseURL)
|
||||
if baseURL == "" {
|
||||
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is required"}
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(baseURL), "https://") {
|
||||
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl must use https"}
|
||||
}
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is invalid"}
|
||||
}
|
||||
if !m.IsDomainAllowed(parsed.Hostname()) {
|
||||
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl host must be listed in permissions.network"}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -231,6 +303,13 @@ func (m *ExtensionManifest) IsLyricsProvider() bool {
|
||||
return m.HasType(ExtensionTypeLyricsProvider)
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) StopsProviderFallback() bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
return m.StopProviderFallback || m.SkipBuiltInFallback
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
for _, allowed := range m.Permissions.Network {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type extensionCallPerf struct {
|
||||
extensionID string
|
||||
operation string
|
||||
startedAt time.Time
|
||||
initMs float64
|
||||
jsMs float64
|
||||
parseMs float64
|
||||
items int
|
||||
payloadBytes int
|
||||
}
|
||||
|
||||
func newExtensionCallPerf(extensionID, operation string) *extensionCallPerf {
|
||||
if !GetLogBuffer().IsLoggingEnabled() {
|
||||
return nil
|
||||
}
|
||||
return &extensionCallPerf{
|
||||
extensionID: extensionID,
|
||||
operation: operation,
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func extensionDurationMs(duration time.Duration) float64 {
|
||||
return float64(duration.Microseconds()) / 1000.0
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) recordInit(duration time.Duration) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.initMs += extensionDurationMs(duration)
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) recordJS(duration time.Duration) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.jsMs += extensionDurationMs(duration)
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) recordParse(duration time.Duration) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.parseMs += extensionDurationMs(duration)
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) recordPayload(value goja.Value) {
|
||||
if p == nil || gojaValueIsEmpty(value) {
|
||||
return
|
||||
}
|
||||
if payload, err := json.Marshal(value); err == nil {
|
||||
p.payloadBytes = len(payload)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) setPayloadBytes(payloadBytes int) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.payloadBytes = payloadBytes
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) setItems(items int) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.items = items
|
||||
}
|
||||
|
||||
func (p *extensionCallPerf) finish() {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
LogDebug(
|
||||
"ExtensionPerf",
|
||||
"extension=%s op=%s totalMs=%.1f initMs=%.1f jsMs=%.1f parseMs=%.1f items=%d payloadBytes=%d",
|
||||
p.extensionID,
|
||||
p.operation,
|
||||
extensionDurationMs(time.Since(p.startedAt)),
|
||||
p.initMs,
|
||||
p.jsMs,
|
||||
p.parseMs,
|
||||
p.items,
|
||||
p.payloadBytes,
|
||||
)
|
||||
}
|
||||
|
||||
func countExtensionTopLevelItems(vm *goja.Runtime, value goja.Value) int {
|
||||
if gojaValueIsEmpty(value) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if length, err := gojaArrayLength(value, vm); err == nil && length > 0 {
|
||||
return length
|
||||
}
|
||||
|
||||
obj := value.ToObject(vm)
|
||||
for _, key := range []string{"items", "tracks", "sections", "albums", "artists", "playlists", "results"} {
|
||||
child := obj.Get(key)
|
||||
if gojaValueIsEmpty(child) {
|
||||
continue
|
||||
}
|
||||
if length, err := gojaArrayLength(child, vm); err == nil && length > 0 {
|
||||
return length
|
||||
}
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtensionProviderWrapperFullSurface(t *testing.T) {
|
||||
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider, ExtensionTypeDownloadProvider, ExtensionTypeLyricsProvider)
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
|
||||
search, err := provider.SearchTracks("query", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchTracks: %v", err)
|
||||
}
|
||||
if search.Total != 1 || search.Tracks[0].ProviderID != ext.ID || search.Tracks[0].ExternalLinks["tidal"] == "" {
|
||||
t.Fatalf("search = %#v", search)
|
||||
}
|
||||
|
||||
track, err := provider.GetTrack("track-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTrack: %v", err)
|
||||
}
|
||||
if track.Name != "Track track-1" || track.ProviderID != ext.ID || track.AudioQuality == "" {
|
||||
t.Fatalf("track = %#v", track)
|
||||
}
|
||||
|
||||
album, err := provider.GetAlbum("album-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlbum: %v", err)
|
||||
}
|
||||
if album.ProviderID != ext.ID || len(album.Tracks) != 1 || album.Tracks[0].ProviderID != ext.ID {
|
||||
t.Fatalf("album = %#v", album)
|
||||
}
|
||||
|
||||
playlist, err := provider.GetPlaylist("playlist-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPlaylist: %v", err)
|
||||
}
|
||||
if playlist.Name != "Playlist playlist-1" || playlist.ProviderID != ext.ID {
|
||||
t.Fatalf("playlist = %#v", playlist)
|
||||
}
|
||||
|
||||
artist, err := provider.GetArtist("artist-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetArtist: %v", err)
|
||||
}
|
||||
if artist.ProviderID != ext.ID || len(artist.Releases) != 1 || artist.Releases[0].ProviderID != ext.ID {
|
||||
t.Fatalf("artist = %#v", artist)
|
||||
}
|
||||
|
||||
enriched, err := provider.EnrichTrack(&ExtTrackMetadata{ID: "track-1", Name: "Old", ProviderID: ext.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("EnrichTrack: %v", err)
|
||||
}
|
||||
if enriched.Name != "Enriched" || enriched.ProviderID != ext.ID {
|
||||
t.Fatalf("enriched = %#v", enriched)
|
||||
}
|
||||
|
||||
availability, err := provider.CheckAvailability("ISRC", "Song", "Artist", "spotify:1", "dz", "tidal", "qobuz")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckAvailability: %v", err)
|
||||
}
|
||||
if !availability.Available || availability.TrackID != "download-track" || !availability.SkipFallback {
|
||||
t.Fatalf("availability = %#v", availability)
|
||||
}
|
||||
|
||||
downloadURL, err := provider.GetDownloadURL("track-1", "LOSSLESS")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDownloadURL: %v", err)
|
||||
}
|
||||
if downloadURL.Format != "flac" || downloadURL.BitDepth != 24 || downloadURL.SampleRate != 96000 {
|
||||
t.Fatalf("download URL = %#v", downloadURL)
|
||||
}
|
||||
|
||||
progress := []int{}
|
||||
download, err := provider.Download("track-1", "LOSSLESS", filepath.Join(t.TempDir(), "song.flac"), "", func(percent int) {
|
||||
progress = append(progress, percent)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Download: %v", err)
|
||||
}
|
||||
if !download.Success || download.Decryption == nil || download.DecryptionKey != "001122" || len(progress) != 1 || progress[0] != 100 {
|
||||
t.Fatalf("download = %#v progress=%v", download, progress)
|
||||
}
|
||||
|
||||
lyrics, err := provider.FetchLyrics("Song", "Artist", "Album", 180)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLyrics: %v", err)
|
||||
}
|
||||
if lyrics.Provider != ext.ID || len(lyrics.Lines) != 1 || lyrics.Lines[0].Words != "Hello" {
|
||||
t.Fatalf("lyrics = %#v", lyrics)
|
||||
}
|
||||
|
||||
urlResult, err := provider.HandleURL("https://example.test/track/1")
|
||||
if err != nil {
|
||||
t.Fatalf("HandleURL: %v", err)
|
||||
}
|
||||
if urlResult.Track == nil || urlResult.Track.Name == "" || len(urlResult.Tracks) != 1 || urlResult.Album == nil || urlResult.Artist == nil {
|
||||
t.Fatalf("url result = %#v", urlResult)
|
||||
}
|
||||
|
||||
match, err := provider.MatchTrack(
|
||||
map[string]interface{}{"name": "Song", "artists": "Artist"},
|
||||
[]map[string]interface{}{{"id": "download-track", "name": "Song"}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("MatchTrack: %v", err)
|
||||
}
|
||||
if !match.Matched || match.TrackID != "download-track" {
|
||||
t.Fatalf("match = %#v", match)
|
||||
}
|
||||
|
||||
post, err := provider.PostProcess(filepath.Join(t.TempDir(), "song.flac"), map[string]interface{}{"title": "Song"}, "hook")
|
||||
if err != nil {
|
||||
t.Fatalf("PostProcess: %v", err)
|
||||
}
|
||||
if !post.Success || post.BitDepth != 24 || post.SampleRate != 96000 {
|
||||
t.Fatalf("post = %#v", post)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionProviderAndManagerSelectionHelpers(t *testing.T) {
|
||||
manifest := &ExtensionManifest{Capabilities: map[string]interface{}{
|
||||
"replacesBuiltInProviders": []interface{}{" Deezer ", 7, ""},
|
||||
}}
|
||||
if values := manifestCapabilityStringList(manifest, "replacesBuiltInProviders"); len(values) != 1 || values[0] != "deezer" {
|
||||
t.Fatalf("capability list = %#v", values)
|
||||
}
|
||||
if !extensionReplacesBuiltInProvider(&loadedExtension{Manifest: manifest}, "deezer") || extensionReplacesBuiltInProvider(nil, "deezer") {
|
||||
t.Fatal("extension replacement mismatch")
|
||||
}
|
||||
if trimKnownProviderPrefix("Deezer:101", "deezer") != "101" || trimKnownProviderPrefix("101", "deezer") != "101" {
|
||||
t.Fatal("trimKnownProviderPrefix mismatch")
|
||||
}
|
||||
if metadataTrackDedupKey(ExtTrackMetadata{ISRC: "usrc"}) != "isrc:USRC" ||
|
||||
metadataTrackDedupKey(ExtTrackMetadata{SpotifyID: "sp"}) != "spotify:sp" ||
|
||||
metadataTrackDedupKey(ExtTrackMetadata{ProviderID: "p", ID: "1"}) != "p:1" {
|
||||
t.Fatal("metadata dedup key mismatch")
|
||||
}
|
||||
|
||||
manager := &extensionManager{extensions: map[string]*loadedExtension{}}
|
||||
downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider, ExtensionTypeMetadataProvider)
|
||||
manager.extensions[downloadExt.ID] = downloadExt
|
||||
if providers := manager.GetDownloadProviders(); len(providers) != 1 {
|
||||
t.Fatalf("download providers = %#v", providers)
|
||||
}
|
||||
SetProviderPriority([]string{"deezer", "coverage-ext", "coverage-ext", " "})
|
||||
if priority := GetProviderPriority(); len(priority) != 1 || priority[0] != "coverage-ext" {
|
||||
t.Fatalf("provider priority = %#v", priority)
|
||||
}
|
||||
SetExtensionFallbackProviderIDs([]string{"a", "a", " ", "b"})
|
||||
if ids := GetExtensionFallbackProviderIDs(); len(ids) != 2 || !isExtensionFallbackAllowed("a") || isExtensionFallbackAllowed("z") {
|
||||
t.Fatalf("fallback ids = %#v", ids)
|
||||
}
|
||||
SetExtensionFallbackProviderIDs(nil)
|
||||
if !isExtensionFallbackAllowed("z") {
|
||||
t.Fatal("nil fallback list should allow all")
|
||||
}
|
||||
SetMetadataProviderPriority([]string{"spotify", "deezer", "coverage-ext", "coverage-ext"})
|
||||
if priority := GetMetadataProviderPriority(); len(priority) != 1 || priority[0] != "coverage-ext" {
|
||||
t.Fatalf("metadata priority = %#v", priority)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TestSetMetadataProviderPriorityStripsRetiredBuiltIns(t *testing.T) {
|
||||
original := GetMetadataProviderPriority()
|
||||
defer SetMetadataProviderPriority(original)
|
||||
|
||||
SetMetadataProviderPriority([]string{"qobuz"})
|
||||
got := GetMetadataProviderPriority()
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected retired built-in qobuz to be stripped, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetExtensionFallbackProviderIDsDedupesExtensions(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs([]string{"ext-a", "ext-a", " ext-b "})
|
||||
|
||||
got := GetExtensionFallbackProviderIDs()
|
||||
want := []string{"ext-a", "ext-b"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected fallback provider length: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unexpected fallback provider at %d: got %v want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs(nil)
|
||||
|
||||
if !isExtensionFallbackAllowed("custom-ext") {
|
||||
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs([]string{"allowed-ext"})
|
||||
|
||||
if !isExtensionFallbackAllowed("allowed-ext") {
|
||||
t.Fatal("expected explicitly allowed extension to be permitted")
|
||||
}
|
||||
if isExtensionFallbackAllowed("blocked-ext") {
|
||||
t.Fatal("expected extension outside allowlist to be blocked")
|
||||
}
|
||||
if isExtensionFallbackAllowed("deezer") {
|
||||
t.Fatal("expected retired Deezer downloader to respect extension fallback allowlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
|
||||
original := GetProviderPriority()
|
||||
defer SetProviderPriority(original)
|
||||
|
||||
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
|
||||
|
||||
got := GetProviderPriority()
|
||||
want := []string{"custom-ext"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetProviderPriorityKeepsExtensionNamedLikeRetiredDownloader(t *testing.T) {
|
||||
original := GetProviderPriority()
|
||||
defer SetProviderPriority(original)
|
||||
|
||||
manager := getExtensionManager()
|
||||
ext := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
ext.ID = "deezer"
|
||||
ext.Manifest.Name = "deezer"
|
||||
|
||||
manager.mu.Lock()
|
||||
previous, hadPrevious := manager.extensions[ext.ID]
|
||||
manager.extensions[ext.ID] = ext
|
||||
manager.mu.Unlock()
|
||||
defer func() {
|
||||
manager.mu.Lock()
|
||||
if hadPrevious {
|
||||
manager.extensions[ext.ID] = previous
|
||||
} else {
|
||||
delete(manager.extensions, ext.ID)
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
}()
|
||||
|
||||
SetProviderPriority([]string{"deezer", "custom-ext"})
|
||||
|
||||
got := GetProviderPriority()
|
||||
want := []string{"deezer", "custom-ext"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrioritizeFallbackProvidersByHealthPrefersOnlineAndSkipsOffline(t *testing.T) {
|
||||
manager := getExtensionManager()
|
||||
amazon := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
amazon.ID = "amazon"
|
||||
amazon.Manifest.Name = "amazon"
|
||||
amazon.Manifest.ServiceHealth = []ExtensionHealthCheck{{
|
||||
ID: "main",
|
||||
URL: "://bad",
|
||||
Required: true,
|
||||
}}
|
||||
|
||||
plain := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
plain.ID = "plain"
|
||||
plain.Manifest.Name = "plain"
|
||||
|
||||
deezer := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
deezer.ID = "deezer"
|
||||
deezer.Manifest.Name = "deezer"
|
||||
deezer.Manifest.ServiceHealth = []ExtensionHealthCheck{{
|
||||
ID: "main",
|
||||
URL: "https://example.test/health",
|
||||
}}
|
||||
|
||||
manager.mu.Lock()
|
||||
previousAmazon, hadAmazon := manager.extensions[amazon.ID]
|
||||
previousPlain, hadPlain := manager.extensions[plain.ID]
|
||||
previousDeezer, hadDeezer := manager.extensions[deezer.ID]
|
||||
manager.extensions[amazon.ID] = amazon
|
||||
manager.extensions[plain.ID] = plain
|
||||
manager.extensions[deezer.ID] = deezer
|
||||
manager.mu.Unlock()
|
||||
defer func() {
|
||||
manager.mu.Lock()
|
||||
if hadAmazon {
|
||||
manager.extensions[amazon.ID] = previousAmazon
|
||||
} else {
|
||||
delete(manager.extensions, amazon.ID)
|
||||
}
|
||||
if hadPlain {
|
||||
manager.extensions[plain.ID] = previousPlain
|
||||
} else {
|
||||
delete(manager.extensions, plain.ID)
|
||||
}
|
||||
if hadDeezer {
|
||||
manager.extensions[deezer.ID] = previousDeezer
|
||||
} else {
|
||||
delete(manager.extensions, deezer.ID)
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
|
||||
extensionHealthCacheMu.Lock()
|
||||
delete(extensionHealthCache, deezer.ID)
|
||||
extensionHealthCacheMu.Unlock()
|
||||
}()
|
||||
|
||||
extensionHealthCacheMu.Lock()
|
||||
extensionHealthCache[deezer.ID] = cachedExtensionHealthResult{
|
||||
result: ExtensionHealthResult{
|
||||
ExtensionID: deezer.ID,
|
||||
Status: "online",
|
||||
CheckedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
expiresAt: time.Now().Add(time.Minute),
|
||||
}
|
||||
extensionHealthCacheMu.Unlock()
|
||||
|
||||
got := prioritizeFallbackProvidersByHealth(
|
||||
[]string{"amazon", "plain", "deezer"},
|
||||
manager,
|
||||
"",
|
||||
)
|
||||
want := []string{"deezer", "plain"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected provider order length: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unexpected provider order at %d: got %v want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
|
||||
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
|
||||
if normalized == nil {
|
||||
t.Fatal("expected legacy decryption key to produce normalized descriptor")
|
||||
}
|
||||
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||
t.Fatalf("strategy = %q", normalized.Strategy)
|
||||
}
|
||||
if normalized.Key != "001122" {
|
||||
t.Fatalf("key = %q", normalized.Key)
|
||||
}
|
||||
if normalized.InputFormat != "mov" {
|
||||
t.Fatalf("input format = %q", normalized.InputFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) {
|
||||
normalized := normalizeDownloadDecryptionInfo(&DownloadDecryptionInfo{
|
||||
Strategy: "mp4_decryption_key",
|
||||
Key: "abcd",
|
||||
InputFormat: "",
|
||||
}, "")
|
||||
if normalized == nil {
|
||||
t.Fatal("expected descriptor to remain available")
|
||||
}
|
||||
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||
t.Fatalf("strategy = %q", normalized.Strategy)
|
||||
}
|
||||
if normalized.InputFormat != "mov" {
|
||||
t.Fatalf("input format = %q", normalized.InputFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionDownloadUsesIsolatedRuntimeForConcurrentCalls(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer server.Close()
|
||||
setPrivateIPCache("download.test", false, time.Minute)
|
||||
|
||||
originalTransport := sharedTransport
|
||||
testTransport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String())
|
||||
},
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
sharedTransport = testTransport
|
||||
defer func() {
|
||||
testTransport.CloseIdleConnections()
|
||||
sharedTransport = originalTransport
|
||||
}()
|
||||
|
||||
extDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(extDir, "index.js"), []byte(`
|
||||
registerExtension({
|
||||
download: function(trackID, quality, outputPath, onProgress) {
|
||||
var result = file.download('https://download.test/' + trackID, outputPath, {
|
||||
onProgress: function(written, total) {
|
||||
if (onProgress) onProgress(50);
|
||||
}
|
||||
});
|
||||
if (!result || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error_message: result && result.error ? result.error : 'download failed',
|
||||
error_type: 'download_error'
|
||||
};
|
||||
}
|
||||
if (onProgress) onProgress(100);
|
||||
return { success: true, file_path: result.path };
|
||||
}
|
||||
});
|
||||
`), 0600); err != nil {
|
||||
t.Fatalf("write extension index: %v", err)
|
||||
}
|
||||
|
||||
outputDir := t.TempDir()
|
||||
SetAllowedDownloadDirs([]string{outputDir})
|
||||
defer SetAllowedDownloadDirs(nil)
|
||||
|
||||
ext := &loadedExtension{
|
||||
ID: "concurrent-download",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "concurrent-download",
|
||||
Description: "Concurrent download test",
|
||||
Version: "1.0.0",
|
||||
Types: []ExtensionType{ExtensionTypeDownloadProvider},
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"download.test"},
|
||||
File: true,
|
||||
},
|
||||
},
|
||||
Enabled: true,
|
||||
SourceDir: extDir,
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
|
||||
start := time.Now()
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
i := i
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result, err := provider.Download(
|
||||
fmt.Sprintf("track-%d", i),
|
||||
"LOSSLESS",
|
||||
filepath.Join(outputDir, fmt.Sprintf("track-%d.flac", i)),
|
||||
"",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if result == nil || !result.Success {
|
||||
errs <- fmt.Errorf("download failed: %#v", result)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if elapsed := time.Since(start); elapsed >= 850*time.Millisecond {
|
||||
t.Fatalf("expected same-extension downloads to overlap, elapsed %s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathAddsExplicitOutputDirToAllowedDirs(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
outputDir := t.TempDir()
|
||||
outputPath := buildOutputPath(DownloadRequest{
|
||||
TrackName: "Song",
|
||||
ArtistName: "Artist",
|
||||
OutputDir: outputDir,
|
||||
OutputExt: ".flac",
|
||||
FilenameFormat: "",
|
||||
})
|
||||
|
||||
if !isPathInAllowedDirs(outputPath) {
|
||||
t.Fatalf("expected output path %q to be allowed", outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathForExtensionAddsExplicitOutputPathDirToAllowedDirs(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
outputDir := t.TempDir()
|
||||
outputPath := filepath.Join(outputDir, "custom.flac")
|
||||
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||
|
||||
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||
OutputPath: outputPath,
|
||||
}, ext)
|
||||
|
||||
if resolved != outputPath {
|
||||
t.Fatalf("resolved output path = %q", resolved)
|
||||
}
|
||||
if !isPathInAllowedDirs(outputPath) {
|
||||
t.Fatalf("expected output path %q to be allowed", outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||
TrackName: "Song",
|
||||
ArtistName: "Artist",
|
||||
OutputDir: filepath.Join("Artist", "Album"),
|
||||
OutputFD: 123,
|
||||
OutputExt: ".flac",
|
||||
}, ext)
|
||||
|
||||
expectedBase := filepath.Join(ext.DataDir, "downloads")
|
||||
if !isPathWithinBase(expectedBase, resolved) {
|
||||
t.Fatalf("expected SAF extension output under %q, got %q", expectedBase, resolved)
|
||||
}
|
||||
if !isPathInAllowedDirs(resolved) {
|
||||
t.Fatalf("expected resolved output path %q to be allowed", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathSanitizesTemplateFilename(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
outputDir := t.TempDir()
|
||||
outputPath := buildOutputPath(DownloadRequest{
|
||||
TrackName: `Gehra Hua (From "Dhurandhar")`,
|
||||
ArtistName: "Artist",
|
||||
OutputDir: outputDir,
|
||||
OutputExt: ".flac",
|
||||
FilenameFormat: "{artist} - {title}",
|
||||
})
|
||||
|
||||
base := filepath.Base(outputPath)
|
||||
if strings.ContainsAny(base, `<>:"/\|?*`) {
|
||||
t.Fatalf("output filename still contains illegal characters: %q", base)
|
||||
}
|
||||
if strings.Contains(base, `"`) {
|
||||
t.Fatalf("output filename still contains straight double quote: %q", base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathForExtensionSanitizesTemplateFilename(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||
TrackName: `Gehra Hua (From "Dhurandhar")`,
|
||||
ArtistName: "Artist",
|
||||
OutputFD: 123,
|
||||
OutputExt: ".flac",
|
||||
FilenameFormat: "{artist} - {title}",
|
||||
}, ext)
|
||||
|
||||
base := filepath.Base(resolved)
|
||||
if strings.ContainsAny(base, `<>:"/\|?*`) {
|
||||
t.Fatalf("extension output filename still contains illegal characters: %q", base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldStopProviderFallback(t *testing.T) {
|
||||
if shouldStopProviderFallback(nil) {
|
||||
t.Fatal("nil availability should not stop fallback")
|
||||
}
|
||||
if shouldStopProviderFallback(&ExtAvailabilityResult{Available: false}) {
|
||||
t.Fatal("availability without skip_fallback should not stop fallback")
|
||||
}
|
||||
if !shouldStopProviderFallback(&ExtAvailabilityResult{Available: false, SkipFallback: true}) {
|
||||
t.Fatal("skip_fallback availability should stop fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExtensionFallbackStoppedResponsePrefersAvailabilityReason(t *testing.T) {
|
||||
resp := buildExtensionFallbackStoppedResponse("soundcloud", &ExtAvailabilityResult{
|
||||
Reason: "direct SoundCloud track ID",
|
||||
SkipFallback: true,
|
||||
}, errors.New("ignored"))
|
||||
|
||||
if resp.Service != "soundcloud" {
|
||||
t.Fatalf("service = %q", resp.Service)
|
||||
}
|
||||
if resp.Error != "Fallback stopped by soundcloud: direct SoundCloud track ID" {
|
||||
t.Fatalf("unexpected error message: %q", resp.Error)
|
||||
}
|
||||
if resp.ErrorType != "extension_error" {
|
||||
t.Fatalf("error type = %q", resp.ErrorType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExtensionFallbackStoppedResponseFallsBackToError(t *testing.T) {
|
||||
resp := buildExtensionFallbackStoppedResponse("soundcloud", &ExtAvailabilityResult{
|
||||
SkipFallback: true,
|
||||
}, errors.New("lookup failed"))
|
||||
|
||||
if resp.Error != "Fallback stopped by soundcloud: lookup failed" {
|
||||
t.Fatalf("unexpected error message: %q", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldAbortCancelledFallbackWithCancelledError(t *testing.T) {
|
||||
if !shouldAbortCancelledFallback("", ErrDownloadCancelled) {
|
||||
t.Fatal("expected cancelled error to abort fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldAbortCancelledFallbackWithCancelledItemState(t *testing.T) {
|
||||
const itemID = "cancelled-item"
|
||||
initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
|
||||
cancelDownload(itemID)
|
||||
|
||||
if !shouldAbortCancelledFallback(itemID, errors.New("generic failure")) {
|
||||
t.Fatal("expected cancelled item state to abort fallback even for generic errors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
|
||||
tempFile := filepath.Join(t.TempDir(), "track.flac")
|
||||
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
tempM4A := filepath.Join(t.TempDir(), "track.m4a")
|
||||
if err := os.WriteFile(tempM4A, []byte("not-flac"), 0644); err != nil {
|
||||
t.Fatalf("failed to create temp m4a file: %v", err)
|
||||
}
|
||||
|
||||
if canEmbedGenreLabel("relative.flac") {
|
||||
t.Fatal("expected relative path to be rejected")
|
||||
}
|
||||
if canEmbedGenreLabel("content://example") {
|
||||
t.Fatal("expected content URI to be rejected")
|
||||
}
|
||||
if canEmbedGenreLabel(filepath.Join(t.TempDir(), "missing.flac")) {
|
||||
t.Fatal("expected missing file to be rejected")
|
||||
}
|
||||
if canEmbedGenreLabel(tempM4A) {
|
||||
t.Fatalf("expected non-FLAC file %q to be rejected", tempM4A)
|
||||
}
|
||||
if !canEmbedGenreLabel(tempFile) {
|
||||
t.Fatalf("expected existing absolute file %q to be accepted", tempFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTracksWithMetadataProvidersIgnoresRetiredBuiltIns(t *testing.T) {
|
||||
originalPriority := GetMetadataProviderPriority()
|
||||
defer func() {
|
||||
SetMetadataProviderPriority(originalPriority)
|
||||
}()
|
||||
|
||||
SetMetadataProviderPriority([]string{"qobuz"})
|
||||
|
||||
manager := getExtensionManager()
|
||||
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
||||
}
|
||||
if len(tracks) != 0 {
|
||||
t.Fatalf("expected no tracks from retired built-in provider, got %+v", tracks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionSearchResultAcceptsObjectAndArrayShapes(t *testing.T) {
|
||||
vm := goja.New()
|
||||
value, err := vm.RunString(`({
|
||||
tracks: [{
|
||||
id: "track-1",
|
||||
name: "Song",
|
||||
artists: "Artist",
|
||||
album_name: "Album",
|
||||
duration_ms: 123000,
|
||||
cover_url: "https://img.test/cover.jpg",
|
||||
external_links: { spotify: "spotify:track:1" },
|
||||
audio_quality: "LOSSLESS"
|
||||
}],
|
||||
total: 9
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build object search result: %v", err)
|
||||
}
|
||||
|
||||
result, err := parseExtensionSearchResult(vm, value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse object search result: %v", err)
|
||||
}
|
||||
if result.Total != 9 || len(result.Tracks) != 1 {
|
||||
t.Fatalf("unexpected object result: %+v", result)
|
||||
}
|
||||
track := result.Tracks[0]
|
||||
if track.ID != "track-1" ||
|
||||
track.AlbumName != "Album" ||
|
||||
track.DurationMS != 123000 ||
|
||||
track.CoverURL != "https://img.test/cover.jpg" ||
|
||||
track.ExternalLinks["spotify"] != "spotify:track:1" ||
|
||||
track.AudioQuality != "LOSSLESS" {
|
||||
t.Fatalf("unexpected parsed track: %+v", track)
|
||||
}
|
||||
|
||||
arrayValue, err := vm.RunString(`[
|
||||
{id: "track-2", name: "Other Song", artists: "Other Artist", albumName: "Other Album", durationMs: 456000}
|
||||
]`)
|
||||
if err != nil {
|
||||
t.Fatalf("build array search result: %v", err)
|
||||
}
|
||||
|
||||
arrayResult, err := parseExtensionSearchResult(vm, arrayValue)
|
||||
if err != nil {
|
||||
t.Fatalf("parse array search result: %v", err)
|
||||
}
|
||||
if arrayResult.Total != 1 ||
|
||||
len(arrayResult.Tracks) != 1 ||
|
||||
arrayResult.Tracks[0].AlbumName != "Other Album" ||
|
||||
arrayResult.Tracks[0].DurationMS != 456000 {
|
||||
t.Fatalf("unexpected array result: %+v", arrayResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionMetadataAndDownloadResults(t *testing.T) {
|
||||
vm := goja.New()
|
||||
value, err := vm.RunString(`({
|
||||
id: "album-1",
|
||||
name: "Album",
|
||||
artists: "Artist",
|
||||
artistId: "artist-1",
|
||||
coverUrl: "https://img.test/album.jpg",
|
||||
releaseDate: "2024-02-03",
|
||||
totalTracks: 2,
|
||||
albumType: "album",
|
||||
tracks: [
|
||||
{id: "track-1", name: "Song 1", artists: "Artist", durationMs: 180000},
|
||||
{id: "track-2", name: "Song 2", artists: "Artist", duration_ms: 181000}
|
||||
]
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build album value: %v", err)
|
||||
}
|
||||
|
||||
album, err := parseExtensionAlbumValue(vm, value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse album: %v", err)
|
||||
}
|
||||
if album.ID != "album-1" ||
|
||||
album.ArtistID != "artist-1" ||
|
||||
album.CoverURL != "https://img.test/album.jpg" ||
|
||||
album.TotalTracks != 2 ||
|
||||
len(album.Tracks) != 2 ||
|
||||
album.Tracks[0].DurationMS != 180000 ||
|
||||
album.Tracks[1].DurationMS != 181000 {
|
||||
t.Fatalf("unexpected album: %+v", album)
|
||||
}
|
||||
|
||||
artistValue, err := vm.RunString(`({
|
||||
id: "artist-1",
|
||||
name: "Artist",
|
||||
imageUrl: "https://img.test/artist.jpg",
|
||||
headerImage: "https://img.test/header.jpg",
|
||||
listeners: 1234,
|
||||
albums: [{id: "album-1", name: "Album", tracks: [{id: "track-1", name: "Song"}]}],
|
||||
releases: [{id: "single-1", name: "Single"}],
|
||||
topTracks: [{id: "top-1", name: "Top Song"}]
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build artist value: %v", err)
|
||||
}
|
||||
|
||||
artist, err := parseExtensionArtistValue(vm, artistValue)
|
||||
if err != nil {
|
||||
t.Fatalf("parse artist: %v", err)
|
||||
}
|
||||
if artist.ID != "artist-1" ||
|
||||
artist.ImageURL != "https://img.test/artist.jpg" ||
|
||||
artist.HeaderImage != "https://img.test/header.jpg" ||
|
||||
artist.Listeners != 1234 ||
|
||||
len(artist.Albums) != 1 ||
|
||||
len(artist.Albums[0].Tracks) != 1 ||
|
||||
len(artist.Releases) != 1 ||
|
||||
len(artist.TopTracks) != 1 {
|
||||
t.Fatalf("unexpected artist: %+v", artist)
|
||||
}
|
||||
|
||||
downloadValue, err := vm.RunString(`({
|
||||
success: true,
|
||||
filePath: "/tmp/song.flac",
|
||||
alreadyExists: true,
|
||||
bitDepth: 24,
|
||||
sampleRate: 96000,
|
||||
title: "Song",
|
||||
albumArtist: "Album Artist",
|
||||
lyricsLrc: "[00:00.00]Line",
|
||||
decryptionKey: "001122",
|
||||
decryption: {
|
||||
strategy: "mp4_decryption_key",
|
||||
key: "001122",
|
||||
inputFormat: "m4a",
|
||||
options: { map: "0:a" }
|
||||
}
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build download value: %v", err)
|
||||
}
|
||||
|
||||
download := parseExtensionDownloadResultValue(vm, downloadValue)
|
||||
if !download.Success ||
|
||||
download.FilePath != "/tmp/song.flac" ||
|
||||
!download.AlreadyExists ||
|
||||
download.BitDepth != 24 ||
|
||||
download.SampleRate != 96000 ||
|
||||
download.AlbumArtist != "Album Artist" ||
|
||||
download.LyricsLRC != "[00:00.00]Line" ||
|
||||
download.Decryption == nil ||
|
||||
download.Decryption.InputFormat != "m4a" ||
|
||||
download.Decryption.Options["map"] != "0:a" {
|
||||
t.Fatalf("unexpected download result: %+v", download)
|
||||
}
|
||||
|
||||
availabilityValue, err := vm.RunString(`({ available: true, trackId: "track-1", skipFallback: true, reason: "direct" })`)
|
||||
if err != nil {
|
||||
t.Fatalf("build availability value: %v", err)
|
||||
}
|
||||
availability := parseExtensionAvailabilityValue(vm, availabilityValue)
|
||||
if !availability.Available || availability.TrackID != "track-1" || !availability.SkipFallback || availability.Reason != "direct" {
|
||||
t.Fatalf("unexpected availability: %+v", availability)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionURLHandleResult(t *testing.T) {
|
||||
vm := goja.New()
|
||||
value, err := vm.RunString(`({
|
||||
type: "album",
|
||||
name: "Shared Album",
|
||||
coverUrl: "https://img.test/shared.jpg",
|
||||
track: { id: "track-1", name: "Song" },
|
||||
tracks: [{ id: "track-2", name: "Song 2" }],
|
||||
album: { id: "album-1", name: "Album", tracks: [{ id: "track-3", name: "Song 3" }] },
|
||||
artist: { id: "artist-1", name: "Artist", topTracks: [{ id: "track-4", name: "Song 4" }] }
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build URL handle value: %v", err)
|
||||
}
|
||||
|
||||
result, err := parseExtensionURLHandleValue(vm, value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse URL handle: %v", err)
|
||||
}
|
||||
if result.Type != "album" ||
|
||||
result.CoverURL != "https://img.test/shared.jpg" ||
|
||||
result.Track == nil ||
|
||||
result.Track.ID != "track-1" ||
|
||||
len(result.Tracks) != 1 ||
|
||||
result.Album == nil ||
|
||||
len(result.Album.Tracks) != 1 ||
|
||||
result.Artist == nil ||
|
||||
len(result.Artist.TopTracks) != 1 {
|
||||
t.Fatalf("unexpected URL handle result: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionAuxiliaryResults(t *testing.T) {
|
||||
vm := goja.New()
|
||||
|
||||
matchValue, err := vm.RunString(`({ matched: true, trackId: "track-1", confidence: 0.92, reason: "isrc" })`)
|
||||
if err != nil {
|
||||
t.Fatalf("build match value: %v", err)
|
||||
}
|
||||
match := parseExtensionMatchTrackValue(vm, matchValue)
|
||||
if !match.Matched || match.TrackID != "track-1" || match.Confidence != 0.92 || match.Reason != "isrc" {
|
||||
t.Fatalf("unexpected match result: %+v", match)
|
||||
}
|
||||
|
||||
postValue, err := vm.RunString(`({ success: true, newFilePath: "/tmp/new.flac", newFileUri: "content://new", bitDepth: 24, sampleRate: 96000 })`)
|
||||
if err != nil {
|
||||
t.Fatalf("build post-process value: %v", err)
|
||||
}
|
||||
post := parseExtensionPostProcessValue(vm, postValue)
|
||||
if !post.Success || post.NewFilePath != "/tmp/new.flac" || post.NewFileURI != "content://new" || post.BitDepth != 24 || post.SampleRate != 96000 {
|
||||
t.Fatalf("unexpected post-process result: %+v", post)
|
||||
}
|
||||
|
||||
lyricsValue, err := vm.RunString(`({
|
||||
syncType: "LINE_SYNCED",
|
||||
instrumental: false,
|
||||
plainLyrics: "Line",
|
||||
provider: "Lyrics Provider",
|
||||
lines: [{ startTimeMs: 1000, words: "Line", endTimeMs: 2000 }]
|
||||
})`)
|
||||
if err != nil {
|
||||
t.Fatalf("build lyrics value: %v", err)
|
||||
}
|
||||
lyrics, err := parseExtensionLyricsValue(vm, lyricsValue)
|
||||
if err != nil {
|
||||
t.Fatalf("parse lyrics: %v", err)
|
||||
}
|
||||
if lyrics.SyncType != "LINE_SYNCED" ||
|
||||
lyrics.PlainLyrics != "Line" ||
|
||||
lyrics.Provider != "Lyrics Provider" ||
|
||||
len(lyrics.Lines) != 1 ||
|
||||
lyrics.Lines[0].StartTimeMs != 1000 ||
|
||||
lyrics.Lines[0].EndTimeMs != 2000 {
|
||||
t.Fatalf("unexpected lyrics result: %+v", lyrics)
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,38 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// allowPrivateNetworkAccess, when enabled, disables the SSRF guard that blocks
|
||||
// requests resolving to private/local/loopback addresses. This is opt-in and
|
||||
// intended for users who route the app's traffic through a local proxy or
|
||||
// custom DNS (e.g. a local mirror of api.zarz.moe). Disabled by default.
|
||||
var allowPrivateNetworkAccess atomic.Bool
|
||||
|
||||
// SetAllowPrivateNetwork toggles whether extensions and built-in network code
|
||||
// are permitted to reach private/local network targets. Exposed to the Flutter
|
||||
// layer via the platform bridge.
|
||||
func SetAllowPrivateNetwork(allowed bool) {
|
||||
allowPrivateNetworkAccess.Store(allowed)
|
||||
if allowed {
|
||||
GoLog("[HTTP] Private/local network access ENABLED (SSRF guard relaxed)\n")
|
||||
} else {
|
||||
GoLog("[HTTP] Private/local network access disabled (default)\n")
|
||||
}
|
||||
}
|
||||
|
||||
// IsPrivateNetworkAllowed reports the current state of the private-network guard.
|
||||
func IsPrivateNetworkAllowed() bool {
|
||||
return allowPrivateNetworkAccess.Load()
|
||||
}
|
||||
|
||||
const DefaultJSTimeout = 30 * time.Second
|
||||
|
||||
var (
|
||||
@@ -80,14 +105,21 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
||||
state.IsAuthenticated = accessToken != ""
|
||||
}
|
||||
|
||||
type ExtensionRuntime struct {
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
type extensionRuntime struct {
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
downloadClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
|
||||
activeDownloadMu sync.RWMutex
|
||||
activeDownloadItemID string
|
||||
|
||||
activeRequestMu sync.RWMutex
|
||||
activeRequestID string
|
||||
|
||||
storageMu sync.RWMutex
|
||||
storageCache map[string]interface{}
|
||||
@@ -119,10 +151,10 @@ var (
|
||||
privateIPCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
|
||||
jar, _ := newSimpleCookieJar()
|
||||
|
||||
runtime := &ExtensionRuntime{
|
||||
runtime := &extensionRuntime{
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]interface{}),
|
||||
@@ -132,17 +164,131 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
storageFlushDelay: defaultStorageFlushDelay,
|
||||
}
|
||||
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second), true)
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout, false)
|
||||
|
||||
return runtime
|
||||
}
|
||||
|
||||
func extensionHTTPTimeout(ext *loadedExtension, fallback time.Duration) time.Duration {
|
||||
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
raw, ok := ext.Manifest.Capabilities["networkTimeoutSeconds"]
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
|
||||
seconds := parseExtensionTimeoutSeconds(raw)
|
||||
if seconds <= 0 {
|
||||
return fallback
|
||||
}
|
||||
|
||||
if seconds < 5 {
|
||||
seconds = 5
|
||||
}
|
||||
if seconds > 300 {
|
||||
seconds = 300
|
||||
}
|
||||
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func parseExtensionTimeoutSeconds(raw interface{}) int {
|
||||
switch v := raw.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int32:
|
||||
return int(v)
|
||||
case int64:
|
||||
return int(v)
|
||||
case float32:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
|
||||
r.activeDownloadMu.Lock()
|
||||
defer r.activeDownloadMu.Unlock()
|
||||
r.activeDownloadItemID = strings.TrimSpace(itemID)
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) clearActiveDownloadItemID() {
|
||||
r.activeDownloadMu.Lock()
|
||||
defer r.activeDownloadMu.Unlock()
|
||||
r.activeDownloadItemID = ""
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) getActiveDownloadItemID() string {
|
||||
r.activeDownloadMu.RLock()
|
||||
defer r.activeDownloadMu.RUnlock()
|
||||
return r.activeDownloadItemID
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) setActiveRequestID(requestID string) {
|
||||
r.activeRequestMu.Lock()
|
||||
defer r.activeRequestMu.Unlock()
|
||||
r.activeRequestID = strings.TrimSpace(requestID)
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) clearActiveRequestID() {
|
||||
r.activeRequestMu.Lock()
|
||||
defer r.activeRequestMu.Unlock()
|
||||
r.activeRequestID = ""
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) getActiveRequestID() string {
|
||||
r.activeRequestMu.RLock()
|
||||
defer r.activeRequestMu.RUnlock()
|
||||
return r.activeRequestID
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Request {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
itemID := r.getActiveDownloadItemID()
|
||||
if itemID == "" {
|
||||
requestID := r.getActiveRequestID()
|
||||
if requestID == "" {
|
||||
return req
|
||||
}
|
||||
return req.WithContext(initExtensionRequestCancel(requestID))
|
||||
}
|
||||
|
||||
return req.WithContext(initDownloadCancel(itemID))
|
||||
}
|
||||
|
||||
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration, compressResponses bool) *http.Client {
|
||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
||||
// API calls can use response compression for faster metadata/search loads,
|
||||
// while media downloads keep identity transfer semantics for progress/streaming.
|
||||
transport := sharedTransport
|
||||
if compressResponses {
|
||||
transport = extensionAPITransport
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: transport,
|
||||
Timeout: timeout,
|
||||
Jar: jar,
|
||||
}
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" {
|
||||
if req.URL.Scheme != "https" &&
|
||||
!(req.URL.Scheme == "http" && ext.Manifest.Permissions.AllowHTTP) {
|
||||
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
|
||||
return fmt.Errorf("redirect blocked: only https is allowed")
|
||||
}
|
||||
@@ -165,9 +311,7 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
runtime.httpClient = client
|
||||
|
||||
return runtime
|
||||
return client
|
||||
}
|
||||
|
||||
type RedirectBlockedError struct {
|
||||
@@ -183,6 +327,12 @@ func (e *RedirectBlockedError) Error() string {
|
||||
}
|
||||
|
||||
func isPrivateIP(host string) bool {
|
||||
// Opt-in escape hatch: when the user has enabled private/local network
|
||||
// access, treat every host as public so local proxies / custom DNS work.
|
||||
if allowPrivateNetworkAccess.Load() {
|
||||
return false
|
||||
}
|
||||
|
||||
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||||
if hostLower == "" {
|
||||
return false
|
||||
@@ -302,11 +452,11 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
||||
return j.cookies[u.Host]
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
func (r *extensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
r.settings = settings
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
r.vm = vm
|
||||
|
||||
httpObj := vm.NewObject()
|
||||
@@ -345,12 +495,23 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||
vm.Set("auth", authObj)
|
||||
|
||||
if r.manifest != nil && r.manifest.SignedSession != nil {
|
||||
sessionObj := vm.NewObject()
|
||||
sessionObj.Set("signedFetch", r.signedSessionFetch)
|
||||
sessionObj.Set("completeGrant", r.signedSessionCompleteGrant)
|
||||
sessionObj.Set("status", r.signedSessionStatus)
|
||||
sessionObj.Set("clear", r.signedSessionClear)
|
||||
vm.Set("session", sessionObj)
|
||||
}
|
||||
|
||||
fileObj := vm.NewObject()
|
||||
fileObj.Set("download", r.fileDownload)
|
||||
fileObj.Set("exists", r.fileExists)
|
||||
fileObj.Set("delete", r.fileDelete)
|
||||
fileObj.Set("read", r.fileRead)
|
||||
fileObj.Set("readBytes", r.fileReadBytes)
|
||||
fileObj.Set("write", r.fileWrite)
|
||||
fileObj.Set("writeBytes", r.fileWriteBytes)
|
||||
fileObj.Set("copy", r.fileCopy)
|
||||
fileObj.Set("move", r.fileMove)
|
||||
fileObj.Set("getSize", r.fileGetSize)
|
||||
@@ -380,8 +541,17 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
||||
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
|
||||
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
|
||||
utilsObj.Set("decryptCTRSegments", r.decryptCTRSegments)
|
||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||
utilsObj.Set("appVersion", r.appVersion)
|
||||
utilsObj.Set("appUserAgent", r.appUserAgent)
|
||||
utilsObj.Set("sleep", r.sleep)
|
||||
utilsObj.Set("isDownloadCancelled", r.isDownloadCancelled)
|
||||
utilsObj.Set("isRequestCancelled", r.isRequestCancelled)
|
||||
utilsObj.Set("setDownloadStatus", r.setDownloadStatus)
|
||||
vm.Set("utils", utilsObj)
|
||||
|
||||
logObj := vm.NewObject()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides Auth API and PKCE support for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -16,8 +15,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Auth API (OAuth Support) ====================
|
||||
|
||||
func validateExtensionAuthURL(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
@@ -55,7 +52,7 @@ func summarizeURLForLog(urlStr string) string {
|
||||
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -102,7 +99,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
@@ -114,7 +111,7 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(state.AuthCode)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -152,7 +149,7 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.Lock()
|
||||
delete(extensionAuthState, r.extensionID)
|
||||
extensionAuthStateMu.Unlock()
|
||||
@@ -165,7 +162,7 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
@@ -181,7 +178,7 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
|
||||
return r.vm.ToValue(state.IsAuthenticated)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
@@ -204,10 +201,6 @@ func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
// ==================== PKCE Support ====================
|
||||
|
||||
// generatePKCEVerifier generates a cryptographically random code verifier
|
||||
// Length should be between 43-128 characters (RFC 7636)
|
||||
func generatePKCEVerifier(length int) (string, error) {
|
||||
if length < 43 {
|
||||
length = 43
|
||||
@@ -232,11 +225,10 @@ func generatePKCEVerifier(length int) (string, error) {
|
||||
|
||||
func generatePKCEChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
// Base64url encode without padding (RFC 7636)
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
length := 64
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
||||
@@ -273,7 +265,7 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
@@ -289,8 +281,7 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
||||
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -394,10 +385,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
})
|
||||
}
|
||||
|
||||
// authExchangeCodeWithPKCE exchanges auth code for tokens using PKCE
|
||||
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
|
||||
// Uses the stored PKCE verifier automatically
|
||||
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -414,7 +402,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
})
|
||||
}
|
||||
|
||||
// Required fields
|
||||
tokenURL, _ := config["tokenUrl"].(string)
|
||||
clientID, _ := config["clientId"].(string)
|
||||
redirectURI, _ := config["redirectUri"].(string)
|
||||
@@ -471,9 +458,10 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
//lint:ignore SA1019 Blowfish is required for legacy extension crypto compatibility.
|
||||
"golang.org/x/crypto/blowfish"
|
||||
)
|
||||
|
||||
type runtimeBlockCipherOptions struct {
|
||||
Algorithm string
|
||||
Mode string
|
||||
Key []byte
|
||||
IV []byte
|
||||
InputEncoding string
|
||||
OutputEncoding string
|
||||
Padding string
|
||||
}
|
||||
|
||||
func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]interface{} {
|
||||
if len(call.Arguments) <= index {
|
||||
return nil
|
||||
}
|
||||
|
||||
value := call.Arguments[index]
|
||||
if goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return nil
|
||||
}
|
||||
|
||||
exported := value.Export()
|
||||
if options, ok := exported.(map[string]interface{}); ok {
|
||||
return options
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runtimeOptionString(options map[string]interface{}, key, defaultValue string) string {
|
||||
if options == nil {
|
||||
return defaultValue
|
||||
}
|
||||
raw, ok := options[key]
|
||||
if !ok || raw == nil {
|
||||
return defaultValue
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
case []byte:
|
||||
if len(value) > 0 {
|
||||
return string(value)
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func runtimeOptionBool(options map[string]interface{}, key string, defaultValue bool) bool {
|
||||
if options == nil {
|
||||
return defaultValue
|
||||
}
|
||||
raw, ok := options[key]
|
||||
if !ok || raw == nil {
|
||||
return defaultValue
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case bool:
|
||||
return value
|
||||
case int:
|
||||
return value != 0
|
||||
case int64:
|
||||
return value != 0
|
||||
case float64:
|
||||
return value != 0
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func runtimeOptionInt64(options map[string]interface{}, key string, defaultValue int64) int64 {
|
||||
if options == nil {
|
||||
return defaultValue
|
||||
}
|
||||
raw, ok := options[key]
|
||||
if !ok || raw == nil {
|
||||
return defaultValue
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case int:
|
||||
return int64(value)
|
||||
case int32:
|
||||
return int64(value)
|
||||
case int64:
|
||||
return value
|
||||
case float32:
|
||||
return int64(value)
|
||||
case float64:
|
||||
return int64(value)
|
||||
case string:
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
var parsed int64
|
||||
if _, err := fmt.Sscanf(value, "%d", &parsed); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func runtimeOptionHasKey(options map[string]interface{}, key string) bool {
|
||||
if options == nil {
|
||||
return false
|
||||
}
|
||||
_, exists := options[key]
|
||||
return exists
|
||||
}
|
||||
|
||||
func decodeRuntimeBytesString(input, encoding string) ([]byte, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||
case "", "utf8", "utf-8", "text":
|
||||
return []byte(input), nil
|
||||
case "base64":
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(input))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid base64 data: %w", err)
|
||||
}
|
||||
return decoded, nil
|
||||
case "hex":
|
||||
decoded, err := hex.DecodeString(strings.TrimSpace(input))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid hex data: %w", err)
|
||||
}
|
||||
return decoded, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported byte encoding: %s", encoding)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
return decodeRuntimeBytesString(value, encoding)
|
||||
case []byte:
|
||||
cloned := make([]byte, len(value))
|
||||
copy(cloned, value)
|
||||
return cloned, nil
|
||||
case goja.ArrayBuffer:
|
||||
src := value.Bytes()
|
||||
cloned := make([]byte, len(src))
|
||||
copy(cloned, src)
|
||||
return cloned, nil
|
||||
case []interface{}:
|
||||
decoded := make([]byte, len(value))
|
||||
for i, item := range value {
|
||||
switch num := item.(type) {
|
||||
case int:
|
||||
decoded[i] = byte(num)
|
||||
case int64:
|
||||
decoded[i] = byte(num)
|
||||
case float64:
|
||||
decoded[i] = byte(int(num))
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported byte array item at index %d", i)
|
||||
}
|
||||
}
|
||||
return decoded, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported byte payload type")
|
||||
}
|
||||
}
|
||||
|
||||
func encodeRuntimeBytes(data []byte, encoding string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||
case "", "base64":
|
||||
return base64.StdEncoding.EncodeToString(data), nil
|
||||
case "hex":
|
||||
return hex.EncodeToString(data), nil
|
||||
case "utf8", "utf-8", "text":
|
||||
return string(data), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported byte encoding: %s", encoding)
|
||||
}
|
||||
}
|
||||
|
||||
func parseRuntimeBlockCipherOptions(options map[string]interface{}) (*runtimeBlockCipherOptions, error) {
|
||||
parsed := &runtimeBlockCipherOptions{
|
||||
Algorithm: strings.ToLower(runtimeOptionString(options, "algorithm", "")),
|
||||
Mode: strings.ToLower(runtimeOptionString(options, "mode", "cbc")),
|
||||
InputEncoding: strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64")),
|
||||
OutputEncoding: strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64")),
|
||||
Padding: strings.ToLower(runtimeOptionString(options, "padding", "none")),
|
||||
}
|
||||
if parsed.Algorithm == "" {
|
||||
return nil, fmt.Errorf("algorithm is required")
|
||||
}
|
||||
if parsed.Mode == "" {
|
||||
return nil, fmt.Errorf("mode is required")
|
||||
}
|
||||
|
||||
key, err := decodeRuntimeBytesString(runtimeOptionString(options, "key", ""), runtimeOptionString(options, "keyEncoding", "utf8"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid key: %w", err)
|
||||
}
|
||||
if len(key) == 0 {
|
||||
return nil, fmt.Errorf("key is required")
|
||||
}
|
||||
parsed.Key = key
|
||||
|
||||
iv, err := decodeRuntimeBytesString(runtimeOptionString(options, "iv", ""), runtimeOptionString(options, "ivEncoding", "utf8"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid iv: %w", err)
|
||||
}
|
||||
parsed.IV = iv
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func newRuntimeBlockCipher(options *runtimeBlockCipherOptions) (cipher.Block, error) {
|
||||
switch options.Algorithm {
|
||||
case "blowfish":
|
||||
return blowfish.NewCipher(options.Key)
|
||||
case "aes":
|
||||
return aes.NewCipher(options.Key)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported block cipher algorithm: %s", options.Algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
func applyPKCS7Padding(data []byte, blockSize int) []byte {
|
||||
padding := blockSize - (len(data) % blockSize)
|
||||
if padding == 0 {
|
||||
padding = blockSize
|
||||
}
|
||||
out := make([]byte, len(data)+padding)
|
||||
copy(out, data)
|
||||
for i := len(data); i < len(out); i++ {
|
||||
out[i] = byte(padding)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func removePKCS7Padding(data []byte, blockSize int) ([]byte, error) {
|
||||
if len(data) == 0 || len(data)%blockSize != 0 {
|
||||
return nil, fmt.Errorf("invalid padded payload length")
|
||||
}
|
||||
padding := int(data[len(data)-1])
|
||||
if padding <= 0 || padding > blockSize || padding > len(data) {
|
||||
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||
}
|
||||
for i := len(data) - padding; i < len(data); i++ {
|
||||
if int(data[i]) != padding {
|
||||
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||
}
|
||||
}
|
||||
return data[:len(data)-padding], nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt bool) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "data and options are required",
|
||||
})
|
||||
}
|
||||
|
||||
options := parseRuntimeOptionsArgument(call, 1)
|
||||
parsedOptions, err := parseRuntimeBlockCipherOptions(options)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
switch parsedOptions.Mode {
|
||||
case "cbc", "ctr":
|
||||
default:
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
|
||||
})
|
||||
}
|
||||
|
||||
inputData, err := decodeRuntimeBytesValue(call.Arguments[0].Export(), parsedOptions.InputEncoding)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
block, err := newRuntimeBlockCipher(parsedOptions)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if len(parsedOptions.IV) != block.BlockSize() {
|
||||
ivLabel := "iv"
|
||||
if parsedOptions.Mode == "ctr" {
|
||||
ivLabel = "iv (counter)"
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("%s must be %d bytes for %s", ivLabel, block.BlockSize(), parsedOptions.Algorithm),
|
||||
})
|
||||
}
|
||||
|
||||
var output []byte
|
||||
if parsedOptions.Mode == "ctr" {
|
||||
// CTR is a stream mode: encryption and decryption are identical,
|
||||
// require no padding, and accept arbitrary input lengths.
|
||||
output = make([]byte, len(inputData))
|
||||
cipher.NewCTR(block, parsedOptions.IV).XORKeyStream(output, inputData)
|
||||
} else {
|
||||
data := inputData
|
||||
if !decrypt && parsedOptions.Padding == "pkcs7" {
|
||||
data = applyPKCS7Padding(data, block.BlockSize())
|
||||
}
|
||||
if len(data)%block.BlockSize() != 0 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
|
||||
})
|
||||
}
|
||||
|
||||
output = make([]byte, len(data))
|
||||
if decrypt {
|
||||
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||
if parsedOptions.Padding == "pkcs7" {
|
||||
output, err = removePKCS7Padding(output, block.BlockSize())
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||
}
|
||||
}
|
||||
|
||||
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": encoded,
|
||||
"block_size": block.BlockSize(),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value {
|
||||
return r.transformBlockCipher(call, false)
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
|
||||
return r.transformBlockCipher(call, true)
|
||||
}
|
||||
|
||||
// decryptCTRSegments decrypts many independently-IV'd AES-CTR segments inside a
|
||||
// single buffer in one host call. This exists to avoid thousands of JS->Go
|
||||
// bridge crossings when an extension decrypts per-sample CENC media (each
|
||||
// sample has its own IV/counter and cannot be merged into one stream).
|
||||
//
|
||||
// It is a generic primitive: any extension can use it for "one buffer, many
|
||||
// CTR segments" workloads, not just Apple CENC.
|
||||
//
|
||||
// For best performance, pass the buffer as an ArrayBuffer/Uint8Array and set
|
||||
// outputEncoding:"bytes" to get an ArrayBuffer back. This avoids base64
|
||||
// encode/decode of the (potentially multi-MB) payload entirely, which is the
|
||||
// dominant cost under the goja interpreter.
|
||||
//
|
||||
// JS signature:
|
||||
// utils.decryptCTRSegments(data, {
|
||||
// algorithm: "aes", // optional, default "aes"
|
||||
// key: "<hex>", keyEncoding: "hex",
|
||||
// segments: [ { offset: <int>, size: <int>, iv: "<base64>" }, ... ],
|
||||
// ivEncoding: "base64", // encoding of each segment.iv, default base64
|
||||
// inputEncoding: "bytes", // "bytes" for ArrayBuffer/Uint8Array, else base64/hex
|
||||
// outputEncoding: "bytes" // "bytes" -> ArrayBuffer; else base64/hex string
|
||||
// })
|
||||
// Returns { success, data, segments_processed } or { success:false, error }.
|
||||
func (r *extensionRuntime) decryptCTRSegments(call goja.FunctionCall) goja.Value {
|
||||
fail := func(msg string) goja.Value {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
if len(call.Arguments) < 2 {
|
||||
return fail("data and options are required")
|
||||
}
|
||||
|
||||
options := parseRuntimeOptionsArgument(call, 1)
|
||||
if options == nil {
|
||||
return fail("options object is required")
|
||||
}
|
||||
|
||||
algorithm := strings.ToLower(runtimeOptionString(options, "algorithm", "aes"))
|
||||
inputEncoding := strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64"))
|
||||
outputEncoding := strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64"))
|
||||
ivEncoding := strings.ToLower(runtimeOptionString(options, "ivEncoding", "base64"))
|
||||
|
||||
key, err := decodeRuntimeBytesString(
|
||||
runtimeOptionString(options, "key", ""),
|
||||
runtimeOptionString(options, "keyEncoding", "hex"),
|
||||
)
|
||||
if err != nil {
|
||||
return fail(fmt.Sprintf("invalid key: %v", err))
|
||||
}
|
||||
if len(key) == 0 {
|
||||
return fail("key is required")
|
||||
}
|
||||
|
||||
var block cipher.Block
|
||||
switch algorithm {
|
||||
case "aes":
|
||||
block, err = aes.NewCipher(key)
|
||||
case "blowfish":
|
||||
block, err = blowfish.NewCipher(key)
|
||||
default:
|
||||
return fail("unsupported algorithm: " + algorithm)
|
||||
}
|
||||
if err != nil {
|
||||
return fail(err.Error())
|
||||
}
|
||||
blockSize := block.BlockSize()
|
||||
|
||||
// Decode the payload. For "bytes" input we operate on the raw []byte
|
||||
// (ArrayBuffer/Uint8Array) without any base64 round-trip.
|
||||
var data []byte
|
||||
if inputEncoding == "bytes" || inputEncoding == "raw" {
|
||||
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), "")
|
||||
if err != nil {
|
||||
return fail("invalid byte payload: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), inputEncoding)
|
||||
if err != nil {
|
||||
return fail(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
rawSegments, ok := options["segments"]
|
||||
if !ok || rawSegments == nil {
|
||||
return fail("segments array is required")
|
||||
}
|
||||
segments, ok := rawSegments.([]interface{})
|
||||
if !ok {
|
||||
return fail("segments must be an array")
|
||||
}
|
||||
|
||||
processed := 0
|
||||
for i, rawSeg := range segments {
|
||||
seg, ok := rawSeg.(map[string]interface{})
|
||||
if !ok {
|
||||
return fail(fmt.Sprintf("segment %d is not an object", i))
|
||||
}
|
||||
|
||||
offset := int(runtimeOptionInt64(seg, "offset", -1))
|
||||
size := int(runtimeOptionInt64(seg, "size", -1))
|
||||
if offset < 0 || size < 0 {
|
||||
return fail(fmt.Sprintf("segment %d has invalid offset/size", i))
|
||||
}
|
||||
if size == 0 {
|
||||
continue
|
||||
}
|
||||
if offset+size > len(data) {
|
||||
return fail(fmt.Sprintf("segment %d out of bounds (offset=%d size=%d len=%d)", i, offset, size, len(data)))
|
||||
}
|
||||
|
||||
iv, err := decodeRuntimeBytesString(runtimeOptionString(seg, "iv", ""), ivEncoding)
|
||||
if err != nil {
|
||||
return fail(fmt.Sprintf("segment %d has invalid iv: %v", i, err))
|
||||
}
|
||||
if len(iv) != blockSize {
|
||||
// Accept short IVs by left-aligning into a block-sized counter
|
||||
// (CENC commonly uses 8-byte IVs for a 16-byte AES counter).
|
||||
if len(iv) > blockSize {
|
||||
return fail(fmt.Sprintf("segment %d iv longer than block size (%d > %d)", i, len(iv), blockSize))
|
||||
}
|
||||
padded := make([]byte, blockSize)
|
||||
copy(padded, iv)
|
||||
iv = padded
|
||||
}
|
||||
|
||||
segData := data[offset : offset+size]
|
||||
cipher.NewCTR(block, iv).XORKeyStream(segData, segData)
|
||||
processed++
|
||||
}
|
||||
|
||||
// Return raw bytes as an ArrayBuffer when requested (zero-copy-ish, no
|
||||
// base64). Otherwise fall back to an encoded string.
|
||||
if outputEncoding == "bytes" || outputEncoding == "raw" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": r.vm.NewArrayBuffer(data),
|
||||
"segments_processed": processed,
|
||||
})
|
||||
}
|
||||
|
||||
encoded, err := encodeRuntimeBytes(data, outputEncoding)
|
||||
if err != nil {
|
||||
return fail(err.Error())
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": encoded,
|
||||
"segments_processed": processed,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func newBinaryTestRuntime(t *testing.T, withFilePermission bool) *goja.Runtime {
|
||||
t.Helper()
|
||||
|
||||
ext := &loadedExtension{
|
||||
ID: "binary-test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "binary-test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: withFilePermission,
|
||||
},
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := newExtensionRuntime(ext)
|
||||
vm := goja.New()
|
||||
runtime.RegisterAPIs(vm)
|
||||
return vm
|
||||
}
|
||||
|
||||
func decodeJSONResult[T any](t *testing.T, value goja.Value) T {
|
||||
t.Helper()
|
||||
|
||||
var decoded T
|
||||
if err := json.Unmarshal([]byte(value.String()), &decoded); err != nil {
|
||||
t.Fatalf("failed to decode JSON result: %v", err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_FileByteAPIs(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, true)
|
||||
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var first = file.writeBytes("bytes.bin", "AAEC", {encoding: "base64", truncate: true});
|
||||
if (!first.success) throw new Error(first.error);
|
||||
|
||||
var second = file.writeBytes("bytes.bin", "0304ff", {encoding: "hex", append: true});
|
||||
if (!second.success) throw new Error(second.error);
|
||||
|
||||
var all = file.readBytes("bytes.bin", {encoding: "hex"});
|
||||
if (!all.success) throw new Error(all.error);
|
||||
|
||||
var slice = file.readBytes("bytes.bin", {offset: 2, length: 2, encoding: "hex"});
|
||||
if (!slice.success) throw new Error(slice.error);
|
||||
|
||||
var tail = file.readBytes("bytes.bin", {offset: 6, length: 4, encoding: "hex"});
|
||||
if (!tail.success) throw new Error(tail.error);
|
||||
|
||||
return JSON.stringify({
|
||||
all: all.data,
|
||||
slice: slice.data,
|
||||
size: all.size,
|
||||
sliceBytes: slice.bytes_read,
|
||||
sliceEof: slice.eof,
|
||||
tailBytes: tail.bytes_read,
|
||||
tailEof: tail.eof
|
||||
});
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("file byte APIs failed: %v", err)
|
||||
}
|
||||
|
||||
decoded := decodeJSONResult[struct {
|
||||
All string `json:"all"`
|
||||
Slice string `json:"slice"`
|
||||
Size int64 `json:"size"`
|
||||
SliceBytes int `json:"sliceBytes"`
|
||||
SliceEof bool `json:"sliceEof"`
|
||||
TailBytes int `json:"tailBytes"`
|
||||
TailEof bool `json:"tailEof"`
|
||||
}](t, result)
|
||||
|
||||
if decoded.All != "0001020304ff" {
|
||||
t.Fatalf("all = %q", decoded.All)
|
||||
}
|
||||
if decoded.Slice != "0203" {
|
||||
t.Fatalf("slice = %q", decoded.Slice)
|
||||
}
|
||||
if decoded.Size != 6 {
|
||||
t.Fatalf("size = %d", decoded.Size)
|
||||
}
|
||||
if decoded.SliceBytes != 2 {
|
||||
t.Fatalf("slice bytes = %d", decoded.SliceBytes)
|
||||
}
|
||||
if decoded.SliceEof {
|
||||
t.Fatal("slice should not be EOF")
|
||||
}
|
||||
if decoded.TailBytes != 0 || !decoded.TailEof {
|
||||
t.Fatalf("tail read mismatch: bytes=%d eof=%v", decoded.TailBytes, decoded.TailEof)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BlockCipherCBCSupportsBlowfish(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var options = {
|
||||
algorithm: "blowfish",
|
||||
mode: "cbc",
|
||||
key: "0123456789ABCDEFF0E1D2C3B4A59687",
|
||||
keyEncoding: "hex",
|
||||
iv: "0001020304050607",
|
||||
ivEncoding: "hex",
|
||||
inputEncoding: "hex",
|
||||
outputEncoding: "hex",
|
||||
padding: "none"
|
||||
};
|
||||
var enc = utils.encryptBlockCipher("00112233445566778899aabbccddeeff", options);
|
||||
if (!enc.success) throw new Error(enc.error);
|
||||
var dec = utils.decryptBlockCipher(enc.data, options);
|
||||
if (!dec.success) throw new Error(dec.error);
|
||||
return JSON.stringify({enc: enc.data, dec: dec.data});
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("blowfish block cipher failed: %v", err)
|
||||
}
|
||||
|
||||
decoded := decodeJSONResult[struct {
|
||||
Enc string `json:"enc"`
|
||||
Dec string `json:"dec"`
|
||||
}](t, result)
|
||||
|
||||
if decoded.Dec != "00112233445566778899aabbccddeeff" {
|
||||
t.Fatalf("dec = %q", decoded.Dec)
|
||||
}
|
||||
if decoded.Enc == decoded.Dec {
|
||||
t.Fatal("expected ciphertext to differ from plaintext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var options = {
|
||||
algorithm: "aes",
|
||||
mode: "cbc",
|
||||
key: "000102030405060708090a0b0c0d0e0f",
|
||||
keyEncoding: "hex",
|
||||
iv: "0f0e0d0c0b0a09080706050403020100",
|
||||
ivEncoding: "hex",
|
||||
inputEncoding: "utf8",
|
||||
outputEncoding: "base64",
|
||||
padding: "pkcs7"
|
||||
};
|
||||
var enc = utils.encryptBlockCipher("hello generic cbc", options);
|
||||
if (!enc.success) throw new Error(enc.error);
|
||||
var dec = utils.decryptBlockCipher(enc.data, {
|
||||
algorithm: "aes",
|
||||
mode: "cbc",
|
||||
key: options.key,
|
||||
keyEncoding: options.keyEncoding,
|
||||
iv: options.iv,
|
||||
ivEncoding: options.ivEncoding,
|
||||
inputEncoding: "base64",
|
||||
outputEncoding: "utf8",
|
||||
padding: "pkcs7"
|
||||
});
|
||||
if (!dec.success) throw new Error(dec.error);
|
||||
return dec.data;
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("aes block cipher failed: %v", err)
|
||||
}
|
||||
|
||||
if result.String() != "hello generic cbc" {
|
||||
t.Fatalf("unexpected decrypted value: %q", result.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BlockCipherCTRSupportsAES(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
// NIST SP 800-38A, F.5.1 CTR-AES128.Encrypt test vector.
|
||||
// Key: 2b7e151628aed2a6abf7158809cf4f3c
|
||||
// Counter: f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff
|
||||
// Plaintext: 6bc1bee22e409f96e93d7e117393172a (block 1)
|
||||
// Ciphertext: 874d6191b620e3261bef6864990db6ce (block 1)
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var options = {
|
||||
algorithm: "aes",
|
||||
mode: "ctr",
|
||||
key: "2b7e151628aed2a6abf7158809cf4f3c",
|
||||
keyEncoding: "hex",
|
||||
iv: "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff",
|
||||
ivEncoding: "hex",
|
||||
inputEncoding: "hex",
|
||||
outputEncoding: "hex"
|
||||
};
|
||||
var enc = utils.encryptBlockCipher("6bc1bee22e409f96e93d7e117393172a", options);
|
||||
if (!enc.success) throw new Error(enc.error);
|
||||
// CTR is symmetric: decrypt is the same transform as encrypt.
|
||||
var dec = utils.decryptBlockCipher(enc.data, options);
|
||||
if (!dec.success) throw new Error(dec.error);
|
||||
return JSON.stringify({enc: enc.data, dec: dec.data});
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("aes ctr block cipher failed: %v", err)
|
||||
}
|
||||
|
||||
decoded := decodeJSONResult[struct {
|
||||
Enc string `json:"enc"`
|
||||
Dec string `json:"dec"`
|
||||
}](t, result)
|
||||
|
||||
if decoded.Enc != "874d6191b620e3261bef6864990db6ce" {
|
||||
t.Fatalf("ctr ciphertext = %q, want NIST vector 874d6191b620e3261bef6864990db6ce", decoded.Enc)
|
||||
}
|
||||
if decoded.Dec != "6bc1bee22e409f96e93d7e117393172a" {
|
||||
t.Fatalf("ctr round-trip dec = %q", decoded.Dec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BlockCipherCTRHandlesNonBlockLength(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
// CTR is a stream mode, so arbitrary (non-16-byte-aligned) input lengths
|
||||
// must round-trip without any padding.
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var options = {
|
||||
algorithm: "aes",
|
||||
mode: "ctr",
|
||||
key: "000102030405060708090a0b0c0d0e0f",
|
||||
keyEncoding: "hex",
|
||||
iv: "0f0e0d0c0b0a09080706050403020100",
|
||||
ivEncoding: "hex",
|
||||
inputEncoding: "utf8",
|
||||
outputEncoding: "base64"
|
||||
};
|
||||
var enc = utils.encryptBlockCipher("stream ctr of odd length", options);
|
||||
if (!enc.success) throw new Error(enc.error);
|
||||
var dec = utils.decryptBlockCipher(enc.data, {
|
||||
algorithm: "aes",
|
||||
mode: "ctr",
|
||||
key: options.key,
|
||||
keyEncoding: options.keyEncoding,
|
||||
iv: options.iv,
|
||||
ivEncoding: options.ivEncoding,
|
||||
inputEncoding: "base64",
|
||||
outputEncoding: "utf8"
|
||||
});
|
||||
if (!dec.success) throw new Error(dec.error);
|
||||
return dec.data;
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("aes ctr stream length failed: %v", err)
|
||||
}
|
||||
|
||||
if result.String() != "stream ctr of odd length" {
|
||||
t.Fatalf("unexpected ctr decrypted value: %q", result.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BlockCipherCTRRejectsBadIV(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var res = utils.encryptBlockCipher("00112233", {
|
||||
algorithm: "aes",
|
||||
mode: "ctr",
|
||||
key: "000102030405060708090a0b0c0d0e0f",
|
||||
keyEncoding: "hex",
|
||||
iv: "0001",
|
||||
ivEncoding: "hex",
|
||||
inputEncoding: "hex",
|
||||
outputEncoding: "hex"
|
||||
});
|
||||
return JSON.stringify({success: res.success, error: res.error || ""});
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("aes ctr bad iv eval failed: %v", err)
|
||||
}
|
||||
|
||||
decoded := decodeJSONResult[struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
}](t, result)
|
||||
|
||||
if decoded.Success {
|
||||
t.Fatal("expected failure for undersized CTR iv")
|
||||
}
|
||||
if decoded.Error == "" {
|
||||
t.Fatal("expected error message for undersized CTR iv")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_DecryptCTRSegmentsMatchesPerSegment(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
// Build a buffer of 3 segments encrypted with distinct 8-byte IVs (CENC
|
||||
// style), then verify the batch primitive decrypts all of them in one call,
|
||||
// matching what per-segment decryptBlockCipher would produce.
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var keyHex = "000102030405060708090a0b0c0d0e0f";
|
||||
function b64(bytes){return utils.base64Encode(utils.toHex ? bytes : bytes);}
|
||||
|
||||
// segment plaintexts (hex) and 8-byte IVs (hex)
|
||||
var segs = [
|
||||
{ pt: "11111111111111111111", iv: "0000000000000001" },
|
||||
{ pt: "2222222222", iv: "0000000000000002" },
|
||||
{ pt: "333333333333333333333333", iv: "00000000000000ff" }
|
||||
];
|
||||
|
||||
// Encrypt each segment individually using single-shot CTR with a
|
||||
// 16-byte counter (8-byte iv left-aligned), producing ciphertext hex.
|
||||
function ivToB64(ivHex){
|
||||
// pad 8-byte hex iv to 16 bytes then base64
|
||||
var full = ivHex + "00000000000000000000000000000000".slice(ivHex.length);
|
||||
return utils.base64Encode(utils.hexToBytes ? utils.hexToBytes(full) : full);
|
||||
}
|
||||
|
||||
var cipherHex = "";
|
||||
var offsets = [];
|
||||
var off = 0;
|
||||
var ivB64s = [];
|
||||
for (var i=0;i<segs.length;i++){
|
||||
var ivFullHex = (segs[i].iv + "00000000000000000000000000000000").slice(0,32);
|
||||
var enc = utils.encryptBlockCipher(segs[i].pt, {
|
||||
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
|
||||
iv: ivFullHex, ivEncoding:"hex",
|
||||
inputEncoding:"hex", outputEncoding:"hex"
|
||||
});
|
||||
if(!enc.success) throw new Error("enc seg "+i+": "+enc.error);
|
||||
cipherHex += enc.data;
|
||||
var sz = segs[i].pt.length/2;
|
||||
offsets.push({offset: off, size: sz, ivHex: ivFullHex});
|
||||
off += sz;
|
||||
}
|
||||
|
||||
// Now decrypt the whole concatenated buffer in ONE batch call.
|
||||
var segments = offsets.map(function(o){
|
||||
return { offset:o.offset, size:o.size, iv:o.ivHex };
|
||||
});
|
||||
var batch = utils.decryptCTRSegments(cipherHex, {
|
||||
algorithm:"aes", key:keyHex, keyEncoding:"hex",
|
||||
segments: segments, ivEncoding:"hex",
|
||||
inputEncoding:"hex", outputEncoding:"hex"
|
||||
});
|
||||
if(!batch.success) throw new Error("batch: "+batch.error);
|
||||
|
||||
var expected = "";
|
||||
for (var j=0;j<segs.length;j++) expected += segs[j].pt;
|
||||
|
||||
return JSON.stringify({
|
||||
out: batch.data,
|
||||
expected: expected,
|
||||
processed: batch.segments_processed
|
||||
});
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("batch CTR eval failed: %v", err)
|
||||
}
|
||||
|
||||
decoded := decodeJSONResult[struct {
|
||||
Out string `json:"out"`
|
||||
Expected string `json:"expected"`
|
||||
Processed int `json:"processed"`
|
||||
}](t, result)
|
||||
|
||||
if decoded.Out != decoded.Expected {
|
||||
t.Fatalf("batch decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
|
||||
}
|
||||
if decoded.Processed != 3 {
|
||||
t.Fatalf("segments_processed = %d, want 3", decoded.Processed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_DecryptCTRSegmentsRejectsOutOfBounds(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var res = utils.decryptCTRSegments("00112233", {
|
||||
algorithm:"aes", key:"000102030405060708090a0b0c0d0e0f", keyEncoding:"hex",
|
||||
inputEncoding:"hex", outputEncoding:"hex",
|
||||
ivEncoding:"hex",
|
||||
segments: [ { offset: 0, size: 99, iv: "00000000000000000000000000000000" } ]
|
||||
});
|
||||
return JSON.stringify({ success: res.success, error: res.error || "" });
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("oob eval failed: %v", err)
|
||||
}
|
||||
|
||||
decoded := decodeJSONResult[struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
}](t, result)
|
||||
|
||||
if decoded.Success {
|
||||
t.Fatal("expected out-of-bounds segment to fail")
|
||||
}
|
||||
if decoded.Error == "" {
|
||||
t.Fatal("expected error message for out-of-bounds segment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_DecryptCTRSegmentsRawBytes(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
// Verify the zero-base64 path: pass an ArrayBuffer in, request bytes out,
|
||||
// and confirm round-trip correctness against single-shot CTR.
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var keyHex = "000102030405060708090a0b0c0d0e0f";
|
||||
var ivFullHex = "0000000000000001" + "00000000000000000000000000000000".slice(16);
|
||||
|
||||
// Plaintext as a Uint8Array of 20 bytes.
|
||||
var pt = new Uint8Array(20);
|
||||
for (var i = 0; i < pt.length; i++) pt[i] = (i * 7 + 3) & 0xff;
|
||||
|
||||
// Encrypt single-shot to get ciphertext (hex output for clarity).
|
||||
var ptHex = "";
|
||||
for (var j = 0; j < pt.length; j++) { var h = pt[j].toString(16); ptHex += (h.length === 1 ? "0" : "") + h; }
|
||||
var enc = utils.encryptBlockCipher(ptHex, {
|
||||
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
|
||||
iv: ivFullHex, ivEncoding:"hex", inputEncoding:"hex", outputEncoding:"base64"
|
||||
});
|
||||
if (!enc.success) throw new Error("enc: " + enc.error);
|
||||
|
||||
// Decode ciphertext base64 into a Uint8Array to feed the raw path.
|
||||
var cipherBytes = utils.base64Decode ? null : null;
|
||||
// Build ArrayBuffer from base64 via Uint8Array manually:
|
||||
var b64 = enc.data;
|
||||
var bin = (typeof atob === "function") ? null : null;
|
||||
|
||||
// Simpler: ask the host to give us bytes by decrypting nothing is hard,
|
||||
// so just pass the base64 ciphertext through decryptCTRSegments using
|
||||
// base64 input but bytes output, then re-run with bytes input.
|
||||
var step1 = utils.decryptCTRSegments(b64, {
|
||||
algorithm:"aes", key:keyHex, keyEncoding:"hex",
|
||||
segments: [ { offset:0, size:20, iv: ivFullHex } ],
|
||||
ivEncoding:"hex", inputEncoding:"base64", outputEncoding:"bytes"
|
||||
});
|
||||
if (!step1.success) throw new Error("step1: " + step1.error);
|
||||
if (typeof step1.data === "string") throw new Error("expected ArrayBuffer output, got string");
|
||||
|
||||
var outArr = new Uint8Array(step1.data);
|
||||
var outHex = "";
|
||||
for (var k = 0; k < outArr.length; k++) { var hh = outArr[k].toString(16); outHex += (hh.length === 1 ? "0" : "") + hh; }
|
||||
return JSON.stringify({ out: outHex, expected: ptHex, len: outArr.length });
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("raw-bytes eval failed: %v", err)
|
||||
}
|
||||
|
||||
decoded := decodeJSONResult[struct {
|
||||
Out string `json:"out"`
|
||||
Expected string `json:"expected"`
|
||||
Len int `json:"len"`
|
||||
}](t, result)
|
||||
|
||||
if decoded.Out != decoded.Expected {
|
||||
t.Fatalf("raw-bytes decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
|
||||
}
|
||||
if decoded.Len != 20 {
|
||||
t.Fatalf("output length = %d, want 20", decoded.Len)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides FFmpeg API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -10,9 +9,7 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== FFmpeg API (Post-Processing) ====================
|
||||
|
||||
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute
|
||||
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute.
|
||||
type FFmpegCommand struct {
|
||||
ExtensionID string
|
||||
Command string
|
||||
@@ -24,7 +21,6 @@ type FFmpegCommand struct {
|
||||
Output string
|
||||
}
|
||||
|
||||
// Global FFmpeg command queue
|
||||
var (
|
||||
ffmpegCommands = make(map[string]*FFmpegCommand)
|
||||
ffmpegCommandsMu sync.RWMutex
|
||||
@@ -54,7 +50,7 @@ func ClearFFmpegCommand(commandID string) {
|
||||
delete(ffmpegCommands, commandID)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -111,7 +107,7 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -135,10 +131,11 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
"sample_rate": quality.SampleRate,
|
||||
"total_samples": quality.TotalSamples,
|
||||
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
|
||||
"codec": quality.Codec,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides File API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -9,12 +8,11 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== File API (Sandboxed) ====================
|
||||
|
||||
var (
|
||||
allowedDownloadDirs []string
|
||||
allowedDownloadDirsMu sync.RWMutex
|
||||
@@ -74,7 +72,7 @@ func isPathWithinBase(baseDir, targetPath string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
func (r *extensionRuntime) validatePath(path string) (string, error) {
|
||||
if !r.manifest.Permissions.File {
|
||||
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||
}
|
||||
@@ -109,7 +107,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -137,6 +135,9 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
|
||||
var onProgress goja.Callable
|
||||
var headers map[string]string
|
||||
var chunkedDownload bool
|
||||
trackItemBytes := true
|
||||
var chunkSize int64
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
optionsObj := call.Arguments[2].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
@@ -151,9 +152,39 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
onProgress = callable
|
||||
}
|
||||
}
|
||||
if trackBytes, ok := opts["trackItemBytes"]; ok {
|
||||
if v, ok := trackBytes.(bool); ok {
|
||||
trackItemBytes = v
|
||||
}
|
||||
} else if trackBytes, ok := opts["track_item_bytes"]; ok {
|
||||
if v, ok := trackBytes.(bool); ok {
|
||||
trackItemBytes = v
|
||||
}
|
||||
}
|
||||
if chunked, ok := opts["chunked"]; ok {
|
||||
switch v := chunked.(type) {
|
||||
case bool:
|
||||
chunkedDownload = v
|
||||
case int64:
|
||||
if v > 0 {
|
||||
chunkedDownload = true
|
||||
chunkSize = v
|
||||
}
|
||||
case float64:
|
||||
if v > 0 {
|
||||
chunkedDownload = true
|
||||
chunkSize = int64(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default chunk size: 1MB (YouTube CDN max without poToken)
|
||||
if chunkedDownload && chunkSize <= 0 {
|
||||
chunkSize = 1024 * 1024
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -162,6 +193,20 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
client := r.downloadClient
|
||||
if client == nil {
|
||||
client = r.httpClient
|
||||
}
|
||||
|
||||
ua := appUserAgent()
|
||||
if h, ok := headers["User-Agent"]; ok && h != "" {
|
||||
ua = h
|
||||
}
|
||||
|
||||
if chunkedDownload {
|
||||
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress, trackItemBytes)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -169,15 +214,16 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -186,7 +232,7 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
|
||||
@@ -202,14 +248,28 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
activeItemID := r.getActiveDownloadItemID()
|
||||
if activeItemID != "" {
|
||||
SetItemDownloading(activeItemID)
|
||||
}
|
||||
|
||||
contentLength := resp.ContentLength
|
||||
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
|
||||
if shouldTrackItemBytes && contentLength > 0 {
|
||||
SetItemBytesTotal(activeItemID, contentLength)
|
||||
}
|
||||
|
||||
var progressWriter interface{ Write([]byte) (int, error) } = out
|
||||
if shouldTrackItemBytes {
|
||||
progressWriter = NewItemProgressWriter(out, activeItemID)
|
||||
}
|
||||
|
||||
var written int64
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
nr, er := resp.Body.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, ew := out.Write(buf[0:nr])
|
||||
nw, ew := progressWriter.Write(buf[0:nr])
|
||||
if nw < 0 || nr < nw {
|
||||
nw = 0
|
||||
if ew == nil {
|
||||
@@ -218,6 +278,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
written += int64(nw)
|
||||
if ew != nil {
|
||||
if ew == ErrDownloadCancelled {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "download cancelled",
|
||||
})
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to write file: %v", ew),
|
||||
@@ -245,6 +311,14 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
if shouldTrackItemBytes {
|
||||
if contentLength > 0 {
|
||||
SetItemProgress(activeItemID, float64(written)/float64(contentLength), written, contentLength)
|
||||
} else if written > 0 {
|
||||
SetItemBytesReceived(activeItemID, written)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -254,7 +328,237 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
// fileDownloadChunked downloads a URL using sequential Range requests.
|
||||
// This is needed for servers (like YouTube's googlevideo CDN) that reject
|
||||
// non-ranged or large-range requests with 403 and require small chunk downloads.
|
||||
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable, trackItemBytes bool) goja.Value {
|
||||
// First, get the total content length with a small probe request
|
||||
probeReq, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: probe request error: %v", err),
|
||||
})
|
||||
}
|
||||
probeReq = r.bindDownloadCancelContext(probeReq)
|
||||
probeReq.Header.Set("User-Agent", ua)
|
||||
for k, v := range headers {
|
||||
if k != "Range" { // Don't copy any existing Range header
|
||||
probeReq.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
probeReq.Header.Set("Range", "bytes=0-1")
|
||||
|
||||
probeResp, err := client.Do(probeReq)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: probe error: %v", err),
|
||||
})
|
||||
}
|
||||
io.Copy(io.Discard, probeResp.Body)
|
||||
probeResp.Body.Close()
|
||||
|
||||
if probeResp.StatusCode != 206 && probeResp.StatusCode != 200 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: probe HTTP %d", probeResp.StatusCode),
|
||||
})
|
||||
}
|
||||
|
||||
// Parse Content-Range to get total size: "bytes 0-1/TOTAL"
|
||||
var totalSize int64
|
||||
contentRange := probeResp.Header.Get("Content-Range")
|
||||
if contentRange != "" {
|
||||
if idx := strings.LastIndex(contentRange, "/"); idx >= 0 {
|
||||
sizeStr := contentRange[idx+1:]
|
||||
if sizeStr != "*" {
|
||||
fmt.Sscanf(sizeStr, "%d", &totalSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalSize <= 0 {
|
||||
// Fallback: try Content-Length from a HEAD-like approach
|
||||
// If we can't determine size, download with unknown size
|
||||
GoLog("[Extension:%s] Chunked download: unknown total size, will download until server says done\n", r.extensionID)
|
||||
} else {
|
||||
GoLog("[Extension:%s] Chunked download: total size %d bytes, chunk size %d\n", r.extensionID, totalSize, chunkSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create file: %v", err),
|
||||
})
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
activeItemID := r.getActiveDownloadItemID()
|
||||
if activeItemID != "" {
|
||||
SetItemDownloading(activeItemID)
|
||||
}
|
||||
|
||||
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
|
||||
if shouldTrackItemBytes && totalSize > 0 {
|
||||
SetItemBytesTotal(activeItemID, totalSize)
|
||||
}
|
||||
|
||||
var progressWriter interface{ Write([]byte) (int, error) } = out
|
||||
if shouldTrackItemBytes {
|
||||
progressWriter = NewItemProgressWriter(out, activeItemID)
|
||||
}
|
||||
|
||||
var totalWritten int64
|
||||
buf := make([]byte, 32*1024)
|
||||
maxRetries := 3
|
||||
|
||||
for offset := int64(0); totalSize <= 0 || offset < totalSize; {
|
||||
end := offset + chunkSize - 1
|
||||
if totalSize > 0 && end >= totalSize {
|
||||
end = totalSize - 1
|
||||
}
|
||||
|
||||
var chunkResp *http.Response
|
||||
var chunkErr error
|
||||
|
||||
for retry := 0; retry < maxRetries; retry++ {
|
||||
chunkReq, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: request error at offset %d: %v", offset, err),
|
||||
})
|
||||
}
|
||||
chunkReq = r.bindDownloadCancelContext(chunkReq)
|
||||
chunkReq.Header.Set("User-Agent", ua)
|
||||
for k, v := range headers {
|
||||
if k != "Range" {
|
||||
chunkReq.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
chunkReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", offset, end))
|
||||
|
||||
chunkResp, chunkErr = client.Do(chunkReq)
|
||||
if chunkErr != nil {
|
||||
if retry < maxRetries-1 {
|
||||
time.Sleep(time.Duration(retry+1) * time.Second)
|
||||
continue
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: error at offset %d after %d retries: %v", offset, maxRetries, chunkErr),
|
||||
})
|
||||
}
|
||||
|
||||
if chunkResp.StatusCode == 206 || chunkResp.StatusCode == 200 {
|
||||
break // Success
|
||||
}
|
||||
|
||||
io.Copy(io.Discard, chunkResp.Body)
|
||||
chunkResp.Body.Close()
|
||||
|
||||
if chunkResp.StatusCode == 403 || chunkResp.StatusCode == 429 {
|
||||
if retry < maxRetries-1 {
|
||||
time.Sleep(time.Duration(retry+1) * 2 * time.Second)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("chunked: HTTP %d at offset %d", chunkResp.StatusCode, offset),
|
||||
})
|
||||
}
|
||||
|
||||
chunkWritten := int64(0)
|
||||
for {
|
||||
nr, er := chunkResp.Body.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, ew := progressWriter.Write(buf[0:nr])
|
||||
if nw < 0 || nr < nw {
|
||||
nw = 0
|
||||
if ew == nil {
|
||||
ew = fmt.Errorf("invalid write result")
|
||||
}
|
||||
}
|
||||
chunkWritten += int64(nw)
|
||||
totalWritten += int64(nw)
|
||||
if ew != nil {
|
||||
chunkResp.Body.Close()
|
||||
if ew == ErrDownloadCancelled {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "download cancelled",
|
||||
})
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to write file: %v", ew),
|
||||
})
|
||||
}
|
||||
if nr != nw {
|
||||
chunkResp.Body.Close()
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "short write",
|
||||
})
|
||||
}
|
||||
|
||||
if onProgress != nil && totalSize > 0 {
|
||||
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(totalWritten), r.vm.ToValue(totalSize))
|
||||
}
|
||||
}
|
||||
if er != nil {
|
||||
if er != io.EOF {
|
||||
chunkResp.Body.Close()
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read chunk at offset %d: %v", offset, er),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
chunkResp.Body.Close()
|
||||
|
||||
offset += chunkWritten
|
||||
|
||||
// If server returned 200 (full content) instead of 206, we're done
|
||||
if chunkResp.StatusCode == 200 {
|
||||
break
|
||||
}
|
||||
|
||||
// If we got less data than expected and we know total size, check if done
|
||||
if totalSize > 0 && offset >= totalSize {
|
||||
break
|
||||
}
|
||||
|
||||
// Unknown size: if we got less than chunk size, assume done
|
||||
if totalSize <= 0 && chunkWritten < chunkSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if shouldTrackItemBytes {
|
||||
if totalSize > 0 {
|
||||
SetItemProgress(activeItemID, float64(totalWritten)/float64(totalSize), totalWritten, totalSize)
|
||||
} else if totalWritten > 0 {
|
||||
SetItemBytesReceived(activeItemID, totalWritten)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension:%s] Chunked download complete: %d bytes to %s\n", r.extensionID, totalWritten, fullPath)
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullPath,
|
||||
"size": totalWritten,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -269,7 +573,7 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(err == nil)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -298,7 +602,7 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -329,7 +633,117 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path is required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
options := parseRuntimeOptionsArgument(call, 1)
|
||||
offset := runtimeOptionInt64(options, "offset", 0)
|
||||
length := runtimeOptionInt64(options, "length", -1)
|
||||
encoding := runtimeOptionString(options, "encoding", "base64")
|
||||
if offset < 0 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "offset must be >= 0",
|
||||
})
|
||||
}
|
||||
file, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
size := info.Size()
|
||||
if offset > size {
|
||||
offset = size
|
||||
}
|
||||
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to seek file: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
var data []byte
|
||||
switch {
|
||||
case length == 0:
|
||||
data = []byte{}
|
||||
case length > 0:
|
||||
buf := make([]byte, int(length))
|
||||
n, readErr := file.Read(buf)
|
||||
if readErr != nil && readErr != io.EOF {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read file: %v", readErr),
|
||||
})
|
||||
}
|
||||
data = buf[:n]
|
||||
default:
|
||||
data, err = io.ReadAll(file)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read file: %v", err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(encoding), "bytes") ||
|
||||
strings.EqualFold(strings.TrimSpace(encoding), "raw") {
|
||||
// Return raw bytes as an ArrayBuffer to avoid base64 encode/decode of
|
||||
// large payloads under the goja interpreter.
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": r.vm.NewArrayBuffer(data),
|
||||
"bytes_read": len(data),
|
||||
"offset": offset,
|
||||
"size": size,
|
||||
"eof": offset+int64(len(data)) >= size,
|
||||
})
|
||||
}
|
||||
|
||||
encoded, err := encodeRuntimeBytes(data, encoding)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": encoded,
|
||||
"bytes_read": len(data),
|
||||
"offset": offset,
|
||||
"size": size,
|
||||
"eof": offset+int64(len(data)) >= size,
|
||||
})
|
||||
}
|
||||
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -369,7 +783,108 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileWriteBytes(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path and data are required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
options := parseRuntimeOptionsArgument(call, 2)
|
||||
appendMode := runtimeOptionBool(options, "append", false)
|
||||
truncate := runtimeOptionBool(options, "truncate", false)
|
||||
hasOffset := runtimeOptionHasKey(options, "offset")
|
||||
offset := runtimeOptionInt64(options, "offset", 0)
|
||||
encoding := runtimeOptionString(options, "encoding", "base64")
|
||||
|
||||
if appendMode && hasOffset {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "append and offset cannot be used together",
|
||||
})
|
||||
}
|
||||
if offset < 0 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "offset must be >= 0",
|
||||
})
|
||||
}
|
||||
|
||||
data, err := decodeRuntimeBytesValue(call.Arguments[1].Export(), encoding)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
flags := os.O_CREATE | os.O_WRONLY
|
||||
if appendMode {
|
||||
flags |= os.O_APPEND
|
||||
}
|
||||
if truncate {
|
||||
flags |= os.O_TRUNC
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(fullPath, flags, 0644)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if hasOffset && !appendMode {
|
||||
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to seek file: %v", err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
written, err := file.Write(data)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
info, statErr := file.Stat()
|
||||
size := int64(0)
|
||||
if statErr == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullPath,
|
||||
"bytes_written": written,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -442,7 +957,7 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -490,7 +1005,7 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides HTTP API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -12,15 +11,31 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== HTTP API (Sandboxed) ====================
|
||||
|
||||
type HTTPResponse struct {
|
||||
StatusCode int `json:"statusCode"`
|
||||
Body string `json:"body"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
const maxExtensionHTTPResponseBytes = 16 << 20
|
||||
|
||||
func readExtensionHTTPResponseBody(resp *http.Response) ([]byte, error) {
|
||||
body, err := io.ReadAll(
|
||||
io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes+1),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) > maxExtensionHTTPResponseBytes {
|
||||
return nil, fmt.Errorf(
|
||||
"response body exceeds %d byte limit; use file.download for large media",
|
||||
maxExtensionHTTPResponseBytes,
|
||||
)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) validateDomain(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
@@ -29,7 +44,8 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
if parsed.Scheme == "" {
|
||||
return fmt.Errorf("invalid URL: scheme is required")
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
if parsed.Scheme != "https" &&
|
||||
!(parsed.Scheme == "http" && r.manifest.Permissions.AllowHTTP) {
|
||||
return fmt.Errorf("network access denied: only https is allowed")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
@@ -52,7 +68,7 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
@@ -84,6 +100,7 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -101,7 +118,7 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
@@ -121,12 +138,13 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"url": resp.Request.URL.String(),
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
@@ -177,6 +195,7 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -197,7 +216,7 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
@@ -217,12 +236,13 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"url": resp.Request.URL.String(),
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
@@ -285,6 +305,7 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -305,7 +326,7 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
@@ -325,24 +346,25 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"url": resp.Request.URL.String(),
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PUT", call)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("DELETE", call)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PATCH", call)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
@@ -410,6 +432,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -429,7 +452,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
@@ -449,12 +472,13 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"url": resp.Request.URL.String(),
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||
jar.mu.Lock()
|
||||
jar.cookies = make(map[string][]*http.Cookie)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package gobackend provides Track Matching API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
@@ -7,9 +6,7 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Track Matching API ====================
|
||||
|
||||
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(0.0)
|
||||
}
|
||||
@@ -25,7 +22,7 @@ func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
|
||||
return r.vm.ToValue(similarity)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -46,7 +43,7 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
||||
return r.vm.ToValue(diff <= tolerance)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
|
||||