Compare commits
1072 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ce66b9e03 | |||
| 8e68af79aa | |||
| 6246e6e821 | |||
| 421d5ffdc8 | |||
| b82dabe316 | |||
| ffdaf14ba5 | |||
| f52527a41b | |||
| 56a89c5fc6 | |||
| 4f5163be01 | |||
| 822c094c8c | |||
| 0952b76e11 | |||
| 7291dbd9e2 | |||
| fb4cd75cb2 | |||
| 8b7cecc1c5 | |||
| 012dcdc2dd | |||
| 629eb66595 | |||
| 36749a40d3 | |||
| 4336e6dc78 | |||
| 3e3e87e73e | |||
| 1b8d6ce7fa | |||
| 60f1df1488 | |||
| ff86869c33 | |||
| 2a2d817314 | |||
| 7845ac8be5 | |||
| 81547013f9 | |||
| 8e605cbd0f | |||
| d664d46ca4 | |||
| b4031936a0 | |||
| f84a33bbf2 | |||
| 8f5c59683a | |||
| 4b7146afe4 | |||
| 939407675b | |||
| 20ac6b2cd4 | |||
| 904b45e8f6 | |||
| 1bd54c530b | |||
| d005e2e2e7 | |||
| fb5d8826a2 | |||
| 4bc28704ff | |||
| ed7171133f | |||
| 67885e17ed | |||
| fd4da1b7c4 | |||
| 242a57b7eb | |||
| 18467c54d6 | |||
| 8238e2fe68 | |||
| 13c2360b7e | |||
| f1138ec7af | |||
| 0e00660e2e | |||
| aad72226c5 | |||
| 83d7106e35 | |||
| 30a7cba02a | |||
| 01a5b43613 | |||
| 149cdc782d | |||
| d24435dbc2 | |||
| bb06ab7e12 | |||
| 2143de3aa7 | |||
| b5973c45a2 | |||
| 9a78798854 | |||
| 101ab3f521 | |||
| cfc8e699f3 | |||
| 6b342aeac6 | |||
| b306056995 | |||
| 82e317c4a8 | |||
| a4dc776bfb | |||
| 5bdaa35ced | |||
| e187ac461d | |||
| 1b4a6cd042 | |||
| dcfb22c3f4 | |||
| 501158df03 | |||
| e17a4fad4e | |||
| 34894faabf | |||
| b329acd710 | |||
| 87dc8eb5ea | |||
| 397669965d | |||
| 60bd0e619e | |||
| 2c7621c1a5 | |||
| b55be00fab | |||
| f8b7812943 | |||
| 8f14ff169a | |||
| ca3abeb1cf | |||
| bb0cc23461 | |||
| 45fa33e1ec | |||
| 64dbf4441c | |||
| 148e5c1231 | |||
| 3a7419ec9f | |||
| 01c7c9cc3a | |||
| 3f56b88fa5 | |||
| bdd3f4aef5 | |||
| 611abdc6ae | |||
| 6e9fa45915 | |||
| 7dafbc1063 | |||
| ad8ac3bd2b | |||
| cd2c2a9854 | |||
| bb7c86c29e | |||
| df96cc4a1d | |||
| 6c3d92cee4 | |||
| 803cd2de96 | |||
| 8f2ca33e87 | |||
| d87e0d7e01 | |||
| 86b8709ea1 | |||
| 702b917929 | |||
| 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 | |||
| 98abaf6635 | |||
| 8675ab3215 | |||
| ad6ef2884a | |||
| 3ebb8a5e79 | |||
| 652b1b0821 | |||
| 4747119a7f | |||
| bfd769b349 | |||
| 40c3c73bfd | |||
| 96d11b1d7d | |||
| b3771f3488 | |||
| a07c125454 | |||
| 54a7b6b568 | |||
| 77d0ac4fce | |||
| bddd733466 | |||
| e6ffb08954 | |||
| 2fe8f659bc | |||
| ab26d84632 | |||
| a202ca4865 | |||
| a2db5bef25 | |||
| a81fa1ead7 | |||
| e7315cbc7e | |||
| cd757f177f | |||
| 103c55c072 | |||
| 765caab6df | |||
| 72f4663dd5 | |||
| deb6d92b55 | |||
| 0222ea6ccb | |||
| 8c047600a0 | |||
| 57b5877fdc | |||
| 7ddf67a977 | |||
| 7af2212d11 | |||
| 5e13651ed9 | |||
| 08e9c8d463 | |||
| b3d93880b5 | |||
| 05e100a492 | |||
| a4e22de455 | |||
| c89600591c | |||
| f1d57d89c7 | |||
| 83124875d3 | |||
| 9460e9faae | |||
| 882afd938b | |||
| ab72a10578 | |||
| d76d020cfe | |||
| e39756fa3f | |||
| 8e794e1ef1 | |||
| caf68c8137 | |||
| 5161ac8f77 | |||
| 4df96db809 | |||
| 5605930aef | |||
| 85bf3cfa84 | |||
| 8eec73d88c | |||
| b63dbbbfd5 | |||
| 8b16157047 | |||
| 6628682f97 | |||
| 5971ffc470 | |||
| baf95ec328 | |||
| 0a6590fafd | |||
| 22dd0ee0f6 | |||
| f9ad6046e8 | |||
| 8a21902fa1 | |||
| 016564eda7 | |||
| 5a8ff7db37 | |||
| cc08596adf | |||
| cdc5836785 | |||
| 813ed79073 | |||
| 537bab69ab | |||
| b0871ad94b | |||
| 0bd7574ab2 | |||
| c3f8b48bf7 | |||
| 90f731ac1e | |||
| e83fd66023 | |||
| d49bab403d | |||
| 8e6cbcbc2a | |||
| a6bef63aa7 | |||
| 898e28c40c | |||
| 9fda7ef596 | |||
| 17ba1713ad | |||
| f4110204b1 | |||
| d2a183b52d | |||
| a8dcf3113c | |||
| 1f52a6c9e0 | |||
| adbed63196 | |||
| 33e20845f1 | |||
| 9a7096c301 | |||
| 4c365032ff | |||
| bbd32d40a6 | |||
| 73f4a91fa1 | |||
| 1e2e383794 | |||
| 3b70b071e3 | |||
| 838c0ea421 | |||
| 3ac9ff1dd7 | |||
| 3e90b29d2b | |||
| b74186464b | |||
| f4934dcb28 | |||
| 30973a8e78 | |||
| 9b89625660 | |||
| c70ba5962e | |||
| 8c722b0a18 | |||
| 3ece6770e1 | |||
| b39ec41255 | |||
| 1407018d98 | |||
| d4d661d6d4 | |||
| 2092f078ec | |||
| 924569aefb | |||
| a5864e15f8 | |||
| 0dc89cf569 | |||
| 3c1e9d03a0 | |||
| 28a082f47a | |||
| 38994d5900 | |||
| 472896328a | |||
| 92f408035a | |||
| 979186243c | |||
| ee66247bea | |||
| 66a9daf733 | |||
| 69a9e0cb40 | |||
| cd6beaa7d4 | |||
| 5f4ff17630 | |||
| 3c3bbe516e | |||
| a1d1ab1f0f | |||
| ab9456fff8 | |||
| 2f673469aa | |||
| 05fde22075 | |||
| deab7b7dd6 | |||
| ae5da3b6e0 | |||
| 4d0c8f49aa | |||
| 3068f4e367 | |||
| 3844704490 | |||
| 12144b8220 | |||
| b639080494 | |||
| e67d7d68cb | |||
| b8f18c1cf5 | |||
| 529958c4af | |||
| 40077a577c | |||
| e0fbd706ce | |||
| b76879f204 | |||
| 564dd8bf95 | |||
| b317f7cd76 | |||
| a3b49d2642 | |||
| 6f20620c97 | |||
| b6a055a01a | |||
| 44ac593ddc | |||
| ca4c2a661e | |||
| 8b3b39f390 | |||
| 915934e5dd | |||
| 42f15018ae | |||
| 3554a7b5b9 | |||
| f2941939b7 | |||
| 1a77ded997 | |||
| 05d25d4d7c | |||
| 7cc1fef989 | |||
| abc599d7f9 | |||
| eefbb63299 | |||
| fdbb474763 | |||
| 6a7eef6956 | |||
| 9b27e86e0f | |||
| dbe8f5d814 | |||
| 9847594ca1 | |||
| 4a966e5e52 | |||
| d8ba4549aa | |||
| 986f5eafc8 | |||
| 84df64fcfe | |||
| a9150b85b9 | |||
| 68e6c8be35 | |||
| bd42655c0e | |||
| fe1c96ea12 | |||
| bae2bf63eb | |||
| 803e0dc5a3 | |||
| 474c37ec8e | |||
| eb7726263a | |||
| f87ccc51c5 | |||
| b0b4e7803c | |||
| 450f19c656 | |||
| 55b9c08f99 | |||
| a5f3aab775 | |||
| 7442c9b106 | |||
| ae66cb478b | |||
| 2516c3e618 | |||
| 02a5893279 | |||
| bd0d653210 | |||
| 62626ddc08 | |||
| b6574f0097 | |||
| c35a8dd803 | |||
| d54b2249b6 | |||
| f7be2c1e12 | |||
| 309568becc | |||
| dd9b6dbfe3 | |||
| 4692b48174 | |||
| db82fa3ae1 | |||
| 5c42507b12 | |||
| ebe7d87da7 | |||
| 3a6b7eed59 | |||
| 51d02d7764 | |||
| df39d61ed4 | |||
| 9cd2b1d8c5 | |||
| 49f1fb43fa | |||
| 7ec5d28caf | |||
| 23f5aa11b0 | |||
| 5fdf1df5df | |||
| 65b521ff8b | |||
| 6d578694e2 | |||
| f7ec649b24 | |||
| 71a9e1baef | |||
| 4a4adcb72e | |||
| 3458f03158 | |||
| 4fe4a01840 | |||
| e5d6fddeda | |||
| 370f5e3b8b | |||
| f5bb0820d5 | |||
| feb6da3ecb | |||
| 39f28a12aa | |||
| 416fc79637 | |||
| 1f43780bec | |||
| f9dd82010f | |||
| f0790b627d | |||
| 55350fffa0 | |||
| 7229602343 | |||
| 1c81c53699 | |||
| 5256d6197b | |||
| 79a6c8cdc0 | |||
| aa3b4d7d1e | |||
| cd220a4650 | |||
| d71b2a9ab8 | |||
| a2efe7243d | |||
| e0acda14e4 | |||
| 029ab8ea47 | |||
| 38f9498006 | |||
| 67fc3e5de2 | |||
| f1e6e9253f | |||
| 11c612e270 | |||
| cec5e49659 | |||
| 1dbdb5f2c3 | |||
| 086511d3e9 | |||
| 3d366d21b7 | |||
| 35f412dbd2 | |||
| c167aa0522 | |||
| fccb3f3d78 | |||
| 3a33283e94 | |||
| c74fb28a3a | |||
| ea504cc3ed | |||
| 61a2ad258e | |||
| ab62a8b1a9 | |||
| 479eb1272d | |||
| d23562e579 | |||
| 541d64bdd0 | |||
| d4f7e6e494 | |||
| 532c08fe2e | |||
| 704b9674f4 | |||
| 3de94280d2 | |||
| 65897789f6 | |||
| 5d097c3a95 | |||
| 4023e752a0 | |||
| 9a722b1a24 | |||
| 481b4b03dc | |||
| b7fd2f7902 | |||
| f2e1e59d6a | |||
| 3af2ecf1f4 | |||
| 1b2f2c891c | |||
| 155f3259f2 | |||
| f52d8d68b8 | |||
| 216d6e152c | |||
| b6f90e727c | |||
| 790bbc544f | |||
| bd511f7dc6 | |||
| e91c8c28a8 | |||
| 3c6d1afa97 | |||
| 3947e109b4 | |||
| 37b4727a29 | |||
| 2604d0002a | |||
| cca337ab31 | |||
| bb6e766a09 | |||
| af203ae51f | |||
| 01cbdde70e | |||
| e70ed311ed | |||
| c732cddf06 | |||
| 1f71f957e2 | |||
| 757c5fab19 | |||
| cfa537db1f | |||
| 8b18bef5ab | |||
| 76b01fb837 | |||
| 219ea593dd | |||
| 5c54e04b69 | |||
| bef07b1583 | |||
| 859762e35c | |||
| ca136b8e17 | |||
| 03d29a73f7 | |||
| c6ee9cda35 | |||
| ad3fefac0b | |||
| ad606cca53 | |||
| c0a9cb756f | |||
| 5fa00c0051 | |||
| 239e073a8c | |||
| bf87662f99 | |||
| 278ebf3472 | |||
| 4273edd836 | |||
| 7ce41fc1c1 | |||
| 7ade57e010 | |||
| 6e7c766945 | |||
| 55b457a4c0 | |||
| 65a152cada | |||
| fb7a576e00 | |||
| 30a559b279 | |||
| f77d5fdf14 | |||
| 0a0667889c | |||
| 14d8cd54d7 | |||
| 5fa3d405e6 | |||
| 34eb335fd0 | |||
| c910530927 | |||
| 69e1a6cf6b | |||
| bd84613624 | |||
| 0b4777fc6b | |||
| e22813caec | |||
| 8f6e8432de | |||
| e4a6177cb5 | |||
| 34ffbca3e8 | |||
| f8acd8f3b6 | |||
| 9956f051ac | |||
| b33ae905a2 | |||
| 11eb0aa12a | |||
| 7c08321ce3 | |||
| e20becdca7 | |||
| 24897e25e2 | |||
| 2dc4cef583 | |||
| 34c95fbd81 | |||
| 9071db9b88 | |||
| 3eb2fdd7fa | |||
| 99e0d3d361 | |||
| a2eb89e230 | |||
| b21e953ef1 | |||
| 0ef086ce57 | |||
| 72d45746a5 | |||
| 9c22f41a3e | |||
| 22f001a735 | |||
| 26d464d3c7 | |||
| 3d6a3f8d04 | |||
| 39ce22a9e2 | |||
| 88f9a65d11 | |||
| 663ee12bcc | |||
| b3c98cecc3 | |||
| 49a18a977b | |||
| a5d0feeedf | |||
| a574e73b44 | |||
| a66f6a739f | |||
| cc7e1b54b6 | |||
| 28cb7fcd3d | |||
| aeb370beca | |||
| 239707e2da | |||
| c1e2778735 | |||
| fb608a554d | |||
| 7561065802 | |||
| 56c8d89999 | |||
| 9192760f3c | |||
| 8c201b5b4a | |||
| 5e19178bc0 | |||
| 107d9ca007 | |||
| 423695c24d | |||
| 4633c7253a | |||
| 8ace180fa8 | |||
| b9c3f2f0dd | |||
| 81b0eede8c | |||
| eb0cdbeba8 | |||
| ee212a0e48 | |||
| 2073516666 | |||
| 9d479b61d6 | |||
| 203e6bc4eb | |||
| 5f1ffbee4e | |||
| 40ec24db69 | |||
| ba8d0a3438 | |||
| 82decf99a6 | |||
| 6ba9fc1fec | |||
| 715d94c2ed | |||
| e1a722f479 | |||
| edbe12c512 | |||
| 9fc6542792 | |||
| 4c01ee26c2 | |||
| 813b9fcf61 | |||
| fe070e0177 | |||
| 423bb87ed8 | |||
| 1641f51b0c | |||
| 3f78a1f3d1 | |||
| b29dc63337 | |||
| 29699117dc | |||
| 3c75f9ecc6 | |||
| 79340703c1 | |||
| df23e3f96c | |||
| d9f788ddeb | |||
| 62afbdcaaa | |||
| 6c578cfd78 | |||
| a17abec799 | |||
| 2a71b70a34 | |||
| 03f77daf19 | |||
| 270b0c1af6 | |||
| 317bb523a4 | |||
| 2c8ad87b7e | |||
| 5e06729029 | |||
| 21bcfe1157 | |||
| 3aeaaaf4f2 | |||
| 3a9d1395db | |||
| 90c46d99d4 | |||
| 96f44fefd4 | |||
| 38a0a76b69 | |||
| 7fc73b6038 | |||
| 6b61dbc2da | |||
| fd3158fd15 | |||
| ff7135bf2c | |||
| 74bac570c7 | |||
| 5f999035c3 | |||
| fa7b5a3559 | |||
| 187821b2ae | |||
| 1435ba9658 | |||
| 62e2e1703c | |||
| 21a732379b | |||
| 8ac035d146 | |||
| d7e7fb065e | |||
| 11d3b8ab3b | |||
| 566e5996bc | |||
| 51618c7dbd | |||
| bdff3a6135 | |||
| ef7cd4ff5d | |||
| 431e437dee | |||
| cebd43e75a | |||
| 17bfbf95f2 | |||
| dad525be40 | |||
| 7dd0dbd594 | |||
| a0bf423a50 | |||
| 288b060983 | |||
| 5ba60d4fd0 | |||
| 07dae97fe6 | |||
| b210f67728 | |||
| 728d1d58c2 | |||
| 6b9650d451 | |||
| 72ae9072bf | |||
| e82263dc14 | |||
| f03b218775 | |||
| c840b59ae1 | |||
| 1213fc449a | |||
| ca21bb0f0c | |||
| 00555b2df6 | |||
| efca120470 | |||
| a178c3943a | |||
| 01ed1f20ad | |||
| e2bd67083e | |||
| 31fb0a87c9 | |||
| ac4d9fc602 | |||
| 8b1b581dbe | |||
| ebdaa24cfc | |||
| 5633e3adf8 | |||
| fcae5e066d | |||
| c312aea75f | |||
| 1e6e19ecd2 | |||
| 0866b04766 | |||
| 78cef8d58e | |||
| ce84aee8da | |||
| 1ba1665215 | |||
| 60fb18c8e2 | |||
| c042b490b8 | |||
| f544b46d97 | |||
| 70759724fe | |||
| fbfe252df6 | |||
| 2c3def8c7b | |||
| 47e67e8299 | |||
| ec15516230 | |||
| 462013bc2a | |||
| 6b5e53864d | |||
| a8a47589c8 | |||
| b9d567d421 | |||
| 81c77af558 | |||
| 1121680da6 | |||
| d31f2e8894 | |||
| 5895a59cb2 | |||
| 3e5e8d7a42 | |||
| 518a7fd2cf | |||
| 6c832d1754 | |||
| d898b5f23e | |||
| c38a1428f1 | |||
| 759eeccc1f | |||
| d0bc3b203c | |||
| 831b68b6cc | |||
| a06111f445 | |||
| 31fdd30c13 | |||
| e207ef89d5 | |||
| 1261da2e5b | |||
| 0c917bc41e | |||
| f525d6c7e6 | |||
| ed7c67a622 | |||
| 99281df5fb | |||
| 24c2fd6a15 | |||
| ec3fe34dc0 | |||
| 56f36da5f9 | |||
| 9bbd774175 | |||
| 020ac32ee6 | |||
| 67a72210ac | |||
| 020f41fd1e | |||
| 820eb8cc32 | |||
| 47fa5c2009 | |||
| 9b0c929423 | |||
| 93105a45fe | |||
| d8b2f4d367 | |||
| f1478bb2ca | |||
| 8b3c377688 | |||
| 8c98b02dca | |||
| 3743e35e8a | |||
| 05a02de4a9 | |||
| c28378cbb5 | |||
| b2bef63b6b | |||
| 6513e14b21 | |||
| fd53755ad6 | |||
| 1dbacb3027 | |||
| 910d9a7662 | |||
| 09bd8c6b21 | |||
| 908d108858 | |||
| 3135993cf4 | |||
| 7a315b5fd4 | |||
| 4bd6dcc3d7 | |||
| 3f7fa19cdf | |||
| 867ec4d125 | |||
| 164467f3a2 | |||
| fc9a2ddc2a | |||
| 543cb45c11 | |||
| c49e5adc52 | |||
| 0fedd446ca | |||
| 0c7b8a68d9 | |||
| 6dd6accbcc | |||
| ca67f7f79d | |||
| 1aa12c5857 | |||
| 80707fc438 | |||
| ff121dfeb8 | |||
| c3aa6a441b | |||
| 496d32e35b | |||
| 291fa58757 | |||
| eddbc2f986 | |||
| 81b8281d2c | |||
| 57f87d9a4c | |||
| c9d0c57d86 | |||
| 54ab5a9243 | |||
| 17b6b27cd7 | |||
| ed131ca1fd | |||
| 190d65cdee | |||
| dbf2e337f0 | |||
| 12e76bed4f | |||
| e00db80dae | |||
| 5de0aa8145 | |||
| 91ffb25027 | |||
| 6bcbdfedf0 | |||
| 3f42128cb9 | |||
| ccb8f98df5 | |||
| 591a597333 | |||
| 22f52f4af2 | |||
| ceaaff8c9b | |||
| a318495046 | |||
| 8ffc6d3821 | |||
| 2036e46da0 | |||
| b82000e87c | |||
| 144906fd8f | |||
| 8a109e9013 | |||
| ba05f6b470 | |||
| 2f80ae7e84 | |||
| e248fef130 | |||
| 174724ddd3 | |||
| 730945d892 | |||
| 4abdce8c58 | |||
| 0d98ada479 | |||
| 5d4fc10ab7 | |||
| e37dfeb080 | |||
| eddae2a9dd | |||
| 6bd7eec615 | |||
| b240e91290 | |||
| 4e0149df29 | |||
| 065872e686 | |||
| 7ab0f5b7c8 | |||
| fd31682242 | |||
| 56c8b62fcf | |||
| c3f879346a | |||
| 6da65ed033 | |||
| 553c6b6c4a | |||
| ac5f74a48f | |||
| 2d22d85c49 | |||
| 3edfe8e8bb | |||
| 6f9722e05b | |||
| 066d35967e | |||
| 2b932cff70 | |||
| a32487ad88 | |||
| bd4946db37 | |||
| 69f143dd9d | |||
| 15408bfa1c | |||
| edc715021d | |||
| 392472b027 | |||
| 69741fa47c | |||
| 484720bcda | |||
| f3cc51fb06 | |||
| 452ea7084a | |||
| bba059fc44 | |||
| 3f75cace2b | |||
| 556c0e1db2 | |||
| 9897d3102e | |||
| 88dfb88bcc | |||
| 75bfe9b3bf | |||
| f4fe74f972 |
@@ -0,0 +1,20 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Windows scripts
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.ps1 text eol=crlf
|
||||||
|
|
||||||
|
# Binary files
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.webp binary
|
||||||
|
*.ico binary
|
||||||
|
*.pdf binary
|
||||||
|
*.zip binary
|
||||||
|
*.jar binary
|
||||||
|
*.aar binary
|
||||||
|
*.keystore binary
|
||||||
|
*.jks binary
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
github: zarzet
|
github: zarzet
|
||||||
ko_fi: zarzet
|
ko_fi: zarzet
|
||||||
buy_me_a_coffee: zarzet
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ contact_links:
|
|||||||
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
||||||
about: Check the README for setup instructions and FAQ
|
about: Check the README for setup instructions and FAQ
|
||||||
- name: Extension Development Guide
|
- name: Extension Development Guide
|
||||||
url: https://zarz.moe/docs
|
url: https://spotiflac.zarz.moe/docs
|
||||||
about: Documentation for building SpotiFLAC extensions
|
about: Documentation for building SpotiFLAC extensions
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2 # Need previous commit to compare
|
fetch-depth: 2 # Need previous commit to compare
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Deploy to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'site/**'
|
||||||
|
- '.github/workflows/pages.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Pages
|
||||||
|
uses: actions/configure-pages@v5
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v4
|
||||||
|
with:
|
||||||
|
path: site
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -60,23 +60,23 @@ jobs:
|
|||||||
df -h
|
df -h
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.21"
|
go-version: "1.25.8"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache Gradle for faster builds
|
# Cache Gradle for faster builds
|
||||||
- name: Cache Gradle
|
- name: Cache Gradle
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -93,12 +93,12 @@ jobs:
|
|||||||
# Accept licenses
|
# Accept licenses
|
||||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
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)
|
# 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
|
# 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
|
- name: Install gomobile
|
||||||
run: |
|
run: |
|
||||||
@@ -158,28 +158,33 @@ jobs:
|
|||||||
ls -la
|
ls -la
|
||||||
|
|
||||||
- name: Upload APK artifact
|
- name: Upload APK artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: android-apk
|
name: android-apk
|
||||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||||
|
|
||||||
build-ios:
|
build-ios:
|
||||||
runs-on: macos-latest
|
runs-on: macos-15
|
||||||
needs: get-version # Only depends on version, NOT android build!
|
needs: get-version # Only depends on version, NOT android build!
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.21"
|
go-version: "1.25.8"
|
||||||
cache-dependency-path: go_backend/go.sum
|
cache-dependency-path: go_backend/go.sum
|
||||||
|
|
||||||
# Cache CocoaPods
|
# Cache CocoaPods
|
||||||
- name: Cache CocoaPods
|
- name: Cache CocoaPods
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ios/Pods
|
path: ios/Pods
|
||||||
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
|
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
|
||||||
@@ -194,7 +199,7 @@ jobs:
|
|||||||
working-directory: go_backend
|
working-directory: go_backend
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ../ios/Frameworks
|
mkdir -p ../ios/Frameworks
|
||||||
gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework .
|
gomobile bind -target=ios -tags ios -o ../ios/Frameworks/Gobackend.xcframework .
|
||||||
env:
|
env:
|
||||||
CGO_ENABLED: 1
|
CGO_ENABLED: 1
|
||||||
|
|
||||||
@@ -249,23 +254,6 @@ jobs:
|
|||||||
channel: "stable"
|
channel: "stable"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
# Swap pubspec for iOS build (includes ffmpeg_kit_flutter)
|
|
||||||
- name: Use iOS pubspec with FFmpeg plugin
|
|
||||||
run: |
|
|
||||||
cp pubspec.yaml pubspec_android_backup.yaml
|
|
||||||
cp pubspec_ios.yaml pubspec.yaml
|
|
||||||
echo "Swapped to iOS pubspec with ffmpeg_kit_flutter"
|
|
||||||
|
|
||||||
# Swap FFmpeg service for iOS
|
|
||||||
- name: Use iOS FFmpeg service
|
|
||||||
run: |
|
|
||||||
cp lib/services/ffmpeg_service.dart lib/services/ffmpeg_service_android.dart
|
|
||||||
cp build_assets/ffmpeg_service_ios.dart lib/services/ffmpeg_service.dart
|
|
||||||
# Update class name in the swapped file
|
|
||||||
sed -i '' 's/FFmpegServiceIOS/FFmpegService/g' lib/services/ffmpeg_service.dart
|
|
||||||
sed -i '' 's/FFmpegResultIOS/FFmpegResult/g' lib/services/ffmpeg_service.dart
|
|
||||||
echo "Swapped to iOS FFmpeg service"
|
|
||||||
|
|
||||||
- name: Get Flutter dependencies
|
- name: Get Flutter dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
@@ -312,7 +300,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload IPA artifact
|
- name: Upload IPA artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: build/ios/ipa/SpotiFLAC-*.ipa
|
path: build/ios/ipa/SpotiFLAC-*.ipa
|
||||||
@@ -325,43 +313,33 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
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
|
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: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
echo "Generated changelog:"
|
||||||
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:"
|
|
||||||
cat /tmp/changelog.txt
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
- name: Download Android APK
|
- name: Download Android APK
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: android-apk
|
name: android-apk
|
||||||
path: ./release
|
path: ./release
|
||||||
|
|
||||||
- name: Download iOS IPA
|
- name: Download iOS IPA
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: ./release
|
path: ./release
|
||||||
@@ -369,15 +347,22 @@ jobs:
|
|||||||
- name: Prepare release body
|
- name: Prepare release body
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
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_OWNER="${{ github.repository_owner }}"
|
||||||
REPO_NAME="${{ github.event.repository.name }}"
|
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
|
cat >> /tmp/release_body.txt << FOOTER
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -402,7 +387,7 @@ jobs:
|
|||||||
cat /tmp/release_body.txt
|
cat /tmp/release_body.txt
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.get-version.outputs.version }}
|
tag_name: ${{ needs.get-version.outputs.version }}
|
||||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||||
@@ -413,6 +398,63 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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:
|
notify-telegram:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [get-version, create-release]
|
needs: [get-version, create-release]
|
||||||
@@ -420,66 +462,59 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Download Android APK
|
- name: Download Android APK
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: android-apk
|
name: android-apk
|
||||||
path: ./release
|
path: ./release
|
||||||
|
|
||||||
- name: Download iOS IPA
|
- name: Download iOS IPA
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: ios-ipa
|
name: ios-ipa
|
||||||
path: ./release
|
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
|
id: changelog
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ needs.get-version.outputs.version }}
|
if [ ! -s /tmp/cliff_tg.txt ]; then
|
||||||
VERSION_NUM=${VERSION#v}
|
echo "See release notes on GitHub for details." > /tmp/changelog.txt
|
||||||
|
|
||||||
# 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."
|
|
||||||
else
|
else
|
||||||
# Convert GitHub Markdown to Telegram HTML:
|
# Convert Markdown to Telegram HTML
|
||||||
# - **text** → <b>text</b>
|
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
|
||||||
# - `code` → <code>code</code>
|
sed '/^## [0-9][0-9.[:alpha:]-]*$/d' | \
|
||||||
# - ### Header → <b>Header</b>
|
sed '/^\*\*Full Changelog\*\*/d' | \
|
||||||
# - Escape HTML special chars first
|
sed 's/ by \[@[^]]*\](https:\/\/github\.com\/[^)]*)//g' | \
|
||||||
# - Remove > blockquote prefix
|
sed 's/ by @[A-Za-z0-9_-]\+//g' | \
|
||||||
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
|
sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
|
||||||
sed 's/^> //' | \
|
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
|
||||||
sed 's/&/\&/g' | \
|
sed 's/&/\&/g' | \
|
||||||
sed 's/</\</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/^### \(.*\)$/<b>\1<\/b>/g' | \
|
||||||
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
||||||
sed 's/^- /• /g' | \
|
sed 's/^- /• /g')
|
||||||
sed 's/^ - / ◦ /g')
|
|
||||||
|
# Truncate for Telegram 4096 char limit
|
||||||
# Take first 2500 characters, then cut at last complete line
|
|
||||||
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
||||||
|
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||||
# Check if truncated
|
|
||||||
FULL_LEN=${#FULL_CHANGELOG}
|
|
||||||
if [ $FULL_LEN -gt 2500 ]; then
|
|
||||||
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
echo "Telegram changelog:"
|
||||||
echo "DEBUG: Final changelog:"
|
|
||||||
cat /tmp/changelog.txt
|
cat /tmp/changelog.txt
|
||||||
|
|
||||||
- name: Send to Telegram Channel
|
- name: Send to Telegram Channel
|
||||||
@@ -507,11 +542,13 @@ jobs:
|
|||||||
MESSAGE=$(cat /tmp/telegram_message.txt)
|
MESSAGE=$(cat /tmp/telegram_message.txt)
|
||||||
|
|
||||||
# Send message first (using HTML parse mode)
|
# Send message first (using HTML parse mode)
|
||||||
|
# Use --data-urlencode for proper encoding of special chars (+, &, etc.)
|
||||||
|
# Use || true to ensure file uploads continue even if message fails
|
||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||||
-d chat_id="${TELEGRAM_CHANNEL_ID}" \
|
--data-urlencode "chat_id=${TELEGRAM_CHANNEL_ID}" \
|
||||||
-d text="${MESSAGE}" \
|
--data-urlencode "text=${MESSAGE}" \
|
||||||
-d parse_mode="HTML" \
|
--data-urlencode "parse_mode=HTML" \
|
||||||
-d disable_web_page_preview="true"
|
--data-urlencode "disable_web_page_preview=true" || true
|
||||||
|
|
||||||
# Upload arm64 APK to channel
|
# Upload arm64 APK to channel
|
||||||
if [ -f "$ARM64_APK" ]; then
|
if [ -f "$ARM64_APK" ]; then
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ Thumbs.db
|
|||||||
# Kiro specs (development only)
|
# Kiro specs (development only)
|
||||||
.kiro/
|
.kiro/
|
||||||
|
|
||||||
|
# Design assets (banners, mockups)
|
||||||
|
design/
|
||||||
|
|
||||||
# Reference folder (development only)
|
# Reference folder (development only)
|
||||||
referensi/
|
referensi/
|
||||||
|
|
||||||
@@ -41,6 +44,7 @@ go_backend/*.xcframework/
|
|||||||
# Android
|
# Android
|
||||||
android/.gradle/
|
android/.gradle/
|
||||||
android/app/libs/gobackend.aar
|
android/app/libs/gobackend.aar
|
||||||
|
android/app/libs/gobackend-sources.jar
|
||||||
android/local.properties
|
android/local.properties
|
||||||
android/*.iml
|
android/*.iml
|
||||||
android/key.properties
|
android/key.properties
|
||||||
@@ -54,7 +58,6 @@ ios/Pods/
|
|||||||
ios/.symlinks/
|
ios/.symlinks/
|
||||||
ios/Flutter/Flutter.framework/
|
ios/Flutter/Flutter.framework/
|
||||||
ios/Flutter/Flutter.podspec
|
ios/Flutter/Flutter.podspec
|
||||||
android/app/libs/gobackend-sources.jar
|
|
||||||
|
|
||||||
# Extension folder
|
# Extension folder
|
||||||
extension/
|
extension/
|
||||||
@@ -64,6 +67,10 @@ AGENTS.md
|
|||||||
|
|
||||||
# Temp/misc
|
# Temp/misc
|
||||||
nul
|
nul
|
||||||
|
NUL
|
||||||
|
network_requests.txt
|
||||||
|
*.bak
|
||||||
|
/AndroidManifest.xml
|
||||||
|
|
||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
@@ -72,3 +79,8 @@ flutter_*.log
|
|||||||
|
|
||||||
# Development tools
|
# Development tools
|
||||||
tool/
|
tool/
|
||||||
|
.claude/settings.local.json
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# FVM Version Cache
|
||||||
|
.fvm/
|
||||||
|
|||||||
@@ -86,17 +86,31 @@ Translation files are located in `lib/l10n/arb/`.
|
|||||||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
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
|
```bash
|
||||||
flutter pub get
|
flutter pub get
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Generate code** (for Riverpod, JSON serialization, etc.)
|
5. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||||
```bash
|
```bash
|
||||||
dart run build_runner build --delete-conflicting-outputs
|
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
|
```bash
|
||||||
flutter run
|
flutter run
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,102 +1,186 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
|
||||||
[](https://www.virustotal.com/gui/file/3257155286587a3596ad5d4380d4576a684aa3d37a5b19a615914a845fbe57f3)
|
|
||||||
[](https://crowdin.com/project/spotiflac-mobile)
|
|
||||||
|
|
||||||
<div align="center">
|
<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>
|
</div>
|
||||||
|
|
||||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
<div align="center">
|
||||||
|
|
||||||
|
[](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
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/images/1.jpg?v=2" width="200" />
|
<img src="assets/readme/1.jpg?v=2" width="200" />
|
||||||
<img src="assets/images/2.jpg?v=2" width="200" />
|
<img src="assets/readme/2.jpg?v=2" width="200" />
|
||||||
<img src="assets/images/3.jpg?v=2" width="200" />
|
<img src="assets/readme/3.jpg?v=2" width="200" />
|
||||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
<img src="assets/readme/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Search Source
|
---
|
||||||
|
|
||||||
SpotiFLAC supports multiple search sources for finding music metadata:
|
|
||||||
|
|
||||||
| Source | Setup |
|
|
||||||
|--------|-------|
|
|
||||||
| **Deezer** (Default) | No setup required |
|
|
||||||
| **Extensions** | Install additional search providers from the Store |
|
|
||||||
|
|
||||||
## Extensions
|
## 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.
|
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
|
### Installing Extensions
|
||||||
1. Go to **Store** tab in the app
|
|
||||||
2. Browse and install extensions with one tap
|
1. Open the **Store** tab in the app
|
||||||
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
2. On first launch, enter an **Extension Repository URL** when prompted
|
||||||
4. Configure extension settings if needed
|
3. Browse and install extensions with one tap
|
||||||
5. Set provider priority in **Settings > Extensions > Provider Priority**
|
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
|
### Developing Extensions
|
||||||
Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
|
|
||||||
|
|
||||||
## Other project
|
> [!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)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
|
||||||
|
|
||||||
> **Note:** Currently unavailable because the GitHub account is suspended. Alternatively, use [SpotiFLAC-Next](https://github.com/spotiverse/SpotiFLAC-Next) until the original is restored.
|
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||||
|
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
||||||
|
|
||||||
## Telegram
|
---
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://t.me/spotiflac">
|
|
||||||
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://t.me/spotiflacchat">
|
|
||||||
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
**Q: Why is my download failing with "Song not found"?**
|
<details>
|
||||||
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.
|
<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?**
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
**Q: Can I download playlists?**
|
</details>
|
||||||
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
|
||||||
|
|
||||||
**Q: Why do I need to grant storage permission?**
|
<details>
|
||||||
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.
|
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
**Q: Is this app safe?**
|
The track may not be available on the streaming services. Try enabling more providers under **Settings > Download > Provider Priority**, or install additional extensions like Amazon Music from the Store.
|
||||||
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).
|
|
||||||
|
|
||||||
**Q: Why is download not working in my country?**
|
</details>
|
||||||
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>
|
||||||
|
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
|
||||||
|
- **Tidal** up to 24-bit/192kHz
|
||||||
|
- **Qobuz** up to 24-bit/192kHz
|
||||||
|
- **Deezer** up to 16-bit/44.1kHz
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<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.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Why do I need to grant storage permission?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
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**.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Is this app safe?</b></summary>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
| | | | | |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| [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
|
## Disclaimer
|
||||||
|
|
||||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
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).
|
||||||
|
|
||||||
**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.
|
- 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.
|
||||||
|
|
||||||
The application is purely a user interface that facilitates communication between your device and existing third-party services.
|
> [!TIP]
|
||||||
|
> **Star the repo** to get notified about all new releases directly from GitHub.
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -9,6 +9,19 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
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:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
@@ -23,6 +36,13 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` 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
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# 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'
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,10 @@ android {
|
|||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
@@ -57,6 +61,18 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "FULL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getByName("profile") {
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "FULL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
release {
|
release {
|
||||||
// For local builds: use release signing if key.properties exists
|
// For local builds: use release signing if key.properties exists
|
||||||
// For CI builds: APK is signed by GitHub Action after build
|
// For CI builds: APK is signed by GitHub Action after build
|
||||||
@@ -71,6 +87,9 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "FULL"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,11 +115,14 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||||
|
|
||||||
// Include all AAR and JAR files from libs folder
|
// Include all AAR and JAR files from libs folder
|
||||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0-beta02")
|
||||||
|
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||||
|
implementation("androidx.activity:activity-ktx:1.13.0")
|
||||||
|
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
-keep class io.flutter.view.** { *; }
|
-keep class io.flutter.view.** { *; }
|
||||||
-keep class io.flutter.** { *; }
|
-keep class io.flutter.** { *; }
|
||||||
-keep class io.flutter.plugins.** { *; }
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
-keep class io.flutter.embedding.** { *; }
|
||||||
|
|
||||||
# Ignore missing Play Core classes (not used, but referenced by Flutter)
|
# Ignore missing Play Core classes (not used, but referenced by Flutter)
|
||||||
-dontwarn com.google.android.play.core.splitcompat.**
|
-dontwarn com.google.android.play.core.splitcompat.**
|
||||||
@@ -14,13 +15,22 @@
|
|||||||
# Ignore missing javax.xml.stream (not used on Android)
|
# Ignore missing javax.xml.stream (not used on Android)
|
||||||
-dontwarn javax.xml.stream.**
|
-dontwarn javax.xml.stream.**
|
||||||
|
|
||||||
# Go backend (gobackend.aar)
|
# Go backend (gobackend.aar) - CRITICAL for release builds
|
||||||
-keep class gobackend.** { *; }
|
-keep class gobackend.** { *; }
|
||||||
-keep class go.** { *; }
|
-keep class go.** { *; }
|
||||||
|
-keep interface gobackend.** { *; }
|
||||||
|
-keepclassmembers class gobackend.** { *; }
|
||||||
|
|
||||||
|
# Go mobile binding internals
|
||||||
|
-keep class org.golang.** { *; }
|
||||||
|
-dontwarn org.golang.**
|
||||||
|
|
||||||
# FFmpeg Kit
|
# FFmpeg Kit
|
||||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||||
-keep class com.arthenica.smartexception.** { *; }
|
-keep class com.arthenica.smartexception.** { *; }
|
||||||
|
# FFmpeg Kit (new fork package)
|
||||||
|
-keep class com.antonkarpenko.ffmpegkit.** { *; }
|
||||||
|
-keep class com.antonkarpenko.smartexception.** { *; }
|
||||||
|
|
||||||
# Apache Tika (if used by FFmpeg)
|
# Apache Tika (if used by FFmpeg)
|
||||||
-dontwarn org.apache.tika.**
|
-dontwarn org.apache.tika.**
|
||||||
@@ -30,15 +40,77 @@
|
|||||||
native <methods>;
|
native <methods>;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Kotlin coroutines
|
# Kotlin coroutines - expanded rules
|
||||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||||
-keepclassmembers class kotlinx.coroutines.** {
|
-keepclassmembers class kotlinx.coroutines.** {
|
||||||
volatile <fields>;
|
volatile <fields>;
|
||||||
}
|
}
|
||||||
|
-keepclassmembernames class kotlinx.** {
|
||||||
|
volatile <fields>;
|
||||||
|
}
|
||||||
|
-dontwarn kotlinx.coroutines.**
|
||||||
|
|
||||||
|
# Kotlin serialization
|
||||||
|
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
|
||||||
|
-dontwarn kotlin.**
|
||||||
|
-keep class kotlin.** { *; }
|
||||||
|
-keep class kotlin.Metadata { *; }
|
||||||
|
|
||||||
|
# Keep MainActivity and related classes
|
||||||
|
-keep class com.zarz.spotiflac.** { *; }
|
||||||
|
|
||||||
# Prevent R8 from removing metadata
|
# Prevent R8 from removing metadata
|
||||||
-keepattributes *Annotation*
|
-keepattributes *Annotation*
|
||||||
-keepattributes SourceFile,LineNumberTable
|
-keepattributes SourceFile,LineNumberTable
|
||||||
-keepattributes Signature
|
-keepattributes Signature
|
||||||
-keepattributes Exceptions
|
-keepattributes Exceptions
|
||||||
|
-keepattributes InnerClasses
|
||||||
|
-keepattributes EnclosingMethod
|
||||||
|
|
||||||
|
# JSON parsing (used by Go backend responses)
|
||||||
|
-keep class org.json.** { *; }
|
||||||
|
|
||||||
|
# Shared Preferences
|
||||||
|
-keep class androidx.datastore.** { *; }
|
||||||
|
-dontwarn androidx.datastore.**
|
||||||
|
|
||||||
|
# Flutter Plugins - CRITICAL: Prevent R8 from removing plugin implementations
|
||||||
|
# Path Provider
|
||||||
|
-keep class io.flutter.plugins.pathprovider.** { *; }
|
||||||
|
-keep class dev.flutter.pigeon.** { *; }
|
||||||
|
|
||||||
|
# Local Notifications
|
||||||
|
-keep class com.dexterous.** { *; }
|
||||||
|
-keep class com.dexterous.flutterlocalnotifications.** { *; }
|
||||||
|
|
||||||
|
# Receive Sharing Intent
|
||||||
|
-keep class com.kasem.receive_sharing_intent.** { *; }
|
||||||
|
|
||||||
|
# Permission Handler
|
||||||
|
-keep class com.baseflow.permissionhandler.** { *; }
|
||||||
|
|
||||||
|
# File Picker
|
||||||
|
-keep class com.mr.flutter.plugin.filepicker.** { *; }
|
||||||
|
|
||||||
|
# URL Launcher
|
||||||
|
-keep class io.flutter.plugins.urllauncher.** { *; }
|
||||||
|
|
||||||
|
# Share Plus
|
||||||
|
-keep class dev.fluttercommunity.plus.share.** { *; }
|
||||||
|
|
||||||
|
# Device Info Plus
|
||||||
|
-keep class dev.fluttercommunity.plus.device_info.** { *; }
|
||||||
|
|
||||||
|
# Open File
|
||||||
|
-keep class com.crazecoder.openfile.** { *; }
|
||||||
|
|
||||||
|
# Sqflite
|
||||||
|
-keep class com.tekartik.sqflite.** { *; }
|
||||||
|
|
||||||
|
# Dynamic Color
|
||||||
|
-keep class io.material.** { *; }
|
||||||
|
|
||||||
|
# Keep all Flutter plugin registrants
|
||||||
|
-keep class io.flutter.plugins.GeneratedPluginRegistrant { *; }
|
||||||
|
-keep class ** extends io.flutter.embedding.engine.plugins.FlutterPlugin { *; }
|
||||||
|
|||||||
@@ -12,17 +12,19 @@
|
|||||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="SpotiFLAC"
|
android:label="SpotiFLAC Mobile"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:usesCleartextTraffic="false"
|
||||||
android:usesCleartextTraffic="true"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:enableOnBackInvokedCallback="true">
|
android:enableOnBackInvokedCallback="true"
|
||||||
|
android:localeConfig="@xml/locale_config">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@@ -43,7 +45,7 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Handle Spotify URL sharing -->
|
<!-- Handle music URL sharing (Spotify, Deezer, Tidal, YT Music) -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
@@ -57,6 +59,47 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="https" android:host="open.spotify.com" />
|
<data android:scheme="https" android:host="open.spotify.com" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Handle Deezer deep links -->
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="www.deezer.com" />
|
||||||
|
<data android:scheme="https" android:host="deezer.com" />
|
||||||
|
<data android:scheme="https" android:host="deezer.page.link" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Handle Tidal deep links -->
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="tidal.com" />
|
||||||
|
<data android:scheme="https" android:host="listen.tidal.com" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Handle YouTube Music deep links -->
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<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>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- Download Service -->
|
<!-- Download Service -->
|
||||||
|
|||||||
@@ -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.3 KiB After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.5 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"?>
|
<?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">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<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>
|
</layer-list>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 7.3 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"?>
|
<?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">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<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>
|
</layer-list>
|
||||||
|
|||||||
@@ -6,4 +6,9 @@
|
|||||||
android:drawable="@drawable/ic_launcher_foreground"
|
android:drawable="@drawable/ic_launcher_foreground"
|
||||||
android:inset="16%" />
|
android:inset="16%" />
|
||||||
</foreground>
|
</foreground>
|
||||||
|
<monochrome>
|
||||||
|
<inset
|
||||||
|
android:drawable="@drawable/ic_launcher_monochrome"
|
||||||
|
android:inset="16%" />
|
||||||
|
</monochrome>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 932 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 651 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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<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">
|
<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>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
</style>
|
</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">
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#1a1a2e</color>
|
<color name="ic_launcher_background">#000000</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,17 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<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">
|
<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>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
</style>
|
</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">
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<locale android:name="en" />
|
||||||
|
<locale android:name="ru" />
|
||||||
|
<locale android:name="es-ES" />
|
||||||
|
<locale android:name="id" />
|
||||||
|
<locale android:name="pt-PT" />
|
||||||
|
<locale android:name="ja" />
|
||||||
|
<locale android:name="tr" />
|
||||||
|
<locale android:name="de" />
|
||||||
|
<locale android:name="fr" />
|
||||||
|
<locale android:name="hi" />
|
||||||
|
<locale android:name="ko" />
|
||||||
|
<locale android:name="nl" />
|
||||||
|
<locale android:name="zh" />
|
||||||
|
</locale-config>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="false" />
|
||||||
|
|
||||||
|
<!-- Allow local loopback cleartext for FFmpeg live decrypt tunnel only. -->
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">localhost</domain>
|
||||||
|
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -22,7 +22,7 @@ subprojects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add desugaring dependency to all Android subprojects
|
// Add desugaring dependency to all Android subprojects
|
||||||
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4")
|
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.5")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
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
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "9.2.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
|
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
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.5.5",
|
||||||
|
"versionDate": "2026-05-14",
|
||||||
|
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.5/SpotiFLAC-v4.5.5-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": 34915749
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 539 KiB |
|
After Width: | Height: | Size: 811 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -1,335 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/ffmpeg_kit.dart';
|
|
||||||
import 'package:ffmpeg_kit_flutter_new_audio/return_code.dart';
|
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
|
||||||
|
|
||||||
final _log = AppLogger('FFmpeg');
|
|
||||||
|
|
||||||
/// FFmpeg service for iOS using ffmpeg_kit_flutter plugin
|
|
||||||
class FFmpegServiceIOS {
|
|
||||||
/// Execute FFmpeg command and return result
|
|
||||||
static Future<FFmpegResultIOS> _execute(String command) async {
|
|
||||||
try {
|
|
||||||
final session = await FFmpegKit.execute(command);
|
|
||||||
final returnCode = await session.getReturnCode();
|
|
||||||
final output = await session.getOutput() ?? '';
|
|
||||||
return FFmpegResultIOS(
|
|
||||||
success: ReturnCode.isSuccess(returnCode),
|
|
||||||
returnCode: returnCode?.getValue() ?? -1,
|
|
||||||
output: output,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
_log.e('FFmpeg execute error: $e');
|
|
||||||
return FFmpegResultIOS(success: false, returnCode: -1, output: e.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert M4A (DASH segments) to FLAC
|
|
||||||
static Future<String?> convertM4aToFlac(String inputPath) async {
|
|
||||||
final outputPath = inputPath.replaceAll('.m4a', '.flac');
|
|
||||||
final command = '-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
try {
|
|
||||||
await File(inputPath).delete();
|
|
||||||
} catch (_) {}
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
_log.e('M4A to FLAC conversion failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert FLAC to MP3
|
|
||||||
/// If deleteOriginal is true, deletes the FLAC file after conversion
|
|
||||||
static Future<String?> convertFlacToMp3(
|
|
||||||
String inputPath, {
|
|
||||||
String bitrate = '320k',
|
|
||||||
bool deleteOriginal = true,
|
|
||||||
}) async {
|
|
||||||
// Convert in same folder, just change extension
|
|
||||||
final outputPath = inputPath.replaceAll('.flac', '.mp3');
|
|
||||||
|
|
||||||
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// Delete original FLAC if requested
|
|
||||||
if (deleteOriginal) {
|
|
||||||
try {
|
|
||||||
await File(inputPath).delete();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
_log.e('FLAC to MP3 conversion failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert FLAC to M4A
|
|
||||||
static Future<String?> convertFlacToM4a(String inputPath, {String codec = 'aac', String bitrate = '256k'}) async {
|
|
||||||
final dir = File(inputPath).parent.path;
|
|
||||||
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
|
||||||
final outputDir = '$dir${Platform.pathSeparator}M4A';
|
|
||||||
await Directory(outputDir).create(recursive: true);
|
|
||||||
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.m4a';
|
|
||||||
|
|
||||||
String command;
|
|
||||||
if (codec == 'alac') {
|
|
||||||
command = '-i "$inputPath" -codec:a alac -map 0:a -map_metadata 0 "$outputPath" -y';
|
|
||||||
} else {
|
|
||||||
command = '-i "$inputPath" -codec:a aac -b:a $bitrate -map 0:a -map_metadata 0 "$outputPath" -y';
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await _execute(command);
|
|
||||||
if (result.success) return outputPath;
|
|
||||||
_log.e('FLAC to M4A conversion failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Embed cover art to FLAC file
|
|
||||||
static Future<String?> embedCover(String flacPath, String coverPath) async {
|
|
||||||
final tempOutput = '$flacPath.tmp';
|
|
||||||
final command = '-i "$flacPath" -i "$coverPath" -map 0:a -map 1:0 -c copy -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -disposition:v attached_pic "$tempOutput" -y';
|
|
||||||
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
try {
|
|
||||||
await File(flacPath).delete();
|
|
||||||
await File(tempOutput).rename(flacPath);
|
|
||||||
return flacPath;
|
|
||||||
} catch (e) {
|
|
||||||
_log.e('Failed to replace file after cover embed: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final tempFile = File(tempOutput);
|
|
||||||
if (await tempFile.exists()) await tempFile.delete();
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
_log.e('Cover embed failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Embed metadata and cover art to FLAC file
|
|
||||||
/// Returns the file path on success, null on failure
|
|
||||||
static Future<String?> embedMetadata({
|
|
||||||
required String flacPath,
|
|
||||||
String? coverPath,
|
|
||||||
Map<String, String>? metadata,
|
|
||||||
}) async {
|
|
||||||
final tempOutput = '$flacPath.tmp';
|
|
||||||
|
|
||||||
// Construct command
|
|
||||||
final StringBuffer cmdBuffer = StringBuffer();
|
|
||||||
cmdBuffer.write('-i "$flacPath" ');
|
|
||||||
|
|
||||||
// Add cover input if available
|
|
||||||
if (coverPath != null) {
|
|
||||||
cmdBuffer.write('-i "$coverPath" ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map audio stream
|
|
||||||
cmdBuffer.write('-map 0:a ');
|
|
||||||
|
|
||||||
// Map cover stream if available
|
|
||||||
if (coverPath != null) {
|
|
||||||
cmdBuffer.write('-map 1:0 ');
|
|
||||||
cmdBuffer.write('-c:v copy ');
|
|
||||||
cmdBuffer.write('-disposition:v attached_pic ');
|
|
||||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
|
||||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy audio codec (don't re-encode)
|
|
||||||
cmdBuffer.write('-c:a copy ');
|
|
||||||
|
|
||||||
// Add text metadata
|
|
||||||
if (metadata != null) {
|
|
||||||
metadata.forEach((key, value) {
|
|
||||||
// Sanitize value: escape double quotes
|
|
||||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
|
||||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdBuffer.write('"$tempOutput" -y');
|
|
||||||
|
|
||||||
final command = cmdBuffer.toString();
|
|
||||||
_log.d('Executing FFmpeg command: $command');
|
|
||||||
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
try {
|
|
||||||
await File(flacPath).delete();
|
|
||||||
await File(tempOutput).rename(flacPath);
|
|
||||||
return flacPath;
|
|
||||||
} catch (e) {
|
|
||||||
_log.e('Failed to replace file after metadata embed: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up temp file if exists
|
|
||||||
try {
|
|
||||||
final tempFile = File(tempOutput);
|
|
||||||
if (await tempFile.exists()) {
|
|
||||||
await tempFile.delete();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
_log.e('Metadata/Cover embed failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Embed metadata and cover art to MP3 file using ID3v2 tags
|
|
||||||
/// Returns the file path on success, null on failure
|
|
||||||
static Future<String?> embedMetadataToMp3({
|
|
||||||
required String mp3Path,
|
|
||||||
String? coverPath,
|
|
||||||
Map<String, String>? metadata,
|
|
||||||
}) async {
|
|
||||||
final tempOutput = '$mp3Path.tmp';
|
|
||||||
|
|
||||||
final StringBuffer cmdBuffer = StringBuffer();
|
|
||||||
cmdBuffer.write('-i "$mp3Path" ');
|
|
||||||
|
|
||||||
if (coverPath != null) {
|
|
||||||
cmdBuffer.write('-i "$coverPath" ');
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdBuffer.write('-map 0:a ');
|
|
||||||
|
|
||||||
if (coverPath != null) {
|
|
||||||
cmdBuffer.write('-map 1:0 ');
|
|
||||||
cmdBuffer.write('-c:v:0 copy ');
|
|
||||||
cmdBuffer.write('-id3v2_version 3 ');
|
|
||||||
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
|
||||||
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdBuffer.write('-c:a copy ');
|
|
||||||
|
|
||||||
if (metadata != null) {
|
|
||||||
// Convert FLAC/Vorbis tags to ID3v2 tags for MP3
|
|
||||||
final id3Metadata = _convertToId3Tags(metadata);
|
|
||||||
id3Metadata.forEach((key, value) {
|
|
||||||
final sanitizedValue = value.replaceAll('"', '\\"');
|
|
||||||
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
|
|
||||||
|
|
||||||
final command = cmdBuffer.toString();
|
|
||||||
_log.d('Executing FFmpeg MP3 embed command: $command');
|
|
||||||
|
|
||||||
final result = await _execute(command);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
try {
|
|
||||||
await File(mp3Path).delete();
|
|
||||||
await File(tempOutput).rename(mp3Path);
|
|
||||||
_log.d('MP3 metadata embedded successfully');
|
|
||||||
return mp3Path;
|
|
||||||
} catch (e) {
|
|
||||||
_log.e('Failed to replace MP3 file after metadata embed: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final tempFile = File(tempOutput);
|
|
||||||
if (await tempFile.exists()) {
|
|
||||||
await tempFile.delete();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
_log.e('MP3 Metadata/Cover embed failed: ${result.output}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags
|
|
||||||
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
|
|
||||||
final id3Map = <String, String>{};
|
|
||||||
|
|
||||||
for (final entry in vorbisMetadata.entries) {
|
|
||||||
final key = entry.key.toUpperCase();
|
|
||||||
final value = entry.value;
|
|
||||||
|
|
||||||
// Map Vorbis comments to ID3v2 frame names
|
|
||||||
switch (key) {
|
|
||||||
case 'TITLE':
|
|
||||||
id3Map['title'] = value;
|
|
||||||
break;
|
|
||||||
case 'ARTIST':
|
|
||||||
id3Map['artist'] = value;
|
|
||||||
break;
|
|
||||||
case 'ALBUM':
|
|
||||||
id3Map['album'] = value;
|
|
||||||
break;
|
|
||||||
case 'ALBUMARTIST':
|
|
||||||
id3Map['album_artist'] = value;
|
|
||||||
break;
|
|
||||||
case 'TRACKNUMBER':
|
|
||||||
case 'TRACK':
|
|
||||||
id3Map['track'] = value;
|
|
||||||
break;
|
|
||||||
case 'DISCNUMBER':
|
|
||||||
case 'DISC':
|
|
||||||
id3Map['disc'] = value;
|
|
||||||
break;
|
|
||||||
case 'DATE':
|
|
||||||
case 'YEAR':
|
|
||||||
id3Map['date'] = value;
|
|
||||||
break;
|
|
||||||
case 'ISRC':
|
|
||||||
id3Map['TSRC'] = value; // ID3v2 ISRC frame
|
|
||||||
break;
|
|
||||||
case 'LYRICS':
|
|
||||||
case 'UNSYNCEDLYRICS':
|
|
||||||
id3Map['lyrics'] = value;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// Pass through other tags as-is
|
|
||||||
id3Map[key.toLowerCase()] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return id3Map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if FFmpeg is available
|
|
||||||
static Future<bool> isAvailable() async {
|
|
||||||
try {
|
|
||||||
final session = await FFmpegKit.execute('-version');
|
|
||||||
final returnCode = await session.getReturnCode();
|
|
||||||
return ReturnCode.isSuccess(returnCode);
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get FFmpeg version info
|
|
||||||
static Future<String?> getVersion() async {
|
|
||||||
try {
|
|
||||||
final session = await FFmpegKit.execute('-version');
|
|
||||||
return await session.getOutput();
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FFmpegResultIOS {
|
|
||||||
final bool success;
|
|
||||||
final int returnCode;
|
|
||||||
final String output;
|
|
||||||
|
|
||||||
FFmpegResultIOS({required this.success, required this.returnCode, required this.output});
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
@@ -6,6 +6,7 @@ files:
|
|||||||
# Short codes for single-variant languages
|
# Short codes for single-variant languages
|
||||||
de: de
|
de: de
|
||||||
es: es
|
es: es
|
||||||
|
es-ES: es_ES
|
||||||
fr: fr
|
fr: fr
|
||||||
hi: hi
|
hi: hi
|
||||||
id: id
|
id: id
|
||||||
@@ -13,7 +14,11 @@ files:
|
|||||||
ko: ko
|
ko: ko
|
||||||
nl: nl
|
nl: nl
|
||||||
pt: pt
|
pt: pt
|
||||||
|
pt-PT: pt_PT
|
||||||
ru: ru
|
ru: ru
|
||||||
|
tr: tr
|
||||||
|
uk: uk
|
||||||
|
zh: zh
|
||||||
# Full codes for Chinese variants
|
# Full codes for Chinese variants
|
||||||
zh-CN: zh_CN
|
zh-CN: zh_CN
|
||||||
zh-TW: zh_TW
|
zh-TW: zh_TW
|
||||||
|
|||||||
@@ -1,639 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AmazonDownloader struct {
|
|
||||||
client *http.Client
|
|
||||||
regions []string
|
|
||||||
lastAPICallTime time.Time
|
|
||||||
apiCallCount int
|
|
||||||
apiCallResetTime time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
globalAmazonDownloader *AmazonDownloader
|
|
||||||
amazonDownloaderOnce sync.Once
|
|
||||||
amazonRateLimitMu sync.Mutex
|
|
||||||
)
|
|
||||||
|
|
||||||
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
|
||||||
type DoubleDoubleSubmitResponse struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DoubleDoubleStatusResponse struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
FriendlyStatus string `json:"friendlyStatus"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Current struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Artist string `json:"artist"`
|
|
||||||
} `json:"current"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
|
||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
|
||||||
|
|
||||||
if normExpected == normFound {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
|
||||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
|
||||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
|
||||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
|
||||||
|
|
||||||
foundFirst := strings.Split(normFound, ",")[0]
|
|
||||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
|
||||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
|
||||||
foundFirst = strings.TrimSpace(foundFirst)
|
|
||||||
|
|
||||||
if expectedFirst == foundFirst {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedASCII := amazonIsASCIIString(expectedArtist)
|
|
||||||
foundASCII := amazonIsASCIIString(foundArtist)
|
|
||||||
if expectedASCII != foundASCII {
|
|
||||||
GoLog("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func amazonIsASCIIString(s string) bool {
|
|
||||||
for _, r := range s {
|
|
||||||
if r > 127 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAmazonDownloader() *AmazonDownloader {
|
|
||||||
amazonDownloaderOnce.Do(func() {
|
|
||||||
globalAmazonDownloader = &AmazonDownloader{
|
|
||||||
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
|
||||||
regions: []string{"us", "eu"}, // Same regions as PC
|
|
||||||
apiCallResetTime: time.Now(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return globalAmazonDownloader
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForRateLimit implements rate limiting similar to PC version
|
|
||||||
func (a *AmazonDownloader) waitForRateLimit() {
|
|
||||||
amazonRateLimitMu.Lock()
|
|
||||||
defer amazonRateLimitMu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
|
||||||
a.apiCallCount = 0
|
|
||||||
a.apiCallResetTime = now
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.apiCallCount >= 9 {
|
|
||||||
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
|
|
||||||
if waitTime > 0 {
|
|
||||||
GoLog("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
|
|
||||||
time.Sleep(waitTime)
|
|
||||||
a.apiCallCount = 0
|
|
||||||
a.apiCallResetTime = time.Now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !a.lastAPICallTime.IsZero() {
|
|
||||||
timeSinceLastCall := now.Sub(a.lastAPICallTime)
|
|
||||||
minDelay := 7 * time.Second
|
|
||||||
if timeSinceLastCall < minDelay {
|
|
||||||
waitTime := minDelay - timeSinceLastCall
|
|
||||||
GoLog("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
|
|
||||||
time.Sleep(waitTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a.lastAPICallTime = time.Now()
|
|
||||||
a.apiCallCount++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uses same service as PC version (doubledouble.top)
|
|
||||||
func (a *AmazonDownloader) GetAvailableAPIs() []string {
|
|
||||||
// DoubleDouble service regions (same as PC)
|
|
||||||
// Format: https://{region}.doubledouble.top
|
|
||||||
var apis []string
|
|
||||||
for _, region := range a.regions {
|
|
||||||
apis = append(apis, fmt.Sprintf("https://%s.doubledouble.top", region))
|
|
||||||
}
|
|
||||||
return apis
|
|
||||||
}
|
|
||||||
|
|
||||||
// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC)
|
|
||||||
// This uses submit → poll → download mechanism
|
|
||||||
// Internal function - not exported to gomobile
|
|
||||||
func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string) (string, string, string, error) {
|
|
||||||
var lastError error
|
|
||||||
|
|
||||||
for _, region := range a.regions {
|
|
||||||
GoLog("[Amazon] Trying region: %s...\n", region)
|
|
||||||
|
|
||||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
|
||||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
|
||||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
|
||||||
|
|
||||||
encodedURL := url.QueryEscape(amazonURL)
|
|
||||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
|
||||||
|
|
||||||
a.waitForRateLimit()
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", submitURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
lastError = fmt.Errorf("failed to create request: %w", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
fmt.Println("[Amazon] Submitting download request...")
|
|
||||||
|
|
||||||
// Retry logic for 429 errors (like PC version: 3 retries with 15s wait)
|
|
||||||
var resp *http.Response
|
|
||||||
maxRetries := 3
|
|
||||||
for retry := 0; retry < maxRetries; retry++ {
|
|
||||||
resp, err = a.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
lastError = fmt.Errorf("failed to submit request: %w", err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode == 429 { // Too Many Requests
|
|
||||||
resp.Body.Close()
|
|
||||||
if retry < maxRetries-1 {
|
|
||||||
waitTime := 15 * time.Second
|
|
||||||
GoLog("[Amazon] Rate limited (429), waiting %v before retry %d/%d...\n", waitTime, retry+2, maxRetries)
|
|
||||||
time.Sleep(waitTime)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lastError = fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
resp.Body.Close()
|
|
||||||
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Success - break retry loop
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil || lastError != nil {
|
|
||||||
if resp != nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var submitResp DoubleDoubleSubmitResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
lastError = fmt.Errorf("failed to decode submit response: %w", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
|
|
||||||
if !submitResp.Success || submitResp.ID == "" {
|
|
||||||
lastError = fmt.Errorf("submit request failed")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadID := submitResp.ID
|
|
||||||
GoLog("[Amazon] Download ID: %s\n", downloadID)
|
|
||||||
|
|
||||||
// Step 2: Poll for completion
|
|
||||||
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
|
|
||||||
fmt.Println("[Amazon] Waiting for download to complete...")
|
|
||||||
|
|
||||||
maxWait := 300 * time.Second // 5 minutes max wait
|
|
||||||
elapsed := time.Duration(0)
|
|
||||||
pollInterval := 3 * time.Second
|
|
||||||
|
|
||||||
for elapsed < maxWait {
|
|
||||||
time.Sleep(pollInterval)
|
|
||||||
elapsed += pollInterval
|
|
||||||
|
|
||||||
statusReq, err := http.NewRequest("GET", statusURL, nil)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
statusReq.Header.Set("User-Agent", getRandomUserAgent())
|
|
||||||
|
|
||||||
statusResp, err := a.client.Do(statusReq)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("\r[Amazon] Status check failed, retrying...")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if statusResp.StatusCode != 200 {
|
|
||||||
statusResp.Body.Close()
|
|
||||||
fmt.Printf("\r[Amazon] Status check failed (status %d), retrying...", statusResp.StatusCode)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var status DoubleDoubleStatusResponse
|
|
||||||
if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil {
|
|
||||||
statusResp.Body.Close()
|
|
||||||
fmt.Printf("\r[Amazon] Invalid JSON response, retrying...")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
statusResp.Body.Close()
|
|
||||||
|
|
||||||
if status.Status == "done" {
|
|
||||||
fmt.Println("\n[Amazon] Download ready!")
|
|
||||||
|
|
||||||
fileURL := status.URL
|
|
||||||
if strings.HasPrefix(fileURL, "./") {
|
|
||||||
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
|
||||||
} else if strings.HasPrefix(fileURL, "/") {
|
|
||||||
fileURL = fmt.Sprintf("%s%s", baseURL, fileURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
trackName := status.Current.Name
|
|
||||||
artist := status.Current.Artist
|
|
||||||
|
|
||||||
GoLog("[Amazon] Downloading: %s - %s\n", artist, trackName)
|
|
||||||
return fileURL, trackName, artist, nil
|
|
||||||
|
|
||||||
} else if status.Status == "error" {
|
|
||||||
errorMsg := status.FriendlyStatus
|
|
||||||
if errorMsg == "" {
|
|
||||||
errorMsg = "Unknown error"
|
|
||||||
}
|
|
||||||
lastError = fmt.Errorf("processing failed: %s", errorMsg)
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
// Still processing
|
|
||||||
friendlyStatus := status.FriendlyStatus
|
|
||||||
if friendlyStatus == "" {
|
|
||||||
friendlyStatus = status.Status
|
|
||||||
}
|
|
||||||
fmt.Printf("\r[Amazon] %s...", friendlyStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if elapsed >= maxWait {
|
|
||||||
lastError = fmt.Errorf("download timeout")
|
|
||||||
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastError != nil {
|
|
||||||
fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Initialize item progress (required for all downloads)
|
|
||||||
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 := os.Create(outputPath)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush buffer before checking for errors
|
|
||||||
flushErr := bufWriter.Flush()
|
|
||||||
closeErr := out.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
os.Remove(outputPath)
|
|
||||||
if isDownloadCancelled(itemID) {
|
|
||||||
return ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
|
||||||
}
|
|
||||||
if flushErr != nil {
|
|
||||||
os.Remove(outputPath)
|
|
||||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
os.Remove(outputPath)
|
|
||||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify file size if Content-Length was provided
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
|
||||||
os.Remove(outputPath)
|
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\r[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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uses DoubleDouble service (same as PC version)
|
|
||||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|
||||||
downloader := NewAmazonDownloader()
|
|
||||||
|
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
songlink := NewSongLinkClient()
|
|
||||||
var availability *TrackAvailability
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
|
||||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
|
||||||
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
|
||||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
|
||||||
} else if req.SpotifyID != "" {
|
|
||||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
|
||||||
} else {
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !availability.Amazon || availability.AmazonURL == "" {
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.OutputDir != "." {
|
|
||||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download using DoubleDouble service (same as PC)
|
|
||||||
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
|
||||||
if err != nil {
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify artist matches
|
|
||||||
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
|
||||||
GoLog("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
|
||||||
"title": req.TrackName,
|
|
||||||
"artist": req.ArtistName,
|
|
||||||
"album": req.AlbumName,
|
|
||||||
"track": req.TrackNumber,
|
|
||||||
"year": extractYear(req.ReleaseDate),
|
|
||||||
"disc": req.DiscNumber,
|
|
||||||
})
|
|
||||||
filename = sanitizeFilename(filename) + ".flac"
|
|
||||||
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)
|
|
||||||
parallelResult = FetchCoverAndLyricsParallel(
|
|
||||||
req.CoverURL,
|
|
||||||
req.EmbedMaxQualityCover,
|
|
||||||
req.SpotifyID,
|
|
||||||
req.TrackName,
|
|
||||||
req.ArtistName,
|
|
||||||
req.EmbedLyrics,
|
|
||||||
int64(req.DurationMS),
|
|
||||||
)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Download audio file with item ID for progress tracking
|
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
|
||||||
if errors.Is(err, ErrDownloadCancelled) {
|
|
||||||
return AmazonDownloadResult{}, ErrDownloadCancelled
|
|
||||||
}
|
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for parallel operations to complete
|
|
||||||
<-parallelDone
|
|
||||||
|
|
||||||
if req.ItemID != "" {
|
|
||||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
|
||||||
SetItemFinalizing(req.ItemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log track info from DoubleDouble (for debugging)
|
|
||||||
if trackName != "" && artistName != "" {
|
|
||||||
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
|
||||||
}
|
|
||||||
|
|
||||||
existingMeta, metaErr := ReadMetadata(outputPath)
|
|
||||||
actualTrackNum := req.TrackNumber
|
|
||||||
actualDiscNum := req.DiscNumber
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Embed metadata using Spotify data (more accurate than DoubleDouble)
|
|
||||||
// But preserve track/disc numbers from file if they were better
|
|
||||||
metadata := Metadata{
|
|
||||||
Title: req.TrackName,
|
|
||||||
Artist: req.ArtistName,
|
|
||||||
Album: req.AlbumName,
|
|
||||||
AlbumArtist: req.AlbumArtist,
|
|
||||||
Date: req.ReleaseDate,
|
|
||||||
TrackNumber: actualTrackNum,
|
|
||||||
TotalTracks: req.TotalTracks,
|
|
||||||
DiscNumber: actualDiscNum,
|
|
||||||
ISRC: req.ISRC,
|
|
||||||
Genre: req.Genre, // From Deezer album metadata
|
|
||||||
Label: req.Label, // From Deezer album metadata
|
|
||||||
Copyright: req.Copyright, // From Deezer album metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use cover data from parallel fetch
|
|
||||||
var coverData []byte
|
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
|
||||||
coverData = parallelResult.CoverData
|
|
||||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
|
||||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
|
||||||
lyricsMode := req.LyricsMode
|
|
||||||
if lyricsMode == "" {
|
|
||||||
lyricsMode = "embed" // default
|
|
||||||
}
|
|
||||||
|
|
||||||
if lyricsMode == "external" || lyricsMode == "both" {
|
|
||||||
GoLog("[Amazon] Saving external LRC file...\n")
|
|
||||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, 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" {
|
|
||||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
|
||||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
|
||||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
|
||||||
} else {
|
|
||||||
fmt.Println("[Amazon] Lyrics embedded successfully")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if req.EmbedLyrics {
|
|
||||||
fmt.Println("[Amazon] No lyrics available from parallel fetch")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
|
||||||
|
|
||||||
quality, err := GetAudioQuality(outputPath)
|
|
||||||
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(outputPath)
|
|
||||||
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
|
|
||||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
|
||||||
|
|
||||||
bitDepth := 0
|
|
||||||
sampleRate := 0
|
|
||||||
if err == nil {
|
|
||||||
bitDepth = quality.BitDepth
|
|
||||||
sampleRate = quality.SampleRate
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,607 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
// Final layout: header + items + footer
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
// ErrDownloadCancelled is returned when a download is cancelled by the user.
|
||||||
var ErrDownloadCancelled = errors.New("download cancelled")
|
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 {
|
type cancelEntry struct {
|
||||||
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
canceled bool
|
canceled bool
|
||||||
|
refs int
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cancelMu sync.Mutex
|
cancelMu sync.Mutex
|
||||||
cancelMap = make(map[string]*cancelEntry)
|
cancelMap = make(map[string]*cancelEntry)
|
||||||
|
|
||||||
|
extensionRequestCancelMu sync.Mutex
|
||||||
|
extensionRequestCancelMap = make(map[string]*cancelEntry)
|
||||||
)
|
)
|
||||||
|
|
||||||
func initDownloadCancel(itemID string) context.Context {
|
func initDownloadCancel(itemID string) context.Context {
|
||||||
@@ -27,10 +36,25 @@ func initDownloadCancel(itemID string) context.Context {
|
|||||||
cancelMu.Lock()
|
cancelMu.Lock()
|
||||||
defer cancelMu.Unlock()
|
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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancelMap[itemID] = &cancelEntry{
|
cancelMap[itemID] = &cancelEntry{
|
||||||
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
canceled: false,
|
canceled: false,
|
||||||
|
refs: 1,
|
||||||
}
|
}
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
@@ -73,6 +97,86 @@ func clearDownloadCancel(itemID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancelMu.Lock()
|
cancelMu.Lock()
|
||||||
delete(cancelMap, itemID)
|
if entry, ok := cancelMap[itemID]; ok {
|
||||||
|
entry.refs--
|
||||||
|
if entry.refs <= 0 {
|
||||||
|
delete(cancelMap, itemID)
|
||||||
|
}
|
||||||
|
}
|
||||||
cancelMu.Unlock()
|
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
|
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
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 {
|
func convertSmallToMedium(imageURL string) string {
|
||||||
if strings.Contains(imageURL, spotifySize300) {
|
if strings.Contains(imageURL, spotifySize300) {
|
||||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||||
@@ -40,7 +44,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
maxURL := upgradeToMaxQuality(downloadURL)
|
maxURL := upgradeToMaxQuality(downloadURL)
|
||||||
if maxURL != downloadURL {
|
if maxURL != downloadURL {
|
||||||
downloadURL = maxURL
|
downloadURL = maxURL
|
||||||
// Log already printed by upgradeToMaxQuality for Deezer
|
|
||||||
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||||
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
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 {
|
func upgradeToMaxQuality(coverURL string) string {
|
||||||
// Spotify CDN upgrade
|
|
||||||
if strings.Contains(coverURL, spotifySize640) {
|
if strings.Contains(coverURL, spotifySize640) {
|
||||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deezer CDN upgrade
|
|
||||||
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||||
return upgradeDeezerCover(coverURL)
|
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
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +113,6 @@ func upgradeDeezerCover(coverURL string) string {
|
|||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace any size pattern with 1800x1800
|
|
||||||
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
||||||
if upgraded != coverURL {
|
if upgraded != coverURL {
|
||||||
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
||||||
@@ -112,12 +120,35 @@ func upgradeDeezerCover(coverURL string) string {
|
|||||||
return upgraded
|
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 {
|
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||||
if imageURL == "" {
|
if imageURL == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always upgrade small to medium first
|
|
||||||
result := convertSmallToMedium(imageURL)
|
result := convertSmallToMedium(imageURL)
|
||||||
|
|
||||||
if maxQuality {
|
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", ".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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ISRCIndex holds a cached map of ISRC -> file path for fast duplicate checking
|
|
||||||
type ISRCIndex struct {
|
type ISRCIndex struct {
|
||||||
index map[string]string // ISRC (uppercase) -> file path
|
index map[string]string // ISRC (uppercase) -> file path
|
||||||
outputDir string
|
outputDir string
|
||||||
@@ -25,10 +24,7 @@ var (
|
|||||||
isrcIndexTTL = 5 * time.Minute
|
isrcIndexTTL = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetISRCIndex returns or builds an ISRC index for the given directory
|
|
||||||
// Uses per-directory mutex to prevent concurrent builds (race condition fix)
|
|
||||||
func GetISRCIndex(outputDir string) *ISRCIndex {
|
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||||
// Fast path: check cache first
|
|
||||||
isrcIndexCacheMu.RLock()
|
isrcIndexCacheMu.RLock()
|
||||||
idx, exists := isrcIndexCache[outputDir]
|
idx, exists := isrcIndexCache[outputDir]
|
||||||
isrcIndexCacheMu.RUnlock()
|
isrcIndexCacheMu.RUnlock()
|
||||||
@@ -37,14 +33,11 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return idx
|
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{})
|
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
||||||
mu := buildLock.(*sync.Mutex)
|
mu := buildLock.(*sync.Mutex)
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
// Double-check cache after acquiring lock (another goroutine may have built it)
|
|
||||||
isrcIndexCacheMu.RLock()
|
isrcIndexCacheMu.RLock()
|
||||||
idx, exists = isrcIndexCache[outputDir]
|
idx, exists = isrcIndexCache[outputDir]
|
||||||
isrcIndexCacheMu.RUnlock()
|
isrcIndexCacheMu.RUnlock()
|
||||||
@@ -56,7 +49,6 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return buildISRCIndex(outputDir)
|
return buildISRCIndex(outputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
|
||||||
func buildISRCIndex(outputDir string) *ISRCIndex {
|
func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||||
idx := &ISRCIndex{
|
idx := &ISRCIndex{
|
||||||
index: make(map[string]string),
|
index: make(map[string]string),
|
||||||
@@ -91,7 +83,7 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||||
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
||||||
|
|
||||||
isrcIndexCacheMu.Lock()
|
isrcIndexCacheMu.Lock()
|
||||||
@@ -113,7 +105,6 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
|||||||
return path, exists
|
return path, exists
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove deletes an ISRC entry from the index (internal use)
|
|
||||||
func (idx *ISRCIndex) remove(isrc string) {
|
func (idx *ISRCIndex) remove(isrc string) {
|
||||||
if isrc == "" {
|
if isrc == "" {
|
||||||
return
|
return
|
||||||
@@ -125,14 +116,11 @@ func (idx *ISRCIndex) remove(isrc string) {
|
|||||||
delete(idx.index, strings.ToUpper(isrc))
|
delete(idx.index, strings.ToUpper(isrc))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup checks if an ISRC exists in the index (gomobile compatible)
|
|
||||||
// Returns filepath if found, empty string if not found
|
|
||||||
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
||||||
path, _ := idx.lookup(isrc)
|
path, _ := idx.lookup(isrc)
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add adds a new ISRC to the index (call after successful download)
|
|
||||||
func (idx *ISRCIndex) Add(isrc, filePath string) {
|
func (idx *ISRCIndex) Add(isrc, filePath string) {
|
||||||
if isrc == "" || filePath == "" {
|
if isrc == "" || filePath == "" {
|
||||||
return
|
return
|
||||||
@@ -144,15 +132,12 @@ func (idx *ISRCIndex) Add(isrc, filePath string) {
|
|||||||
idx.index[strings.ToUpper(isrc)] = filePath
|
idx.index[strings.ToUpper(isrc)] = filePath
|
||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateCache clears the ISRC index cache for a directory
|
|
||||||
func InvalidateISRCCache(outputDir string) {
|
func InvalidateISRCCache(outputDir string) {
|
||||||
isrcIndexCacheMu.Lock()
|
isrcIndexCacheMu.Lock()
|
||||||
delete(isrcIndexCache, outputDir)
|
delete(isrcIndexCache, outputDir)
|
||||||
isrcIndexCacheMu.Unlock()
|
isrcIndexCacheMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
|
|
||||||
// Uses ISRC index for fast lookup
|
|
||||||
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||||
if isrc == "" || outputDir == "" {
|
if isrc == "" || outputDir == "" {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -173,13 +158,11 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
|||||||
return filePath, true
|
return filePath, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
|
||||||
func CheckISRCExists(outputDir, isrc string) (string, error) {
|
func CheckISRCExists(outputDir, isrc string) (string, error) {
|
||||||
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
|
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
|
||||||
return filepath, nil
|
return filepath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckFileExists checks if a file with the given name exists
|
|
||||||
func CheckFileExists(filePath string) bool {
|
func CheckFileExists(filePath string) bool {
|
||||||
info, err := os.Stat(filePath)
|
info, err := os.Stat(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -188,7 +171,6 @@ func CheckFileExists(filePath string) bool {
|
|||||||
return !info.IsDir() && info.Size() > 0
|
return !info.IsDir() && info.Size() > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileExistenceResult represents the result of checking if a file exists
|
|
||||||
type FileExistenceResult struct {
|
type FileExistenceResult struct {
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
Exists bool `json:"exists"`
|
Exists bool `json:"exists"`
|
||||||
@@ -249,8 +231,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
|
|||||||
return string(resultJSON), nil
|
return string(resultJSON), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreBuildISRCIndex pre-builds the ISRC index for a directory
|
|
||||||
// Call this when app starts or when entering album/playlist screen
|
|
||||||
func PreBuildISRCIndex(outputDir string) error {
|
func PreBuildISRCIndex(outputDir string) error {
|
||||||
if outputDir == "" {
|
if outputDir == "" {
|
||||||
return fmt.Errorf("output directory is required")
|
return fmt.Errorf("output directory is required")
|
||||||
@@ -260,7 +240,6 @@ func PreBuildISRCIndex(outputDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddToISRCIndex adds a new file to the ISRC index after successful download
|
|
||||||
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
||||||
if outputDir == "" || isrc == "" || filePath == "" {
|
if outputDir == "" || isrc == "" || filePath == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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,440 @@
|
|||||||
|
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 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"); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
|
||||||
|
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
|
||||||
|
}
|
||||||
|
if _, err := buildStoreExtensionDestPath(dir, " "); 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,390 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
extensionHealthDefaultTimeout = 4 * time.Second
|
||||||
|
extensionHealthMaxBodyBytes = 64 * 1024
|
||||||
|
extensionHealthDefaultCache = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
|
||||||
|
|
||||||
|
extensionHealthCacheMu.Lock()
|
||||||
|
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
|
||||||
|
result: result,
|
||||||
|
expiresAt: now.Add(ttl),
|
||||||
|
}
|
||||||
|
extensionHealthCacheMu.Unlock()
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
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 < 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 {
|
||||||
|
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 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":
|
||||||
|
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)
|
||||||
|
if statusCode, ok := healthNumber(rawStatus); ok {
|
||||||
|
if statusCode >= 200 && statusCode < 300 {
|
||||||
|
return "online", strings.Join(messageParts, ": "), true
|
||||||
|
}
|
||||||
|
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
|
||||||
|
return "degraded", strings.Join(messageParts, ": "), true
|
||||||
|
}
|
||||||
|
if statusCode == http.StatusInternalServerError && hasOK && okValue {
|
||||||
|
return "online", strings.Join(messageParts, ": "), true
|
||||||
|
}
|
||||||
|
return "offline", strings.Join(messageParts, ": "), true
|
||||||
|
}
|
||||||
|
|
||||||
|
if isExtensionHealthAuthRequired(detail) {
|
||||||
|
return "degraded", strings.Join(messageParts, ": "), true
|
||||||
|
}
|
||||||
|
if hasOK {
|
||||||
|
if okValue {
|
||||||
|
return "online", strings.Join(messageParts, ": "), true
|
||||||
|
}
|
||||||
|
return "offline", strings.Join(messageParts, ": "), true
|
||||||
|
}
|
||||||
|
if !hasStatus {
|
||||||
|
return "unknown", strings.Join(messageParts, ": "), true
|
||||||
|
}
|
||||||
|
|
||||||
|
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
|
||||||
|
switch statusString {
|
||||||
|
case "ok", "up", "online", "healthy", "operational":
|
||||||
|
return "online", strings.Join(messageParts, ": "), true
|
||||||
|
case "degraded", "partial", "warning", "warn":
|
||||||
|
return "degraded", strings.Join(messageParts, ": "), true
|
||||||
|
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
||||||
|
return "offline", strings.Join(messageParts, ": "), true
|
||||||
|
default:
|
||||||
|
return "unknown", strings.Join(messageParts, ": "), 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 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,143 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"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 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,4 +1,3 @@
|
|||||||
// Package gobackend provides extension manifest parsing and validation
|
|
||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,15 +6,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExtensionType represents the type of extension
|
|
||||||
type ExtensionType string
|
type ExtensionType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
|
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
|
||||||
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
||||||
|
ExtensionTypeLyricsProvider ExtensionType = "lyrics_provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SettingType represents the type of a setting field
|
|
||||||
type SettingType string
|
type SettingType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -26,14 +24,13 @@ const (
|
|||||||
SettingTypeButton SettingType = "button" // Action button that calls a JS function
|
SettingTypeButton SettingType = "button" // Action button that calls a JS function
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExtensionPermissions defines what resources an extension can access
|
|
||||||
type ExtensionPermissions struct {
|
type ExtensionPermissions struct {
|
||||||
Network []string `json:"network"` // List of allowed domains
|
Network []string `json:"network"`
|
||||||
Storage bool `json:"storage"` // Whether extension can use storage API
|
Storage bool `json:"storage"`
|
||||||
File bool `json:"file"` // Whether extension can use file API
|
File bool `json:"file"`
|
||||||
|
AllowHTTP bool `json:"allowHttp,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionSetting defines a configurable setting for an extension
|
|
||||||
type ExtensionSetting struct {
|
type ExtensionSetting struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Type SettingType `json:"type"`
|
Type SettingType `json:"type"`
|
||||||
@@ -42,19 +39,17 @@ type ExtensionSetting struct {
|
|||||||
Required bool `json:"required,omitempty"`
|
Required bool `json:"required,omitempty"`
|
||||||
Secret bool `json:"secret,omitempty"`
|
Secret bool `json:"secret,omitempty"`
|
||||||
Default interface{} `json:"default,omitempty"`
|
Default interface{} `json:"default,omitempty"`
|
||||||
Options []string `json:"options,omitempty"` // For select type
|
Options []string `json:"options,omitempty"`
|
||||||
Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin")
|
Action string `json:"action,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QualityOption represents a quality option for download providers
|
|
||||||
type QualityOption struct {
|
type QualityOption struct {
|
||||||
ID string `json:"id"` // Unique identifier (e.g., "mp3_320", "opus_128")
|
ID string `json:"id"`
|
||||||
Label string `json:"label"` // Display name (e.g., "MP3 320kbps")
|
Label string `json:"label"`
|
||||||
Description string `json:"description"` // Optional description (e.g., "Best quality MP3")
|
Description string `json:"description"`
|
||||||
Settings []QualitySpecificSetting `json:"settings,omitempty"` // Quality-specific settings
|
Settings []QualitySpecificSetting `json:"settings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QualitySpecificSetting represents a setting that's specific to a quality option
|
|
||||||
type QualitySpecificSetting struct {
|
type QualitySpecificSetting struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Type SettingType `json:"type"`
|
Type SettingType `json:"type"`
|
||||||
@@ -63,72 +58,85 @@ type QualitySpecificSetting struct {
|
|||||||
Required bool `json:"required,omitempty"`
|
Required bool `json:"required,omitempty"`
|
||||||
Secret bool `json:"secret,omitempty"`
|
Secret bool `json:"secret,omitempty"`
|
||||||
Default interface{} `json:"default,omitempty"`
|
Default interface{} `json:"default,omitempty"`
|
||||||
Options []string `json:"options,omitempty"` // For select type
|
Options []string `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchFilter struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchBehaviorConfig defines custom search behavior for an extension
|
|
||||||
type SearchBehaviorConfig struct {
|
type SearchBehaviorConfig struct {
|
||||||
Enabled bool `json:"enabled"` // Whether extension provides custom search
|
Enabled bool `json:"enabled"`
|
||||||
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box
|
Placeholder string `json:"placeholder,omitempty"`
|
||||||
Primary bool `json:"primary,omitempty"` // If true, show as primary search tab
|
Primary bool `json:"primary,omitempty"`
|
||||||
Icon string `json:"icon,omitempty"` // Icon for search tab
|
Icon string `json:"icon,omitempty"`
|
||||||
ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
ThumbnailRatio string `json:"thumbnailRatio,omitempty"`
|
||||||
ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels
|
ThumbnailWidth int `json:"thumbnailWidth,omitempty"`
|
||||||
ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels
|
ThumbnailHeight int `json:"thumbnailHeight,omitempty"`
|
||||||
|
Filters []SearchFilter `json:"filters,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// URLHandlerConfig defines custom URL handling for an extension
|
|
||||||
type URLHandlerConfig struct {
|
type URLHandlerConfig struct {
|
||||||
Enabled bool `json:"enabled"` // Whether extension handles URLs
|
Enabled bool `json:"enabled"`
|
||||||
Patterns []string `json:"patterns,omitempty"` // URL patterns to match (e.g., "music.youtube.com", "soundcloud.com")
|
Patterns []string `json:"patterns,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackMatchingConfig defines custom track matching behavior
|
|
||||||
type TrackMatchingConfig struct {
|
type TrackMatchingConfig struct {
|
||||||
CustomMatching bool `json:"customMatching"` // Whether extension handles matching
|
CustomMatching bool `json:"customMatching"`
|
||||||
Strategy string `json:"strategy,omitempty"` // "isrc", "name", "duration", "custom"
|
Strategy string `json:"strategy,omitempty"`
|
||||||
DurationTolerance int `json:"durationTolerance,omitempty"` // Tolerance in seconds for duration matching
|
DurationTolerance int `json:"durationTolerance,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostProcessingHook defines a post-processing hook
|
|
||||||
type PostProcessingHook struct {
|
type PostProcessingHook struct {
|
||||||
ID string `json:"id"` // Unique identifier
|
ID string `json:"id"`
|
||||||
Name string `json:"name"` // Display name
|
Name string `json:"name"`
|
||||||
Description string `json:"description,omitempty"` // Description
|
Description string `json:"description,omitempty"`
|
||||||
DefaultEnabled bool `json:"defaultEnabled,omitempty"` // Whether enabled by default
|
DefaultEnabled bool `json:"defaultEnabled,omitempty"`
|
||||||
SupportedFormats []string `json:"supportedFormats,omitempty"` // Supported file formats (e.g., ["flac", "mp3"])
|
SupportedFormats []string `json:"supportedFormats,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostProcessingConfig defines post-processing capabilities
|
|
||||||
type PostProcessingConfig struct {
|
type PostProcessingConfig struct {
|
||||||
Enabled bool `json:"enabled"` // Whether extension provides post-processing
|
Enabled bool `json:"enabled"`
|
||||||
Hooks []PostProcessingHook `json:"hooks,omitempty"` // Available hooks
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionManifest represents the manifest.json of an extension
|
|
||||||
type ExtensionManifest struct {
|
type ExtensionManifest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Author string `json:"author"`
|
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Homepage string `json:"homepage,omitempty"`
|
Homepage string `json:"homepage,omitempty"`
|
||||||
Icon string `json:"icon,omitempty"` // Icon filename (e.g., "icon.png")
|
Icon string `json:"icon,omitempty"`
|
||||||
Types []ExtensionType `json:"type"`
|
Types []ExtensionType `json:"type"`
|
||||||
Permissions ExtensionPermissions `json:"permissions"`
|
Permissions ExtensionPermissions `json:"permissions"`
|
||||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"` // Custom quality options for download providers
|
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"` // If true, don't enrich metadata from Deezer/Spotify
|
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"` // If true, don't fallback to built-in providers (tidal/qobuz/amazon)
|
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"` // Custom search behavior
|
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
||||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"` // Custom URL handling
|
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"` // Custom track matching
|
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"` // Post-processing hooks
|
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"` // Extension capabilities (homeFeed, browseCategories, etc.)
|
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||||
|
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||||
|
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
||||||
|
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManifestValidationError represents a validation error in the manifest
|
|
||||||
type ManifestValidationError struct {
|
type ManifestValidationError struct {
|
||||||
Field string
|
Field string
|
||||||
Message string
|
Message string
|
||||||
@@ -138,7 +146,6 @@ func (e *ManifestValidationError) Error() string {
|
|||||||
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
|
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseManifest parses and validates a manifest from JSON bytes
|
|
||||||
func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
||||||
var manifest ExtensionManifest
|
var manifest ExtensionManifest
|
||||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
@@ -161,10 +168,6 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
return &ManifestValidationError{Field: "version", Message: "version is required"}
|
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) == "" {
|
if strings.TrimSpace(m.Description) == "" {
|
||||||
return &ManifestValidationError{Field: "description", Message: "description is required"}
|
return &ManifestValidationError{Field: "description", Message: "description is required"}
|
||||||
}
|
}
|
||||||
@@ -174,15 +177,14 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, t := range m.Types {
|
for _, t := range m.Types {
|
||||||
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
|
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider && t != ExtensionTypeLyricsProvider {
|
||||||
return &ManifestValidationError{
|
return &ManifestValidationError{
|
||||||
Field: "type",
|
Field: "type",
|
||||||
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider' or 'download_provider')", t),
|
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider', 'download_provider', or 'lyrics_provider')", t),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate settings if present
|
|
||||||
for i, setting := range m.Settings {
|
for i, setting := range m.Settings {
|
||||||
if strings.TrimSpace(setting.Key) == "" {
|
if strings.TrimSpace(setting.Key) == "" {
|
||||||
return &ManifestValidationError{
|
return &ManifestValidationError{
|
||||||
@@ -214,10 +216,31 @@ 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasType checks if the extension has a specific type
|
|
||||||
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
||||||
for _, et := range m.Types {
|
for _, et := range m.Types {
|
||||||
if et == t {
|
if et == t {
|
||||||
@@ -227,17 +250,25 @@ func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsMetadataProvider returns true if extension provides metadata
|
|
||||||
func (m *ExtensionManifest) IsMetadataProvider() bool {
|
func (m *ExtensionManifest) IsMetadataProvider() bool {
|
||||||
return m.HasType(ExtensionTypeMetadataProvider)
|
return m.HasType(ExtensionTypeMetadataProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsDownloadProvider returns true if extension provides downloads
|
|
||||||
func (m *ExtensionManifest) IsDownloadProvider() bool {
|
func (m *ExtensionManifest) IsDownloadProvider() bool {
|
||||||
return m.HasType(ExtensionTypeDownloadProvider)
|
return m.HasType(ExtensionTypeDownloadProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsDomainAllowed checks if a domain is in the allowed network permissions
|
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 {
|
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||||
for _, allowed := range m.Permissions.Network {
|
for _, allowed := range m.Permissions.Network {
|
||||||
@@ -247,7 +278,7 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
|||||||
}
|
}
|
||||||
// Support wildcard subdomains (e.g., *.example.com)
|
// Support wildcard subdomains (e.g., *.example.com)
|
||||||
if strings.HasPrefix(allowed, "*.") {
|
if strings.HasPrefix(allowed, "*.") {
|
||||||
suffix := allowed[1:] // Remove the *
|
suffix := allowed[1:]
|
||||||
if strings.HasSuffix(domain, suffix) {
|
if strings.HasSuffix(domain, suffix) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -256,27 +287,22 @@ func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasCustomSearch returns true if extension provides custom search
|
|
||||||
func (m *ExtensionManifest) HasCustomSearch() bool {
|
func (m *ExtensionManifest) HasCustomSearch() bool {
|
||||||
return m.SearchBehavior != nil && m.SearchBehavior.Enabled
|
return m.SearchBehavior != nil && m.SearchBehavior.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasCustomMatching returns true if extension provides custom track matching
|
|
||||||
func (m *ExtensionManifest) HasCustomMatching() bool {
|
func (m *ExtensionManifest) HasCustomMatching() bool {
|
||||||
return m.TrackMatching != nil && m.TrackMatching.CustomMatching
|
return m.TrackMatching != nil && m.TrackMatching.CustomMatching
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasPostProcessing returns true if extension provides post-processing
|
|
||||||
func (m *ExtensionManifest) HasPostProcessing() bool {
|
func (m *ExtensionManifest) HasPostProcessing() bool {
|
||||||
return m.PostProcessing != nil && m.PostProcessing.Enabled
|
return m.PostProcessing != nil && m.PostProcessing.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasURLHandler returns true if extension handles custom URLs
|
|
||||||
func (m *ExtensionManifest) HasURLHandler() bool {
|
func (m *ExtensionManifest) HasURLHandler() bool {
|
||||||
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
|
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchesURL checks if a URL matches any of the extension's URL patterns
|
|
||||||
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||||
if !m.HasURLHandler() {
|
if !m.HasURLHandler() {
|
||||||
return false
|
return false
|
||||||
@@ -285,7 +311,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
|||||||
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
||||||
for _, pattern := range m.URLHandler.Patterns {
|
for _, pattern := range m.URLHandler.Patterns {
|
||||||
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
||||||
// Check if URL contains the pattern (host match)
|
|
||||||
if strings.Contains(urlStr, pattern) {
|
if strings.Contains(urlStr, pattern) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -293,7 +318,6 @@ func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPostProcessingHooks returns all post-processing hooks
|
|
||||||
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
||||||
if m.PostProcessing == nil {
|
if m.PostProcessing == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -301,7 +325,6 @@ func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
|||||||
return m.PostProcessing.Hooks
|
return m.PostProcessing.Hooks
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToJSON serializes the manifest to JSON
|
|
||||||
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
|
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
|
||||||
return json.Marshal(m)
|
return json.Marshal(m)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||