Compare commits
766 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| abc599d7f9 | |||
| eefbb63299 | |||
| fdbb474763 | |||
| 6a7eef6956 | |||
| 9b27e86e0f | |||
| dbe8f5d814 | |||
| 9847594ca1 | |||
| 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 | |||
| 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 | |||
| 6388f3a5b8 | |||
| 22f52f4af2 | |||
| ceaaff8c9b | |||
| a318495046 | |||
| 8ffc6d3821 | |||
| 2036e46da0 | |||
| b82000e87c | |||
| 144906fd8f | |||
| 8a109e9013 | |||
| ba05f6b470 | |||
| 2f80ae7e84 | |||
| e248fef130 | |||
| 174724ddd3 | |||
| 730945d892 | |||
| 4abdce8c58 | |||
| 55b75dc48d | |||
| f6cea1a683 | |||
| 8d205600b8 | |||
| aa35f60fad | |||
| b627ae1874 | |||
| 46afa6e733 | |||
| c01b189477 | |||
| 966935b677 | |||
| f2f8ca4528 | |||
| 7844bd2f42 | |||
| ac3d51e2cd | |||
| b899b54bb8 | |||
| 7a17de49b2 | |||
| 79180dd918 | |||
| 0d98ada479 | |||
| 5d4fc10ab7 | |||
| e37dfeb080 | |||
| eddae2a9dd | |||
| 6bd7eec615 | |||
| b240e91290 | |||
| 4e0149df29 | |||
| 065872e686 | |||
| 7ab0f5b7c8 | |||
| fd31682242 | |||
| 56c8b62fcf | |||
| c3f879346a | |||
| 6da65ed033 | |||
| 553c6b6c4a | |||
| ac5f74a48f | |||
| e725a7be77 | |||
| 2d22d85c49 | |||
| d960708dac | |||
| c62ad005f5 | |||
| 3edfe8e8bb | |||
| 68fa1bfdae | |||
| 6f9722e05b | |||
| bd6b23400e | |||
| 066d35967e | |||
| b6d2fea847 | |||
| 2b932cff70 | |||
| f356e53f7e | |||
| bb1ff187a3 | |||
| d99a1b1c21 | |||
| c36497e87c | |||
| a32487ad88 | |||
| bd4946db37 | |||
| 69f143dd9d | |||
| 15408bfa1c | |||
| edc715021d | |||
| 392472b027 | |||
| 69741fa47c | |||
| 484720bcda | |||
| f3cc51fb06 | |||
| 452ea7084a | |||
| bba059fc44 | |||
| 3f75cace2b | |||
| 03027813c1 | |||
| 8e9d0c3e9a | |||
| 6c8813c9de | |||
| ec314eb479 | |||
| 77e4457244 | |||
| 0119db094d | |||
| 9c35515d6f | |||
| 1546d7da22 | |||
| 61720f3f2a | |||
| 7749399239 | |||
| d143b82068 | |||
| 606e7c1079 | |||
| a650632c4e | |||
| 3c118f74e4 | |||
| bc3055f6e1 | |||
| 7c86ae0b7e | |||
| 595bfb2711 | |||
| 5f39a3d52f | |||
| e7077781e6 | |||
| 42d15db4ca | |||
| c2599981d6 | |||
| a1647a41ff | |||
| bf2fc7702b | |||
| f814408702 | |||
| 6b1958bfd0 | |||
| bc120ffa76 | |||
| 5ea454a0b0 | |||
| da574f895c | |||
| 1c445e91d9 | |||
| 5d03eb0656 | |||
| becb6845a6 | |||
| be3ee3b216 | |||
| 3747674968 | |||
| ff9d088c5f | |||
| 12db11d559 | |||
| 7e1aca33a5 | |||
| 07a1c68354 | |||
| f4d7c6531f | |||
| e9ca054682 | |||
| 1069bdd0d8 | |||
| ff882a58d7 | |||
| dddc8c3d94 | |||
| 720525b67b | |||
| cc12f63d36 | |||
| 5c67553596 | |||
| 0ccda8db58 | |||
| 556c0e1db2 | |||
| 6d7b89b881 | |||
| 47777b4343 | |||
| 2eb1d2a65d | |||
| ce057c6473 | |||
| 46cfe8b632 | |||
| 2e5eff6e3d | |||
| dd506efeb6 | |||
| 9897d3102e | |||
| 8d92d22fda | |||
| b99764b1ad | |||
| 621582cf11 | |||
| b96233f90b | |||
| 88dfb88bcc | |||
| 75bfe9b3bf | |||
| be9444c76b | |||
| 65e21a421d | |||
| 87b33dda7e | |||
| 2f097c8f6c | |||
| 8cbdea1417 | |||
| 48bdd154f6 | |||
| ae0e157c34 | |||
| 53fcdd9a47 | |||
| 3d6be3bf92 | |||
| 2d7fba3f52 | |||
| e02d8ff2cd | |||
| f8cee25958 | |||
| 99c133aae1 | |||
| cedb32904e | |||
| e73f932083 | |||
| 4645d3ac8b | |||
| 1cdf8b7f23 | |||
| 1e18f53e6a | |||
| fc8cfb05d0 | |||
| fc0c0571fe | |||
| e6ca29e199 | |||
| 7413a8a698 | |||
| 205032e094 | |||
| 9c6f438e22 | |||
| 4f2587554a | |||
| 369fdd84bf | |||
| 5c3b668e92 | |||
| 141db45051 | |||
| 8f9bc8f058 | |||
| be372604fe | |||
| 6c25fc6a8d | |||
| 2eef021587 | |||
| f4fe74f972 | |||
| 9eac6e6e56 | |||
| e5c310f455 | |||
| d8f73dfa56 | |||
| f128d0caf0 | |||
| aa499ceba2 | |||
| 01306afc2d | |||
| 9a3cd0273b | |||
| ac25683f33 | |||
| 624b2112d8 | |||
| 8bd34dc87e | |||
| 948779bcfc | |||
| a74b3a19f7 | |||
| 931d9fbf61 | |||
| a8c76004db | |||
| 0df4596f79 | |||
| cf549df049 | |||
| bd3783154b | |||
| 6919408905 | |||
| f4c08a5981 | |||
| 7fff55da96 | |||
| 3c4dbd1a80 | |||
| f26af38c1e | |||
| 7c6705c75c | |||
| b193bc0b8f | |||
| 1a90887465 | |||
| 82440affac | |||
| 6d2f75c5dc | |||
| 18bc079632 | |||
| 4091a9c499 | |||
| 9346f2d149 | |||
| 8ab52959e8 | |||
| bad95e99c8 | |||
| dbd7fd70be | |||
| 125d070cfe | |||
| 15acf181d1 | |||
| e049f9b868 | |||
| 6a886c5276 | |||
| 1ec190bfe7 | |||
| 7ca032b3f5 | |||
| 13b917d1a0 | |||
| 961072e2ac | |||
| 8a7815268b | |||
| c7e1ffd926 | |||
| 729ab01a5f | |||
| 0a16be4395 | |||
| 47cdb5564a | |||
| f7d5a24d17 | |||
| 8daff4d0a4 | |||
| a38d66fd41 | |||
| 0cab01780d | |||
| 4afc14dee8 | |||
| 00753ffe86 | |||
| 523b1edc44 | |||
| 4966a84614 | |||
| 9247a775fa | |||
| b185b51b31 | |||
| d98960d053 | |||
| d417743654 | |||
| c4bea124fb | |||
| c37410b5de | |||
| b90c94125c | |||
| efbf5d4c5b | |||
| 35532b0c73 | |||
| 4c09b988e4 | |||
| c673581c32 | |||
| bcd718b178 | |||
| 2b9357cb6d | |||
| 26d84041c7 | |||
| 93b4047143 | |||
| a6d488696b | |||
| 3dbd131e49 | |||
| 57cb575483 | |||
| 24ef66be4c | |||
| d07a49f605 | |||
| 4eba28db7a | |||
| b73a3f8912 | |||
| 9f47f2ce85 | |||
| f2aca734a3 | |||
| 09cb637a86 | |||
| 11e7034cec | |||
| f12c18d76b | |||
| 0da39a1b8b | |||
| f29fe5054c | |||
| c8c0164964 | |||
| 52dd657913 | |||
| c30f9fe412 | |||
| bea5dd1d4a | |||
| 8726a0858a | |||
| 74bc747599 | |||
| cbc8fdcb0c | |||
| 3b79b4f1ca | |||
| 5692a76650 | |||
| 7a009ad0af | |||
| e5e75e7092 | |||
| 01b8fd2480 | |||
| ee807a44cc | |||
| c9b905eb18 | |||
| e9c7bf830e | |||
| 8bc97d5bd3 | |||
| f2c241c323 | |||
| 9c512ffe28 | |||
| 53a1da6249 | |||
| d4274e8ca8 | |||
| 49a9f12841 | |||
| d7fa040e3c | |||
| 9baa1e2088 | |||
| 482457205a | |||
| 3b2ec319e2 | |||
| a0f7e75a9a | |||
| c725e53e4c | |||
| 1d7c43a302 | |||
| df7c1c5bb7 | |||
| bb05353b7e | |||
| 7ac92d77e5 | |||
| cf00ecb756 | |||
| 525f2fd0cd | |||
| 3e841cef06 | |||
| a8527df80a | |||
| 51b2ad5c77 | |||
| d641a517b8 | |||
| 608fa2ca74 | |||
| 343b309314 | |||
| 0787b32dd8 | |||
| 6927fdf7a9 | |||
| fe6af34478 | |||
| 85bb67da47 | |||
| 794486a200 | |||
| 8ce5e958ee | |||
| 5c6bf02f1c | |||
| 852335f794 | |||
| b87de1f00a | |||
| 8fcb389bb2 | |||
| 08bca30fcd | |||
| a7c5afdd20 | |||
| 5eac386eba | |||
| d35d60ac7d | |||
| 7c43d4bf70 | |||
| 2043370b6c | |||
| 39ddb7a14f | |||
| bd9b527161 | |||
| 39bcc2c547 | |||
| 973c2e3b41 | |||
| 62805720da | |||
| 0d8234ccd2 | |||
| 0edd616c3d | |||
| 9ca0e8cf5c | |||
| 37b8682faa | |||
| 6563f0f2b3 | |||
| 562fd4d7bb | |||
| 7aa3e77df1 | |||
| 4caa803eb2 | |||
| 6d5c9d0f91 | |||
| 1b2ad4cdd5 | |||
| 33e8ddd758 | |||
| d227d57545 | |||
| db1439e08f | |||
| 47e7850ee0 | |||
| 3ea665dab4 | |||
| bd4acdf222 | |||
| 8ac679003e | |||
| 6a1265eac3 | |||
| 9570547ff9 | |||
| ef62fb218a | |||
| ba5c91090c | |||
| c454bcd5ee | |||
| 4d2ee6fca6 | |||
| 89851bbd62 | |||
| 2c614f9e2f | |||
| f36bee1095 | |||
| e4218a1894 | |||
| db335f5ba6 | |||
| ab9869a849 | |||
| 34791310b7 | |||
| 97e366b5ef | |||
| 3a4019a55e | |||
| 320bc09b57 | |||
| c033c73a95 | |||
| d4d3a48167 | |||
| 52310dd801 | |||
| f374af2023 | |||
| b0016fb510 | |||
| b3f5c7b2dc |
@@ -0,0 +1,3 @@
|
||||
github: zarzet
|
||||
ko_fi: zarzet
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug! Please fill out the form below.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched existing issues and this bug hasn't been reported yet
|
||||
required: true
|
||||
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is
|
||||
placeholder: Describe the bug...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Steps to reproduce the behavior
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
placeholder: Describe what you expected...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
placeholder: Describe what actually happened...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: App Version
|
||||
description: Which version of SpotiFLAC are you using? (Check in Settings > About)
|
||||
placeholder: "e.g., v2.2.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Which platform are you using?
|
||||
options:
|
||||
- Android
|
||||
- iOS
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: Device & OS Version
|
||||
description: What device and OS version are you using?
|
||||
placeholder: "e.g., Samsung Galaxy S24, Android 14"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: download-service
|
||||
attributes:
|
||||
label: Download Service
|
||||
description: Which download service were you using when the bug occurred?
|
||||
options:
|
||||
- Tidal
|
||||
- Qobuz
|
||||
- Amazon Music
|
||||
- Deezer (search only)
|
||||
- Not applicable
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / Screenshots
|
||||
description: |
|
||||
If applicable, add logs or screenshots to help explain your problem.
|
||||
|
||||
**To get logs:**
|
||||
1. Go to Settings > Options > Detailed Logging (turn ON)
|
||||
2. Reproduce the bug
|
||||
3. Go to Settings > Logs
|
||||
4. Tap Share button to export logs
|
||||
placeholder: Paste logs or drag & drop screenshots here...
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context about the problem
|
||||
placeholder: Add any other context...
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: README
|
||||
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
|
||||
about: Check the README for setup instructions and FAQ
|
||||
- name: Extension Development Guide
|
||||
url: https://zarz.moe/docs
|
||||
about: Documentation for building SpotiFLAC extensions
|
||||
@@ -0,0 +1,111 @@
|
||||
name: Download Issue
|
||||
description: Report issues with downloading specific tracks or albums
|
||||
title: "[Download]: "
|
||||
labels: ["download-issue"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Having trouble downloading a specific track or album? Please provide details below.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
|
||||
required: true
|
||||
- label: I am using the latest version of SpotiFLAC (Stable Version)
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: issue-type
|
||||
attributes:
|
||||
label: Issue Type
|
||||
description: What kind of download issue are you experiencing?
|
||||
options:
|
||||
- Track not found on service
|
||||
- Wrong track downloaded
|
||||
- Download fails/errors
|
||||
- Metadata incorrect
|
||||
- Audio quality issue
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: spotify-url
|
||||
attributes:
|
||||
label: Spotify URL
|
||||
description: The Spotify URL of the track/album you're trying to download
|
||||
placeholder: "https://open.spotify.com/track/..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: track-info
|
||||
attributes:
|
||||
label: Track Info
|
||||
description: Artist name and track title
|
||||
placeholder: "Artist - Track Title"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: download-service
|
||||
attributes:
|
||||
label: Download Service
|
||||
description: Which service did you try to download from?
|
||||
options:
|
||||
- Tidal
|
||||
- Qobuz
|
||||
- Amazon Music
|
||||
- All services
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: search-service
|
||||
attributes:
|
||||
label: Search Service
|
||||
description: Which search service are you using?
|
||||
options:
|
||||
- Spotify
|
||||
- Deezer
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Describe the issue in detail
|
||||
placeholder: |
|
||||
What happened? What did you expect?
|
||||
If wrong track was downloaded, what track was downloaded instead?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: App Version
|
||||
description: Which version of SpotiFLAC are you using?
|
||||
placeholder: "e.g., v2.2.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots / Logs
|
||||
description: |
|
||||
If applicable, add screenshots or logs.
|
||||
|
||||
**To get logs:**
|
||||
1. Go to Settings > Options > Detailed Logging (turn ON)
|
||||
2. Try downloading the track again
|
||||
3. Go to Settings > Logs
|
||||
4. Tap Share button to export logs
|
||||
placeholder: Drag & drop screenshots or paste logs here...
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Extension API Feature Request
|
||||
description: Request new API features or capabilities for extension development
|
||||
title: "[Extension API]: "
|
||||
labels: ["enhancement", "extension-api"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for helping improve the SpotiFLAC Extension API!
|
||||
This form is for extension developers who need new features or capabilities that don't exist yet.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have read the [Extension Development Guide](https://github.com/zarzet/SpotiFLAC-Mobile/blob/main/docs/EXTENSION_DEVELOPMENT.md)
|
||||
required: true
|
||||
- label: I have searched existing issues and this API feature hasn't been requested yet
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extension_goal
|
||||
attributes:
|
||||
label: What are you trying to build?
|
||||
description: Describe the extension or feature you're developing
|
||||
placeholder: "I'm building an extension that downloads from [service name] / provides metadata from [source]..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: current_limitation
|
||||
attributes:
|
||||
label: Current API Limitation
|
||||
description: What's missing or limiting in the current extension API?
|
||||
placeholder: |
|
||||
The current API doesn't support:
|
||||
- [missing feature 1]
|
||||
- [missing feature 2]
|
||||
|
||||
This prevents me from...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed_api
|
||||
attributes:
|
||||
label: Proposed API / Feature
|
||||
description: Describe the API or feature you'd like to see added
|
||||
placeholder: |
|
||||
I would like to have:
|
||||
- A new function `api.newFeature()` that does X
|
||||
- A new manifest field `newOption` that enables Y
|
||||
- Access to Z capability...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: use_case
|
||||
attributes:
|
||||
label: Use Case Example
|
||||
description: Provide a code example of how you would use this feature
|
||||
placeholder: |
|
||||
```javascript
|
||||
// Example usage in extension code
|
||||
function download(request, progressCallback) {
|
||||
const result = api.proposedFeature(params);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: api_category
|
||||
attributes:
|
||||
label: API Category
|
||||
description: What category does this feature fall under?
|
||||
options:
|
||||
- HTTP/Network API
|
||||
- File System API
|
||||
- Storage API
|
||||
- FFmpeg/Audio Processing
|
||||
- Manifest Options
|
||||
- Runtime Functions
|
||||
- UI Integration
|
||||
- Authentication
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: How critical is this for your extension?
|
||||
options:
|
||||
- Blocker - Cannot build my extension without this
|
||||
- High - Major functionality depends on this
|
||||
- Medium - Would significantly improve my extension
|
||||
- Low - Nice to have
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: workaround
|
||||
attributes:
|
||||
label: Current Workaround
|
||||
description: Are you using any workaround currently? If so, describe it.
|
||||
placeholder: "Currently I'm working around this by..."
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, links to similar APIs, or examples from other platforms
|
||||
placeholder: "Similar feature in other platforms: ..."
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for suggesting a feature! Please fill out the form below.
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched existing issues and this feature hasn't been requested yet
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem / Motivation
|
||||
description: Is your feature request related to a problem? Please describe.
|
||||
placeholder: "A clear description of what the problem is. Ex: I'm always frustrated when..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the solution you'd like
|
||||
placeholder: A clear description of what you want to happen...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Describe any alternative solutions or features you've considered
|
||||
placeholder: Other approaches you've thought about...
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Category
|
||||
description: What category does this feature fall under?
|
||||
options:
|
||||
- UI/UX Improvement
|
||||
- Download Feature
|
||||
- New Service Integration
|
||||
- Metadata/Tagging
|
||||
- Performance
|
||||
- Settings/Configuration
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, mockups, or screenshots about the feature request
|
||||
placeholder: Add any other context or screenshots...
|
||||
@@ -1,76 +0,0 @@
|
||||
name: Android Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
- name: Install Android SDK & NDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install gomobile
|
||||
run: |
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
gomobile init
|
||||
|
||||
- name: Build Go backend for Android
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
mkdir -p ../android/app/libs
|
||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Generate app icons
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
- name: Build APK (Release)
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Build App Bundle (Release)
|
||||
run: flutter build appbundle --release
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: SpotiFLAC-Android-APK
|
||||
path: build/app/outputs/flutter-apk/app-release.apk
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload AAB artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: SpotiFLAC-Android-AAB
|
||||
path: build/app/outputs/bundle/release/app-release.aab
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,77 @@
|
||||
name: Auto Tag on Version Change
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'pubspec.yaml'
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2 # Need previous commit to compare
|
||||
|
||||
- name: Get current version
|
||||
id: current
|
||||
run: |
|
||||
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Current version: $VERSION"
|
||||
|
||||
- name: Get previous version
|
||||
id: previous
|
||||
run: |
|
||||
git checkout HEAD~1 -- pubspec.yaml 2>/dev/null || echo "version: 0.0.0" > pubspec.yaml.old
|
||||
if [ -f pubspec.yaml.old ]; then
|
||||
VERSION=$(grep '^version:' pubspec.yaml.old | sed 's/version: //' | cut -d'+' -f1)
|
||||
else
|
||||
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||
fi
|
||||
git checkout HEAD -- pubspec.yaml
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Previous version: $VERSION"
|
||||
|
||||
- name: Check if version changed
|
||||
id: check
|
||||
run: |
|
||||
CURRENT="${{ steps.current.outputs.version }}"
|
||||
PREVIOUS="${{ steps.previous.outputs.version }}"
|
||||
|
||||
if [ "$CURRENT" != "$PREVIOUS" ]; then
|
||||
echo "Version changed from $PREVIOUS to $CURRENT"
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Version unchanged: $CURRENT"
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check if tag exists
|
||||
id: tag_exists
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
run: |
|
||||
TAG="v${{ steps.current.outputs.version }}"
|
||||
if git ls-remote --tags origin | grep -q "refs/tags/$TAG"; then
|
||||
echo "Tag $TAG already exists"
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Tag $TAG does not exist"
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create and push tag
|
||||
if: steps.check.outputs.changed == 'true' && steps.tag_exists.outputs.exists == 'false'
|
||||
run: |
|
||||
TAG="v${{ steps.current.outputs.version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$TAG" -m "Release $TAG"
|
||||
git push origin "$TAG"
|
||||
echo "Created and pushed tag: $TAG"
|
||||
@@ -1,73 +0,0 @@
|
||||
name: iOS Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-ios:
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
- name: Install gomobile
|
||||
run: |
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
gomobile init
|
||||
|
||||
- name: Build Go backend for iOS (XCFramework)
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
mkdir -p ../ios/Frameworks
|
||||
gomobile bind -target=ios -o ../ios/Frameworks/Gobackend.xcframework .
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Generate app icons
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
- name: Build iOS (no codesign)
|
||||
run: flutter build ios --release --no-codesign
|
||||
|
||||
- name: Create IPA (unsigned)
|
||||
run: |
|
||||
mkdir -p build/ios/ipa
|
||||
cd build/ios/iphoneos
|
||||
mkdir Payload
|
||||
cp -r Runner.app Payload/
|
||||
zip -r ../ipa/SpotiFLAC-unsigned.ipa Payload
|
||||
rm -rf Payload
|
||||
|
||||
- name: Upload IPA artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: SpotiFLAC-iOS-unsigned
|
||||
path: build/ios/ipa/SpotiFLAC-unsigned.ipa
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload XCFramework artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Gobackend-XCFramework
|
||||
path: ios/Frameworks/Gobackend.xcframework
|
||||
retention-days: 30
|
||||
@@ -3,47 +3,102 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version tag (e.g., v1.0.0)'
|
||||
description: "Version tag (e.g., v1.0.0)"
|
||||
required: true
|
||||
default: 'v1.0.0'
|
||||
default: "v1.0.0"
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
# Get version first (quick job)
|
||||
get-version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
else
|
||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Check if version contains -preview, -beta, -rc, or -alpha (NOT -hotfix)
|
||||
VERSION_LOWER=$(echo "$VERSION" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$VERSION_LOWER" == *"-preview"* ]] || [[ "$VERSION_LOWER" == *"-beta"* ]] || [[ "$VERSION_LOWER" == *"-rc"* ]] || [[ "$VERSION_LOWER" == *"-alpha"* ]]; then
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
echo "Detected pre-release version: $VERSION"
|
||||
else
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
echo "Detected stable version: $VERSION"
|
||||
fi
|
||||
|
||||
# Android and iOS build in PARALLEL
|
||||
build-android:
|
||||
runs-on: ubuntu-latest
|
||||
needs: get-version
|
||||
|
||||
steps:
|
||||
- name: Free disk space
|
||||
run: |
|
||||
# Remove large unused tools (~15GB total)
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||
sudo rm -rf /usr/local/share/boost
|
||||
sudo rm -rf /usr/share/swift
|
||||
sudo rm -rf /usr/local/.ghcup
|
||||
# Clean docker images
|
||||
sudo docker image prune --all --force
|
||||
# Show available space
|
||||
df -h
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: "1.26"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache Gradle for faster builds
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
- name: Install Android SDK & NDK
|
||||
uses: android-actions/setup-android@v3
|
||||
run: |
|
||||
# Use pre-installed Android SDK on GitHub runners
|
||||
echo "ANDROID_HOME=$ANDROID_HOME"
|
||||
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
||||
|
||||
# Accept licenses
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||
|
||||
# Install NDK r27d LTS (required for 16KB page size support on Android 15+)
|
||||
# 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"
|
||||
|
||||
# Set NDK path
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.3.13750724" >> $GITHUB_ENV
|
||||
|
||||
- name: Install gomobile
|
||||
run: |
|
||||
@@ -61,7 +116,7 @@ jobs:
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
@@ -70,34 +125,66 @@ jobs:
|
||||
- name: Generate app icons
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
- name: Build APK (Release)
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Rename APK
|
||||
- name: Build APK (Release - unsigned)
|
||||
run: |
|
||||
VERSION=${{ steps.get_version.outputs.version }}
|
||||
mv build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/SpotiFLAC-${VERSION}-android.apk
|
||||
flutter build apk --release --split-per-abi || true
|
||||
# Verify APKs were created
|
||||
ls -la build/app/outputs/flutter-apk/
|
||||
if [ ! -f "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" ]; then
|
||||
echo "ERROR: APK not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Sign APKs
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
id: sign_arm64
|
||||
with:
|
||||
releaseDirectory: build/app/outputs/flutter-apk
|
||||
signingKeyBase64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: "36.0.0"
|
||||
|
||||
- name: Rename APKs
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
cd build/app/outputs/flutter-apk
|
||||
# Signed files have -signed suffix
|
||||
mv app-arm64-v8a-release-signed.apk SpotiFLAC-${VERSION}-arm64.apk || mv app-arm64-v8a-release.apk SpotiFLAC-${VERSION}-arm64.apk || true
|
||||
mv app-armeabi-v7a-release-signed.apk SpotiFLAC-${VERSION}-arm32.apk || mv app-armeabi-v7a-release.apk SpotiFLAC-${VERSION}-arm32.apk || true
|
||||
mv app-release-signed.apk SpotiFLAC-${VERSION}-universal.apk || mv app-release.apk SpotiFLAC-${VERSION}-universal.apk || true
|
||||
ls -la
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: android-apk
|
||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||
|
||||
build-ios:
|
||||
runs-on: macos-latest
|
||||
needs: build-android
|
||||
|
||||
needs: get-version # Only depends on version, NOT android build!
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: "1.26"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache CocoaPods
|
||||
- name: Cache CocoaPods
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ios/Pods
|
||||
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
|
||||
restore-keys: pods-${{ runner.os }}-
|
||||
|
||||
- name: Install gomobile
|
||||
run: |
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
@@ -107,14 +194,59 @@ jobs:
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
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:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
- name: Verify XCFramework created
|
||||
run: |
|
||||
ls -la ios/Frameworks/
|
||||
ls -la ios/Frameworks/Gobackend.xcframework/ || (echo "ERROR: XCFramework not found!" && exit 1)
|
||||
|
||||
- name: Add XCFramework to Xcode project
|
||||
run: |
|
||||
# Install xcodeproj gem for modifying Xcode project
|
||||
sudo gem install xcodeproj
|
||||
|
||||
# Create Ruby script to add framework
|
||||
cat > add_framework.rb << 'EOF'
|
||||
require 'xcodeproj'
|
||||
|
||||
project_path = 'ios/Runner.xcodeproj'
|
||||
project = Xcodeproj::Project.open(project_path)
|
||||
|
||||
# Get the main target
|
||||
target = project.targets.find { |t| t.name == 'Runner' }
|
||||
|
||||
# Get or create Frameworks group
|
||||
frameworks_group = project.main_group.find_subpath('Frameworks', true)
|
||||
frameworks_group ||= project.main_group.new_group('Frameworks')
|
||||
|
||||
# Add XCFramework reference
|
||||
framework_path = 'Frameworks/Gobackend.xcframework'
|
||||
framework_ref = frameworks_group.new_file(framework_path, :project)
|
||||
|
||||
# Add to frameworks build phase
|
||||
frameworks_build_phase = target.frameworks_build_phase
|
||||
frameworks_build_phase.add_file_reference(framework_ref)
|
||||
|
||||
# Add to embed frameworks build phase
|
||||
embed_phase = target.build_phases.find { |p| p.is_a?(Xcodeproj::Project::Object::PBXCopyFilesBuildPhase) && p.name == 'Embed Frameworks' }
|
||||
if embed_phase
|
||||
build_file = embed_phase.add_file_reference(framework_ref)
|
||||
build_file.settings = { 'ATTRIBUTES' => ['CodeSignOnCopy', 'RemoveHeadersOnCopy'] }
|
||||
end
|
||||
|
||||
project.save
|
||||
puts "Successfully added Gobackend.xcframework to Xcode project"
|
||||
EOF
|
||||
|
||||
ruby add_framework.rb
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
@@ -124,77 +256,274 @@ jobs:
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
- name: Build iOS (unsigned)
|
||||
run: flutter build ios --release --no-codesign
|
||||
run: |
|
||||
# Build Flutter iOS without codesigning
|
||||
flutter build ios --release --no-codesign --config-only
|
||||
|
||||
# Use xcodebuild with code signing disabled
|
||||
cd ios
|
||||
xcodebuild -workspace Runner.xcworkspace \
|
||||
-scheme Runner \
|
||||
-configuration Release \
|
||||
-sdk iphoneos \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath build/Runner.xcarchive \
|
||||
archive \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGN_IDENTITY="" \
|
||||
DEVELOPMENT_TEAM=""
|
||||
|
||||
- name: Create IPA
|
||||
run: |
|
||||
VERSION=${{ needs.build-android.outputs.version }}
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
mkdir -p build/ios/ipa
|
||||
cd build/ios/iphoneos
|
||||
cd ios/build/Runner.xcarchive/Products/Applications
|
||||
mkdir Payload
|
||||
cp -r Runner.app Payload/
|
||||
zip -r ../ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload
|
||||
# Use absolute path to avoid relative path issues
|
||||
zip -r $GITHUB_WORKSPACE/build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa Payload
|
||||
rm -rf Payload
|
||||
|
||||
- name: Verify IPA created
|
||||
run: |
|
||||
ls -la build/ios/ipa/
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
if [ ! -f "build/ios/ipa/SpotiFLAC-${VERSION}-ios-unsigned.ipa" ]; then
|
||||
echo "ERROR: IPA not created!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload IPA artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: build/ios/ipa/SpotiFLAC-*.ipa
|
||||
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-android, build-ios]
|
||||
needs: [get-version, build-android, build-ios]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Extract changelog for version
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
|
||||
|
||||
echo "Looking for version: $VERSION_NUM"
|
||||
|
||||
# Extract changelog section for this version using sed
|
||||
# Find the line with version, then print until next version header or end
|
||||
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
|
||||
|
||||
# If no changelog found, use default message
|
||||
if [ -z "$CHANGELOG" ]; then
|
||||
echo "No changelog found for version $VERSION_NUM"
|
||||
CHANGELOG="See CHANGELOG.md for details."
|
||||
else
|
||||
echo "Found changelog content"
|
||||
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
|
||||
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
|
||||
fi
|
||||
|
||||
# Save to file for multiline support
|
||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||
echo "Extracted changelog:"
|
||||
cat /tmp/changelog.txt
|
||||
|
||||
- name: Download Android APK
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: android-apk
|
||||
path: ./release
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
|
||||
- name: Prepare release body
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
cat > /tmp/release_body.txt << 'HEADER'
|
||||
### What's New
|
||||
HEADER
|
||||
|
||||
cat /tmp/changelog.txt >> /tmp/release_body.txt
|
||||
|
||||
REPO_OWNER="${{ github.repository_owner }}"
|
||||
REPO_NAME="${{ github.event.repository.name }}"
|
||||
|
||||
cat >> /tmp/release_body.txt << FOOTER
|
||||
|
||||
---
|
||||
|
||||
### Downloads
|
||||
|
||||
#### Android
|
||||
- **arm64**: \`SpotiFLAC-${VERSION}-arm64.apk\` (recommended for modern devices)
|
||||
- **arm32**: \`SpotiFLAC-${VERSION}-arm32.apk\` (older devices)
|
||||
|
||||
#### iOS
|
||||
- **iOS**: \`SpotiFLAC-${VERSION}-ios-unsigned.ipa\` (sideload required)
|
||||
|
||||
### Installation
|
||||
**Android**: Enable "Install from unknown sources" and install the APK
|
||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||
|
||||
  
|
||||
FOOTER
|
||||
|
||||
echo "Release body:"
|
||||
cat /tmp/release_body.txt
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.build-android.outputs.version }}
|
||||
name: SpotiFLAC ${{ needs.build-android.outputs.version }}
|
||||
body: |
|
||||
## SpotiFLAC ${{ needs.build-android.outputs.version }}
|
||||
|
||||
Download Spotify tracks in FLAC quality from Tidal, Qobuz & Amazon Music.
|
||||
|
||||
### Downloads
|
||||
- **Android**: `SpotiFLAC-${{ needs.build-android.outputs.version }}-android.apk`
|
||||
- **iOS**: `SpotiFLAC-${{ needs.build-android.outputs.version }}-ios-unsigned.ipa` (requires sideloading)
|
||||
|
||||
### Features
|
||||
- Search Spotify tracks, albums, and playlists
|
||||
- Download in FLAC quality from multiple sources
|
||||
- Automatic fallback to available services
|
||||
- Embedded metadata and cover art
|
||||
- Lyrics support (synced and plain)
|
||||
- Material 3 Expressive UI with dynamic colors
|
||||
|
||||
### Installation
|
||||
**Android**: Enable "Install from unknown sources" and install the APK
|
||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||
|
||||
---
|
||||
*Note: iOS IPA is unsigned and requires sideloading*
|
||||
files: |
|
||||
./release/*
|
||||
tag_name: ${{ needs.get-version.outputs.version }}
|
||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||
body_path: /tmp/release_body.txt
|
||||
files: ./release/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
prerelease: ${{ needs.get-version.outputs.is_prerelease == 'true' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
notify-telegram:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-version, create-release]
|
||||
if: ${{ needs.get-version.outputs.is_prerelease != 'true' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download Android APK
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: android-apk
|
||||
path: ./release
|
||||
|
||||
- name: Download iOS IPA
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: ./release
|
||||
|
||||
- name: Extract changelog for version
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
VERSION_NUM=${VERSION#v}
|
||||
|
||||
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
|
||||
# Use tr -d '\r' to handle CRLF line endings from Windows
|
||||
FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
|
||||
|
||||
echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
|
||||
echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
|
||||
|
||||
if [ -z "$FULL_CHANGELOG" ]; then
|
||||
CHANGELOG="See release notes on GitHub for details."
|
||||
else
|
||||
# Convert GitHub Markdown to Telegram HTML:
|
||||
# - **text** → <b>text</b>
|
||||
# - `code` → <code>code</code>
|
||||
# - ### Header → <b>Header</b>
|
||||
# - Escape HTML special chars first
|
||||
# - Remove > blockquote prefix
|
||||
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
|
||||
sed 's/^> //' | \
|
||||
sed 's/&/\&/g' | \
|
||||
sed 's/</\</g' | \
|
||||
sed 's/>/\>/g' | \
|
||||
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
|
||||
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
|
||||
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
|
||||
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
|
||||
sed 's/^- /• /g' | \
|
||||
sed 's/^ - / ◦ /g')
|
||||
|
||||
# Take first 2500 characters, then cut at last complete line
|
||||
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
|
||||
|
||||
# Check if truncated
|
||||
FULL_LEN=${#FULL_CHANGELOG}
|
||||
if [ $FULL_LEN -gt 2500 ]; then
|
||||
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$CHANGELOG" > /tmp/changelog.txt
|
||||
echo "DEBUG: Final changelog:"
|
||||
cat /tmp/changelog.txt
|
||||
|
||||
- name: Send to Telegram Channel
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHANNEL_ID: ${{ secrets.TELEGRAM_CHANNEL_ID }}
|
||||
run: |
|
||||
VERSION=${{ needs.get-version.outputs.version }}
|
||||
CHANGELOG=$(cat /tmp/changelog.txt)
|
||||
|
||||
# Find APK files
|
||||
ARM64_APK=$(find ./release -name "*arm64*.apk" | head -1)
|
||||
ARM32_APK=$(find ./release -name "*arm32*.apk" | head -1)
|
||||
|
||||
# Prepare message with changelog (HTML format)
|
||||
printf '%s\n' \
|
||||
"<b>SpotiFLAC Mobile ${VERSION} Released!</b>" \
|
||||
"" \
|
||||
"<b>What's New:</b>" \
|
||||
"${CHANGELOG}" \
|
||||
"" \
|
||||
"<a href=\"https://github.com/${{ github.repository }}/releases/tag/${VERSION}\">View Release Notes</a>" \
|
||||
> /tmp/telegram_message.txt
|
||||
|
||||
MESSAGE=$(cat /tmp/telegram_message.txt)
|
||||
|
||||
# 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" \
|
||||
--data-urlencode "chat_id=${TELEGRAM_CHANNEL_ID}" \
|
||||
--data-urlencode "text=${MESSAGE}" \
|
||||
--data-urlencode "parse_mode=HTML" \
|
||||
--data-urlencode "disable_web_page_preview=true" || true
|
||||
|
||||
# Upload arm64 APK to channel
|
||||
if [ -f "$ARM64_APK" ]; then
|
||||
echo "Uploading arm64 APK to Telegram..."
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM64_APK}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
|
||||
fi
|
||||
|
||||
# Upload arm32 APK to channel
|
||||
if [ -f "$ARM32_APK" ]; then
|
||||
echo "Uploading arm32 APK to Telegram..."
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM32_APK}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - arm32"
|
||||
fi
|
||||
|
||||
# Upload iOS IPA to channel
|
||||
IOS_IPA=$(find ./release -name "*ios*.ipa" | head -1)
|
||||
if [ -f "$IOS_IPA" ]; then
|
||||
echo "Uploading iOS IPA to Telegram..."
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${IOS_IPA}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
|
||||
fi
|
||||
|
||||
echo "Telegram notification sent!"
|
||||
|
||||
@@ -6,9 +6,70 @@ Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
.cursorignore
|
||||
.cursorrules
|
||||
|
||||
# Kiro specs (optional - remove if you want to track specs)
|
||||
# .kiro/
|
||||
# Kiro specs (development only)
|
||||
.kiro/
|
||||
|
||||
# Reference folder (if you don't want to include it)
|
||||
# referensi/
|
||||
# Reference folder (development only)
|
||||
referensi/
|
||||
|
||||
# Documentation (development only, published separately)
|
||||
docs/
|
||||
|
||||
# Old spotiflac_android folder (moved to root)
|
||||
spotiflac_android/
|
||||
|
||||
# Flutter/Dart
|
||||
.dart_tool/
|
||||
.packages
|
||||
build/
|
||||
*.lock
|
||||
!pubspec.lock
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.metadata
|
||||
*.apk
|
||||
|
||||
# Go backend build artifacts
|
||||
go_backend/*.aar
|
||||
go_backend/*.jar
|
||||
go_backend/*.exe
|
||||
go_backend/*.xcframework/
|
||||
|
||||
# Android
|
||||
android/.gradle/
|
||||
android/app/libs/gobackend.aar
|
||||
android/local.properties
|
||||
android/*.iml
|
||||
android/key.properties
|
||||
android/*.jks
|
||||
android/*.keystore
|
||||
android/app/*.jks
|
||||
|
||||
# iOS
|
||||
ios/Frameworks/
|
||||
ios/Pods/
|
||||
ios/.symlinks/
|
||||
ios/Flutter/Flutter.framework/
|
||||
ios/Flutter/Flutter.podspec
|
||||
android/app/libs/gobackend-sources.jar
|
||||
|
||||
# Extension folder
|
||||
extension/
|
||||
|
||||
# Agent instructions
|
||||
AGENTS.md
|
||||
|
||||
# Temp/misc
|
||||
nul
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
hs_err_*.log
|
||||
flutter_*.log
|
||||
|
||||
# Development tools
|
||||
tool/
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -0,0 +1,957 @@
|
||||
# Changelog
|
||||
|
||||
## [3.6.6] - 2026-02-12
|
||||
|
||||
### Added
|
||||
|
||||
- "Filter Contributing Artists in Album Artist" setting - strips featured/contributing artists from Album Artist metadata tag
|
||||
- Library scan notifications (Android and iOS) - shows progress, completion, failure, and cancellation status
|
||||
- Collapsible "Artist Name Filters" section in download settings UI
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed downloads not working on iOS - missing `downloadByStrategy` and `downloadFromYouTube` method channel handlers in AppDelegate.swift
|
||||
- Fixed extended metadata (genre, label, copyright) lost during service fallback (e.g. Tidal unavailable, falls back to Qobuz) - Go backend now enriches metadata from Deezer by ISRC before download and preserves it through the fallback chain
|
||||
- Fixed local library showing incorrect "16-bit" quality label for lossy formats (MP3, Opus) - now displays actual bitrate (e.g. "MP3 320kbps")
|
||||
- Fixed inaccurate Opus/Vorbis duration calculation (e.g. 4:11 showing as 8:44) - now reads granule position from last Ogg page for precise duration
|
||||
- Fixed MP3 duration/bitrate inaccuracy for VBR files - added Xing/Info and VBRI header parsing with MPEG2/2.5 bitrate table support
|
||||
- Fixed Track Metadata screen showing scan date instead of file date for local library items
|
||||
- Fixed SAF content URI paths displayed as raw `content://` strings in Track Metadata - now shows human-readable paths
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed legacy iOS download handlers (`downloadTrack`, `downloadWithFallback`, `downloadFromYouTube`) - iOS now uses `downloadByStrategy` only
|
||||
- Updated translations from Crowdin (all 14 languages)
|
||||
|
||||
---
|
||||
|
||||
## [3.6.5] - 2026-02-10
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Audio Format Conversion**: Convert between FLAC, MP3, and Opus directly from Track Metadata screen with full metadata and cover art preservation
|
||||
- **PC v7.0.8 Backend Merge**: Adopts several Go backend improvements from SpotiFLAC PC v7.0.8 including Amazon encrypted stream support, SpotFetch metadata fallback, and Qobuz API update
|
||||
- **Amazon Music Re-enabled**: Amazon provider back in service with new API
|
||||
|
||||
### Added
|
||||
|
||||
- "Use Primary Artist Only" setting: strips featured artists from folder names (e.g. "Justin Bieber, Quavo" becomes "Justin Bieber") for cleaner folder organization
|
||||
- Supports separators: `, ` `;` `&` `feat.` `ft.` `featuring` `with` `x`
|
||||
- Available in Settings > Download > below "Use Album Artist for folders"
|
||||
- Audio format conversion from Track Metadata screen
|
||||
- Convert between FLAC, MP3, and Opus formats (any direction)
|
||||
- Selectable bitrate: 128k, 192k, 256k, 320k
|
||||
- Full metadata and cover art preservation during conversion
|
||||
- Confirmation dialog before converting (original file deleted after)
|
||||
- SAF storage support: copies to temp, converts, writes back via SAF
|
||||
- Download history automatically updated with new file path
|
||||
- Unified download request contract (`DownloadRequestPayload`) for all providers/flows
|
||||
- Includes full superset fields: lyrics mode, genre/label/copyright, provider IDs, SAF params, cover/quality settings
|
||||
- Added strategy flags in payload: `use_extensions`, `use_fallback`
|
||||
- New Go unified router entrypoint: `DownloadByStrategy(requestJSON)`
|
||||
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
|
||||
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
|
||||
- SpotFetch metadata fallback integration for Spotify-blocked regions
|
||||
- New backend client for `spotify.afkarxyz.fun/api`
|
||||
- Automatic fallback in Spotify metadata fetch path when primary source fails
|
||||
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
|
||||
- Includes heuristic detection of lyrics stored in Comment fields
|
||||
- Edit Metadata now supports manual cover selection (pick/replace cover image) and embeds it into audio tags on save
|
||||
- Save Lyrics now shows an immediate in-progress snackbar (`Saving lyrics...`) so users know the operation has started
|
||||
|
||||
### Changed
|
||||
|
||||
- Merged several Go backend improvements from SpotiFLAC PC v7.0.8: Amazon new API with encrypted stream/decryption support, SpotFetch metadata fallback for Spotify-blocked regions, multi-format lyrics extraction (MP3/Opus/OGG), Qobuz Jumo API update.
|
||||
- Download queue execution now builds one payload and uses a single bridge entrypoint (`PlatformBridge.downloadByStrategy`) instead of branching into multiple bridge methods
|
||||
- Dart `downloadByStrategy` now sends a single request to Go (`downloadByStrategy` channel); routing concern is centralized in Go backend
|
||||
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
|
||||
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
|
||||
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
|
||||
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
|
||||
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
|
||||
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
|
||||
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
|
||||
- Edit Metadata cover section moved to the top of the form and now previews current embedded cover before replacement (plus selected replacement preview)
|
||||
- Edit Metadata cover preview enlarged (120px to 160px) with shadow, side-by-side layout for current vs selected cover, and label repositioned below image
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed lyrics mode "External .LRC" still embedding lyrics into metadata - `lyrics_mode` was not being sent to Go backend for single-service downloads and YouTube provider, causing Go to default to "embed"
|
||||
- Fixed `flutter_local_notifications` v20 breaking changes - migrated all `initialize()`, `show()`, and `cancel()` calls from positional parameters to named parameters
|
||||
- Fixed SAF duplicate folder bug: concurrent batch downloads creating empty folders with `(1)`, `(2)`, `(3)` suffixes - added synchronized lock to `ensureDocumentDir` in Kotlin with duplicate detection and cleanup
|
||||
- Track Metadata lyrics section now hides "Embed Lyrics" when lyrics are already embedded in file, preventing redundant embed attempts
|
||||
- Fixed lyrics embed path to support FLAC/MP3/Opus consistently (including SAF files) without forcing unsupported parser paths
|
||||
- Inconsistent parameter parity across download paths
|
||||
- `downloadWithExtensions` now carries `copyright`
|
||||
- YouTube path now carries `embed_max_quality_cover` and metadata parity fields
|
||||
- Inconsistent success response metadata between direct/fallback flows
|
||||
- Added shared Go response builder for `DownloadTrack` and `DownloadWithFallback`
|
||||
- Success responses now consistently include `genre`, `label`, `copyright`, and `lyrics_lrc`
|
||||
- YouTube success response now also includes extended metadata fields (`cover_url`, `genre`, `label`, `copyright`) for parity with other providers
|
||||
- Fixed `Save Lyrics` crash on Android (`java.lang.Integer cannot be cast to java.lang.Long`) by normalizing `duration_ms` channel argument as `Number -> Long`
|
||||
- Fixed FLAC Re-enrich cover edge case where metadata could be written without cover when temp cover file creation failed; FLAC cover embed now uses in-memory bytes and verifies cover after write
|
||||
- Fixed FLAC picture-block embed robustness by detecting image MIME via magic bytes (JPEG/PNG/GIF/WEBP) instead of relying on filename extension
|
||||
- Fixed MP3/Opus metadata rewrite flows to preserve existing embedded cover when no new cover is available
|
||||
- Fixed Library tab cover not updating after manual cover edit/re-embed for downloaded tracks
|
||||
- Queue/Library now prefers embedded cover art extracted from local files (not just cached `coverUrl`)
|
||||
- Added per-track extraction cache with file-modification invalidation so updated embedded art is reflected in Library
|
||||
- Extraction is now on-demand for edited tracks only (not full-library reload)
|
||||
- Returning from Track Metadata now refreshes cover cache only for the affected track
|
||||
- Cover refresh is now skipped when file modification time is unchanged, removing unnecessary flash when simply opening/closing metadata screen
|
||||
- Fixed repeated cover preview extraction in Track Metadata screen (`track_cover_preview_*`) causing visible flash when reopening
|
||||
- Added in-memory preview cache keyed by file path so reopening metadata reuses existing preview without re-extract
|
||||
- Cache validation uses file modification time for filesystem paths; SAF paths are refreshed only after successful edit actions
|
||||
- Queue/Library now also compares SAF file last-modified (`getSafFileModTimes`) before refreshing embedded-cover cache
|
||||
- Preview cache key is now stable per track item (not volatile temp SAF path), eliminating false cache misses on SAF-backed files
|
||||
- Track Metadata no longer auto-extracts cover preview on every screen open; extraction now runs only after actual edit/re-enrich changes (or when explicitly forced)
|
||||
- Track metadata edits/re-enrich now sync updated tags back into `downloadHistoryProvider` + SQLite history rows
|
||||
- Non-Library screens that read download history (Home/album/history views) now reflect updated title/artist/album/tags without manual rescan
|
||||
- Track Metadata back-navigation now returns an explicit update result after successful edits/re-enrich, enabling History-tab cover refresh fallback when SAF timestamps are unreliable
|
||||
|
||||
### Performance
|
||||
|
||||
- Configured Flutter image cache limits (240 entries / 60 MiB) and added `ResizeImage` wrappers for cover art precaching across all screens, reducing peak memory usage on cover-heavy pages
|
||||
- Added LRU eviction to Deezer cache with configurable max entries per cache type (search/album/artist/ISRC) and periodic expired-entry cleanup to prevent unbounded memory growth in long sessions
|
||||
- Download progress notifications are now normalized (2-decimal progress, 1-decimal speed, 0.1 MiB byte steps) and deduplicated by track/artist/percent/queue-count, reducing notification overhead during batch downloads
|
||||
- Each queue item now uses a dedicated `ConsumerWidget` with per-item `.select()` instead of rebuilding the entire list on any item change; items are wrapped in `RepaintBoundary` for paint isolation
|
||||
- Queue/Library search indexes are now built on-demand per item instead of upfront for all items, with bounded LRU caches (max 4000 entries)
|
||||
- `copyWith` now preserves derived lookup indexes (ISRC map, track key set) when items list is unchanged, avoiding O(n) rebuild on every scan progress update
|
||||
- Scan progress polling now compares values before calling `setState`, skipping unnecessary widget rebuilds when nothing changed
|
||||
- Added in-flight flag to download progress and library scan polling to prevent concurrent timer callbacks from overlapping
|
||||
- New `DownloadedEmbeddedCoverResolver` service replaces per-screen cover extraction logic with a shared bounded cache (160 entries), mod-time validation, and throttled refresh checks
|
||||
- Multiple embedded cover change callbacks are now coalesced into a single frame via `addPostFrameCallback`, preventing redundant rebuilds
|
||||
- Downloaded album screen now caches filtered/sorted track lists and reuses them when the source data reference is unchanged
|
||||
- Home tab recent downloads now use single-pass aggregation instead of building full per-album lists, and store only IDs instead of full item objects for the clear-all action
|
||||
- Removed duplicate `_downloadedSpotifyIds` Set and `_isrcSet` (both now use existing map lookups), removed unused `_isTyping` state in home tab
|
||||
- Track cache pre-warming is now capped at 80 tracks per request to avoid excessive backend calls on large playlists
|
||||
- About page contributor avatars now use `memCacheWidth`/`memCacheHeight` to decode at display size instead of full resolution
|
||||
- Orphaned download cleanup now checks file existence in parallel (chunk 16) instead of sequentially
|
||||
- Local library `findByTrackAndArtist` now uses O(1) map lookup (`_byTrackKey`) instead of O(n) linear scan
|
||||
- Local library database load and SharedPreferences fetch now run in parallel
|
||||
- Legacy mod-time backfill now uses chunked parallel `File.stat` (chunk 24) with per-chunk cancel check
|
||||
- Downloaded album screen now caches disc grouping, sorted disc numbers, common quality, and embedded cover path with reference-identity invalidation
|
||||
- Local album screen common quality is now computed once during cache rebuild instead of per-build
|
||||
- Batch delete in album screens now uses O(1) map lookup (`tracksById`) instead of `.where().firstOrNull`
|
||||
- Cache management page now fires all async init calls in parallel and uses chunked async directory deletion (chunk 24)
|
||||
- Cover resolver preview file existence check is now throttled (2.2s interval) to reduce synchronous I/O in build path
|
||||
- History and library database DELETE operations are now chunked (500 per batch) to stay within SQLite variable limits
|
||||
- Library database `cleanupMissingFiles` now checks file existence in parallel (chunk 16) and deletes in batched SQL
|
||||
|
||||
### Security
|
||||
|
||||
- All logs (Go and Dart) now automatically redact Bearer tokens, access/refresh tokens, client secrets, API keys, and passwords using regex-based sanitization before storage
|
||||
- Extension auth URLs are now validated for HTTPS-only, no embedded credentials, and no private/local network targets before opening
|
||||
- Auth URLs in logs are summarized to scheme+host+path only (query params stripped) to prevent token leakage; token exchange error bodies are truncated and sanitized
|
||||
- Extension HTTP requests now block URLs with embedded credentials (`user:pass@host`)
|
||||
- Extension storage files changed from `0644` to `0600` (owner-only read/write)
|
||||
- All SAF relative directory paths are now sanitized per-segment with `.`/`..` filtering; all user-provided file names pass through `sanitizeFilename()` before use
|
||||
- Extension ID is sanitized before building download destination path
|
||||
- Log export device info now shows Build ID and Security Patch level instead of masked Device ID
|
||||
|
||||
### Technical
|
||||
|
||||
- Centralized request serialization in `PlatformBridge` via shared invoke helper and unified payload model
|
||||
- Go strategy router normalizes incoming service casing before dispatch
|
||||
- Extension runtime: `customSearch` now passes query/options via VM globals instead of string interpolation, preventing parser edge cases on certain devices
|
||||
- Extension runtime: JS panic handler now logs full stack trace for easier debugging
|
||||
- `DownloadQueueLookup` expanded with `byItemId` map and `itemIds` list for O(1) queue item access from UI
|
||||
- Non-error/non-fatal log entries are now skipped entirely (not just hidden) when detailed logging is disabled, reducing buffer growth and Go log polling overhead
|
||||
|
||||
### Removed
|
||||
|
||||
- Buy Me a Coffee references removed from donate page, FUNDING.yml, README, and all localization files (account suspended)
|
||||
|
||||
---
|
||||
|
||||
## [3.6.0] - 2026-02-09
|
||||
|
||||
### Highlights
|
||||
|
||||
- **YouTube Provider (Lossy)**: New download option via Cobalt API for tracks not available on lossless services
|
||||
- Opus 256kbps (recommended) or MP3 320kbps quality options
|
||||
- Full metadata embedding: cover art, title, artist, album, track/disc number, year, ISRC
|
||||
- Lyrics fetching from lrclib.net with embed and external .lrc support
|
||||
- Works as fallback when Tidal/Qobuz/Amazon downloads fail
|
||||
- **Edit Metadata**: Edit embedded metadata directly from the Track Metadata screen (FLAC, MP3, Opus)
|
||||
- Editable fields: Title, Artist, Album, Album Artist, Date, Track#, Disc#, Genre, ISRC
|
||||
- Advanced fields: Label, Copyright, Composer, Comment
|
||||
- FLAC: native Go writer, MP3/Opus: FFmpeg-based writer
|
||||
- UI refreshes in-place after save without needing to re-open the screen
|
||||
- iOS and Android support
|
||||
|
||||
### Added
|
||||
|
||||
- Save Cover Art: download high-quality album art as standalone .jpg from track metadata screen
|
||||
- Save Lyrics (.lrc): fetch and save lyrics as standalone .lrc file without downloading the song
|
||||
- Re-enrich Metadata: re-embed metadata, cover art, and lyrics into existing audio files without re-downloading (FLAC native, MP3/Opus via FFmpeg)
|
||||
- Re-enrich now supports local library items: searches Spotify/Deezer by track name + artist to fetch complete metadata from the internet, then embeds cover art, lyrics, genre, label, and all tags into the file
|
||||
- YouTube download provider using Cobalt API with SongLink/Odesli integration for Spotify/Deezer ID → YouTube URL conversion
|
||||
- SpotubeDL as fallback Cobalt proxy when primary API fails
|
||||
- YouTube video ID detection for YT Music extension compatibility
|
||||
- Parallel cover art and lyrics fetching during YouTube download
|
||||
- Queue progress now shows "X.X MB" instead of "0%" for streaming downloads where total size is unknown (Cobalt tunnel mode)
|
||||
- Full metadata pipeline for YouTube downloads: cover art, lyrics, title, artist, album, track#, disc#, year, ISRC
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed Tidal HIGH (lossy AAC) quality option - use YouTube provider for lossy downloads instead
|
||||
- Simplified download service picker by removing dead lossy format code
|
||||
- Removed Amazon from download settings UI (now only used as automatic fallback)
|
||||
- Cleaned up dead disabled-chip code in download service selector
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `error.api.youtube.login` by using YouTube Music URLs instead of regular YouTube URLs for Cobalt requests
|
||||
- Fixed SongLink to prioritize `youtubeMusic` platform URL over `youtube` for Cobalt compatibility
|
||||
- Fixed YouTube metadata not being overwritten by setting `DisableMetadata: true` in Cobalt requests
|
||||
- Fixed ISRC validation in metadata enrichment flow - invalid ISRCs no longer trigger failed Deezer lookups
|
||||
- Fixed YouTube metadata enrichment to work like other providers (SongLink Deezer ID extraction, proper metadata embedding)
|
||||
- Go metadata parsers now read Composer, Comment, Label, Copyright from FLAC, MP3 (ID3v2.2/v2.3/v2.4), and Opus/OGG files
|
||||
- Added proper COMM frame parser for ID3v2 (handles language code + description prefix correctly)
|
||||
- Fixed Re-enrich Metadata failing on SAF storage files (`content://` URIs) - Kotlin now copies SAF file to temp, Go processes temp file, then writes back for FLAC or returns temp path for FFmpeg (MP3/Opus)
|
||||
- Fixed Save Cover Art and Save Lyrics crashing on SAF-stored download history items - now saves to temp then writes to SAF tree via `createSafFileFromPath`
|
||||
- Fixed `_getFileDirectory()` crash when called with `content://` URI by adding SAF guard
|
||||
- Fixed `readAudioMetadata` Kotlin handler not handling SAF URIs - now copies to temp for reading
|
||||
- Added metadata summary log in Re-enrich flow showing all fields before embedding (title, artist, album, track#, disc#, date, ISRC, genre, label)
|
||||
|
||||
---
|
||||
|
||||
## [3.5.3] - 2026-02-09
|
||||
|
||||
### Added
|
||||
|
||||
- CSV import flow now includes a new option: **Skip already downloaded songs** before enqueueing tracks
|
||||
- Added regression test suite for cross-script matching behavior in Go backend (`go_backend/matching_test.go`)
|
||||
|
||||
### Changed
|
||||
|
||||
- CSV import confirmation dialog now supports filtering out tracks already present in download history (matched by Spotify ID and ISRC)
|
||||
- CSV import enqueue feedback now reports added/skipped counts when duplicate downloads are skipped
|
||||
- Home search now prioritizes **Recent Access** when search field is focused with empty input, even if old search results still exist in memory
|
||||
- Search filter/result sections are now hidden while Recent Access mode is active to avoid stale-result overlap
|
||||
- Recent Access now shows a localized empty-state message when no recent items are available
|
||||
- Normalized collapsing AppBar top inset across iOS/Android so header height/animation stays visually consistent on Apple devices
|
||||
- Storage & Cache UX improved: `Clear all cache` now preserves web/runtime cache by default (optional), with explicit warnings/actions for runtime cache resets
|
||||
- Local library settings now include a display count for tracks excluded because they already exist in download history
|
||||
- Responsive layout tuning applied across key screens to reduce hardcoded-height overflow issues on smaller devices
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed false-positive cross-script matching in Qobuz/Tidal where unrelated titles/artists in different scripts could be incorrectly accepted
|
||||
- Cross-script title/artist matching now requires transliteration-aware normalization and strict similarity checks instead of auto-accepting script differences
|
||||
- Qobuz metadata fallback no longer scans all results when zero title matches are found; title verification is now required
|
||||
- Qobuz metadata final validation now rejects results when title does not match expected track name
|
||||
- Fixed Home search regression where Recent Access panel could disappear after previous searches
|
||||
- Fixed Local Library card/layout crash caused by `Flex` usage under unbounded height constraints
|
||||
- Hardened FFmpeg metadata embedding temp-file naming to prevent rare collisions during parallel downloads/fallback flows (Qobuz → Tidal) that could cause missing embedded metadata
|
||||
- Fixed SAF external lyrics naming where some providers saved `.lrc` files as `.lrc.txt`; LRC export now uses neutral MIME to preserve `.lrc` extension
|
||||
|
||||
## [3.5.2] - 2026-02-08
|
||||
|
||||
### Performance
|
||||
|
||||
- Home tab search result sections are now virtualized with `SliverList` (lazy item build) instead of eager `Column` rendering, reducing frame drops on large result sets
|
||||
- Home tab now narrows Riverpod subscriptions using field-level `select(...)` for search/provider state to reduce unnecessary full-tab rebuilds
|
||||
- Search provider dropdown now watches only required fields (`searchProvider`, `metadataSource`, `extensions`) instead of full provider states
|
||||
- Track row rendering in Home search now receives precomputed thumbnail sizing/local-library flags from parent to avoid repeated per-item provider watches
|
||||
- Removed thumbnail `debugPrint` calls inside track row `build()` to reduce runtime overhead during scrolling/rebuilds
|
||||
- Queue tab root subscription no longer watches full queue item list; it now watches only queue presence (`items.isNotEmpty`) to avoid full Library UI rebuilds on every progress tick
|
||||
- Queue download header/list rendering has been isolated into dedicated `Consumer` slivers; header now watches only queue length (`items.length`) while item list watches queue item updates
|
||||
- Queue filter/sort computations are now centralized and memoized per filter mode within a build pass (`all`/`albums`/`singles`), reducing repeated list transforms for chip counts and page content
|
||||
- Selection bottom bar content is now computed only when selection mode is active, removing hidden-state heavy list preparation
|
||||
- File existence checks in queue/library rows now use per-path `ValueNotifier` + `ValueListenableBuilder` updates instead of triggering global `setState`, reducing unnecessary whole-tab repaints
|
||||
|
||||
### Changed
|
||||
|
||||
- Replaced date range filter with sorting options in Library tab: Latest, Oldest, A-Z, Z-A
|
||||
- Sorting applies to all views: unified items, downloaded albums, and local library albums
|
||||
- Local library items now use file modification time (`fileModTime`) for sorting instead of scan time, providing more accurate chronological ordering
|
||||
- Removed redundant manual "Export Failed Downloads" button from Library UI (auto-export setting in Settings is sufficient)
|
||||
- Library filters (quality, format, source) now correctly apply to album tabs and update tab chip counts (All/Albums/Singles)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed local library scan crashing on Samsung One UI devices due to MediaStore URI mismatch in SAF tree traversal
|
||||
- Added MediaStore URI fallback in SAF file reader: when SAF permission is denied for Samsung-returned MediaStore URIs, automatically retries using READ_MEDIA_AUDIO permission
|
||||
- Hardened SAF scan with per-directory and per-file error handling: scan now skips problematic files instead of aborting entirely
|
||||
- Added visited directory tracking to prevent infinite loops from circular SAF references
|
||||
- Fixed metadata enrichment cascading failure after one queued download fails: metadata APIs (Deezer, SongLink, Spotify) now use isolated `metadataTransport` so failed download connections cannot poison metadata requests
|
||||
- Added immediate connection cleanup on every download failure path (error response and exception), not only periodic cleanup every N downloads
|
||||
- Fixed incremental SAF scan edge case where `lastModified()` failure could misclassify existing files as removed (`removedUris`)
|
||||
- Fixed tracks marked "In Library" still showing active download button - download button now shows as completed (checkmark) for local library tracks across all screens (album, playlist, artist, home/search)
|
||||
- Fixed FFmpeg M4A-to-FLAC conversion erroneously triggered on already-existing FLAC files when re-downloading duplicates via Tidal
|
||||
- Fixed SAF download creating empty artist/album folders when re-downloading duplicate tracks; directory is now only created after confirming the file does not already exist
|
||||
|
||||
## [3.5.1] - 2026-02-08
|
||||
|
||||
### Performance
|
||||
|
||||
- Removed PaletteService (palette_generator) from all screens for faster navigation and reduced memory usage
|
||||
- Album, Playlist, Downloaded Album, Local Album, and Track Metadata screens now use blurred cover art as header background instead of dominant color extraction
|
||||
- Removed `palette_generator` dependency
|
||||
- App startup now renders immediately (`runApp`) while service initialization runs asynchronously in eager init
|
||||
- Main shell provider subscriptions now use field-level `select(...)` to reduce unnecessary rebuilds
|
||||
- Settings persistence now uses single-flight + queued save coalescing to avoid redundant disk writes
|
||||
- Progress polling cadence adjusted to 800ms for download queue, local library scan progress, and Go log polling
|
||||
- Android foreground download service progress updates are throttled (change-based updates + 5s heartbeat)
|
||||
- SAF history repair is now batched (`20` items per batch) and capped per launch (`60`) to reduce startup I/O spikes
|
||||
- Incremental library scan now builds final item list in-memory instead of reloading from database
|
||||
- Local cover images in queue/library use direct `Image.file` with `errorBuilder` instead of `FutureBuilder` existence check
|
||||
- CSV parser `_parseLine` rewritten: correct escaped-quote handling, no quote characters in output
|
||||
- Removed unused legacy screen files (`home_screen.dart`, `queue_screen.dart`, `settings_screen.dart`, `settings_tab.dart`)
|
||||
- Incremental local library scan now merges delta results in-memory and sorts once, avoiding full-state reload churn
|
||||
- Queue local cover rendering now uses direct `Image.file` + `errorBuilder` (removed repeated async file-exists checks)
|
||||
|
||||
### Added
|
||||
|
||||
- Auto-cleanup orphaned downloads on history load (files that no longer exist are automatically removed from history)
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed legacy screen files that were no longer used after the tab/part refactor:
|
||||
- `lib/screens/home_screen.dart`
|
||||
- `lib/screens/queue_screen.dart`
|
||||
- `lib/screens/settings_screen.dart`
|
||||
- `lib/screens/settings_tab.dart`
|
||||
- Concurrent download limit increased from `3` to `5` (settings clamp + Options UI chips now support `1..5`)
|
||||
- Download queue now uses a single parallel scheduler path; `1` concurrency is handled as parallel-with-limit-1 (no separate sequential engine)
|
||||
- Download queue now listens to settings updates in real-time so concurrency/output settings stay in sync while queue is active
|
||||
|
||||
### Fixed
|
||||
|
||||
- CSV parser now correctly handles escaped quotes (`""`) inside quoted fields during import
|
||||
- Fixed dynamic concurrency update during active downloads: changing limit (e.g. `1 -> 3`) now schedules additional queued items without waiting current active item to finish
|
||||
- Queue scheduler now re-checks capacity/queued items on short intervals to avoid blocking on long-running single active download
|
||||
|
||||
### Dependencies
|
||||
|
||||
#### Flutter
|
||||
- `flutter_local_notifications` 19.x → 20.0.0 (breaking: all positional params converted to named params)
|
||||
- `connectivity_plus` 6.x → 7.0.0
|
||||
- `flutter_secure_storage` 9.x → 10.0.0
|
||||
- Removed `palette_generator` dependency
|
||||
|
||||
#### Go
|
||||
- `go-flac/go-flac` v1.0.0 → v2.0.4
|
||||
- `go-flac/flacvorbis` v0.2.0 → v2.0.2
|
||||
- `go-flac/flacpicture` v0.3.0 → v2.0.2
|
||||
- Go toolchain 1.24 → 1.25.7
|
||||
|
||||
#### Android
|
||||
- Android Gradle Plugin 8.x → 9.0.0
|
||||
- Kotlin 2.1.x → 2.3.10
|
||||
- `desugar_jdk_libs` → 2.1.5
|
||||
- `kotlinx-coroutines-android` → 1.10.2
|
||||
- `lifecycle-runtime-ktx` → 2.10.0
|
||||
- `activity-ktx` → 1.12.3
|
||||
|
||||
#### CI/CD
|
||||
- `actions/cache` v4 → v5
|
||||
- `actions/checkout` v4 → v6
|
||||
- `actions/setup-go` v5 → v6
|
||||
- `actions/setup-java` v4 → v5
|
||||
- `softprops/action-gh-release` v1 → v2
|
||||
- GitHub artifact actions updated
|
||||
|
||||
---
|
||||
|
||||
## [3.5.0] - 2026-02-07
|
||||
|
||||
### Highlights
|
||||
|
||||
- **SAF Storage (Android 10+)**: Proper Storage Access Framework support for download destination (content URIs)
|
||||
- Select download folder via SAF tree picker
|
||||
- Downloads now write to SAF file descriptors (`/proc/self/fd/*`) instead of raw filesystem paths
|
||||
- Works around Android 10+ scoped storage permission errors
|
||||
- **Modern Onboarding Experience**: Completely redesigned Setup and Tutorial screens
|
||||
|
||||
### Added
|
||||
|
||||
- Home feed disk caching via SharedPreferences for instant restore on app startup
|
||||
- SAF display path resolver in native Android layer (converts tree URIs to readable paths)
|
||||
- New settings fields for storage mode + SAF tree URI
|
||||
- SAF platform bridge methods: pick tree, stat/exists/delete, open content URI, copy to temp, write back to SAF
|
||||
- SAF library scan mode (DocumentFile traversal + metadata read)
|
||||
- Incremental library scanning for filesystem and SAF paths (only scans new/modified files and detects removed files)
|
||||
- Force Full Scan action in Library Settings to rescan all files on demand
|
||||
- Downloaded files are now excluded from Local Library scan results to prevent duplicate entries
|
||||
- Legacy library rows now support `file_mod_time` backfill before incremental scans (faster follow-up scans after upgrade)
|
||||
- Library UI toggle to show SAF-repaired history items
|
||||
- Scan cancelled banner + retry action for library scans
|
||||
- Android DocumentFile dependency for SAF operations
|
||||
- Post-processing API v2 (SAF-aware, ready to replace v1)
|
||||
- Donate page in Settings with Ko-fi and Buy Me a Coffee links
|
||||
- Per-App Language support on Android 13+ (locale_config.xml)
|
||||
- Interactive tutorial with working search bar simulation and clickable download buttons
|
||||
- Tutorial completion state is persisted after onboarding
|
||||
- Visual feedback animations for page transitions, entrance effects, and feature lists
|
||||
- New dedicated welcome step in setup wizard with improved branding
|
||||
|
||||
### Changed
|
||||
|
||||
- Download pipeline supports `output_path` + `output_ext` for Go backend
|
||||
- Tidal/Qobuz/Amazon/Extension downloads use SAF-aware output when enabled
|
||||
- Post-processing hooks run for SAF content URIs (via temp file bridge)
|
||||
- File operations in Library/Queue/Track screens now SAF-aware (`open`, `exists`, `delete`, `stat`)
|
||||
- Local Library scan defaults to incremental mode; full rescan is available via Force Full Scan
|
||||
- Local library database upgraded to schema v3 with `file_mod_time` tracking for incremental scan cache
|
||||
- Platform channels expanded with incremental scan APIs (`scanLibraryFolderIncremental`) on Android and iOS
|
||||
- Android platform channel adds `getSafFileModTimes` for SAF legacy cache backfill
|
||||
- Android build tooling upgraded to Gradle 9.3.1 (wrapper)
|
||||
- Android build path validated with Java 25 (Gradle/Kotlin/assemble debug)
|
||||
- SAF tree picker flow in `MainActivity` migrated to Activity Result API (`registerForActivityResult`)
|
||||
- `MainActivity` host migrated to `FlutterFragmentActivity` for SAF picker compatibility
|
||||
- Legacy `startActivityForResult` / `onActivityResult` SAF picker path removed
|
||||
- Setup screen UI polish: smaller logo, thin outline borders on text fields
|
||||
- Removed support section from About page (moved to Donate page)
|
||||
- Qobuz squid.wtf region fallback for blocked regions
|
||||
- Setup screen converted to PageView flow with animated progress bar and modern card layouts
|
||||
- Tutorial screen aligned with Setup Screen design, updated typography and softened UI shapes
|
||||
- Larger, more accessible navigation buttons for onboarding flow
|
||||
- Reduced visual noise by removing unnecessary glow effects
|
||||
|
||||
### Fixed
|
||||
|
||||
- Android 10+ `permission denied` when writing to `/storage/emulated/0` (now handled via SAF)
|
||||
- SAF history repair: auto-resolve missing content URIs using tree + filename
|
||||
- SAF download fallback: retry in app-private storage when SAF write fails
|
||||
- Tidal DASH manifest writing when output path is a file descriptor (no invalid `.m4a` path)
|
||||
- External LRC output in SAF mode
|
||||
- Restored old-device renderer fallback while using `FlutterFragmentActivity` by injecting shell args from a custom `FlutterFragment` (`--enable-impeller=false` on problematic devices)
|
||||
- Preserved Flutter fragment creation behavior (cached engine, engine group, new engine) while adding Impeller fallback support
|
||||
- SAF tree picker result now consistently returns `tree_uri` payload with persisted URI permission handling
|
||||
- SAF share file now copies to temp before sharing (fixes share from SAF content URI)
|
||||
- Home feed not updating after installing extension with homeFeed capability (no longer requires app restart)
|
||||
- Library scan hero card showing 0 tracks during scan (now shows scanned file count in real-time)
|
||||
- Library folder picker no longer requires MANAGE_EXTERNAL_STORAGE on Android 10+ (uses SAF tree picker)
|
||||
- One-time SAF migration prompt for users updating from pre-SAF versions
|
||||
- Fixed `fileModTime` propagation across Go/Android/Dart so incremental scan cache is stored and reused correctly
|
||||
- Fixed SAF incremental scan key mismatch (`lastModified` vs `fileModTime`) and normalized result fields (`skippedCount`, `totalFiles`)
|
||||
- Fixed incremental scan progress when all files are skipped (`scanned_files` now reaches total files)
|
||||
- Removed duplicate `"removeExtension"` branch in Android method channel handler (eliminates Kotlin duplicate-branch warning)
|
||||
|
||||
---
|
||||
|
||||
## [3.4.2] - 2026-02-04
|
||||
|
||||
### Improved
|
||||
|
||||
- **Mobile Network Reliability**: All providers (Qobuz, Tidal, Amazon, Deezer) now have retry logic with exponential backoff
|
||||
- Increased API timeouts: 15s → 25s (Deezer, Qobuz, Tidal), 30s (Amazon)
|
||||
- Up to 3 retry attempts per API call (500ms → 1s → 2s backoff)
|
||||
- Retryable: timeout, connection reset/refused, EOF, HTTP 5xx, HTTP 429
|
||||
- **SongLink ID Extraction**: Extract QobuzID/TidalID directly from SongLink URLs
|
||||
- New fields in `TrackAvailability`: `QobuzID`, `TidalID`
|
||||
- Qobuz/Tidal now use direct Track ID from SongLink instead of re-parsing URLs
|
||||
- **Qobuz Download Flow**: New Strategy 3 - get QobuzID from SongLink before ISRC search
|
||||
- Cache hit now uses `GetTrackByID()` directly instead of searching again
|
||||
- Pre-warm cache tries SongLink first before direct ISRC search
|
||||
- **Tidal Download Flow**: Use `availability.TidalID` directly from SongLink struct
|
||||
|
||||
---
|
||||
|
||||
## [3.4.1] - 2026-02-04
|
||||
|
||||
### Fixed
|
||||
|
||||
- Metadata Priority order now persists after app restart
|
||||
- Download Provider Priority order now persists after app restart
|
||||
|
||||
---
|
||||
|
||||
## [3.4.0] - 2026-02-03
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Local Library Scanning** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): Scan existing music collection to detect duplicates (FLAC, M4A, MP3, Opus, OGG)
|
||||
- **Duplicate Detection** ([#117](https://github.com/zarzet/SpotiFLAC-Mobile/issues/117)): "In Library" badge on tracks matching by ISRC or track name + artist
|
||||
- **Unified Library Tab**: History renamed to Library, shows Downloaded + Local Library tracks with source badges
|
||||
|
||||
### Added
|
||||
|
||||
- Local Album Screen with cover art, disc grouping, and selection mode
|
||||
- Albums tab shows local library albums with folder icon badge
|
||||
- Singles filter includes local library singles
|
||||
- Advanced library filters: Source, Quality, Format, Date
|
||||
- Cover art extraction from embedded tags (FLAC, MP3, Opus/Ogg)
|
||||
- "Already in Library" notification when downloading existing tracks
|
||||
- Spotify secrets now stored in secure storage (`flutter_secure_storage`)
|
||||
- **Multi-Service Link Support**: Share links from Deezer, Tidal, and YouTube Music (in addition to Spotify)
|
||||
- Deezer: Full support for track, album, playlist, artist links
|
||||
- Tidal: Track links converted via SongLink to Spotify/Deezer for metadata
|
||||
- YouTube Music: Handled via ytmusic extension URL handler
|
||||
- Local library tracks now open metadata screen on tap
|
||||
|
||||
### Changed
|
||||
|
||||
- Extension HTTP sandbox enforces HTTPS and blocks private IPs
|
||||
- Extension file sandbox validates paths with boundary-safe checks
|
||||
|
||||
### Fixed
|
||||
|
||||
- Search filter bar now only appears after results load, not during loading
|
||||
- MP3/Ogg metadata parsing (ID3v2 extended headers, Ogg packet reassembly)
|
||||
- Library scan metadata (ISRC, disc number, release date)
|
||||
- Cover cache robustness (size + mtime cache key)
|
||||
- Local library selection and delete in list/grid views
|
||||
- Albums/Singles count includes local library items
|
||||
|
||||
---
|
||||
|
||||
## [3.3.6] - 2026-02-02
|
||||
|
||||
### Added
|
||||
|
||||
- **WiFi-Only Download Mode**: Pause downloads on mobile data, auto-resume on WiFi (Settings > Download > Download Network)
|
||||
- Added `connectivity_plus: ^6.0.3` dependency
|
||||
|
||||
---
|
||||
|
||||
## [3.3.5] - 2026-02-01
|
||||
|
||||
Same as 3.3.1 but fixes crash issues caused by FFmpeg.
|
||||
|
||||
### Added
|
||||
|
||||
- **Export Failed Downloads**: Export failed downloads to TXT file for easy lookup on other platforms
|
||||
- **Auto-Export Setting**: Option to automatically export failed downloads when queue finishes
|
||||
|
||||
### Fixed
|
||||
|
||||
- **FFmpeg Crash**: Fixed crash issues during M4A to MP3/Opus conversion
|
||||
- **Service Selection Ignored**: Fixed bug where selecting Qobuz/Amazon from service picker was ignored and always used Tidal instead
|
||||
- **iOS iCloud Drive Permission Error**: Block iCloud Drive folder selection on iOS (Go backend cannot access iCloud due to sandboxing)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Amazon Fallback Only**: Amazon Music is now grayed out in service picker and can only be used as fallback provider
|
||||
|
||||
---
|
||||
|
||||
## [3.3.1] - 2026-02-01
|
||||
|
||||
### Added
|
||||
|
||||
- **Clear All Queue Button**: Cancel all queued downloads with one tap ([#96](https://github.com/zarzet/SpotiFLAC-Mobile/issues/96))
|
||||
- **IDHS Fallback**: Fallback link resolver when SongLink fails (rate limited 8 req/min)
|
||||
- **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps)
|
||||
- **Search Filters**: Filter results by type (Tracks, Artists, Albums, Playlists)
|
||||
- **Album/Playlist Search**: Deezer search now includes albums and playlists
|
||||
- **New Languages**: Turkish (Kaan, BedirhanGltkn), Japanese (Re\*Index.(ot_inc))
|
||||
- **Optional All Files Access**: Android 13+ no longer requires full storage access; enable in Settings if needed
|
||||
- **Improved VPN Compatibility**: Better HTTP/2 support for users behind VPN or restricted networks
|
||||
|
||||
### Changed
|
||||
|
||||
- **Amazon Download API**: Switched to AfkarXYZ API
|
||||
- **Qobuz Download API**: Added Jumo API as fallback
|
||||
- **Search Results**: Reduced artist limit from 5 to 2
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MP3 Download Error 403**: Fixed 403 Forbidden error when downloading MP3 files ([#108](https://github.com/zarzet/SpotiFLAC-Mobile/issues/108))
|
||||
- **Opus Cover Art**: Implemented METADATA_BLOCK_PICTURE for proper cover embedding
|
||||
- **Deezer Pagination**: Fixed >25 tracks only showing first 25 ([#112](https://github.com/zarzet/SpotiFLAC-Mobile/issues/112))
|
||||
- **Duplicate Embed Lyrics Setting**: Removed from Options page ([#110](https://github.com/zarzet/SpotiFLAC-Mobile/issues/110))
|
||||
|
||||
---
|
||||
|
||||
## [3.2.1] - 2026-01-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Artist/Album + Singles Folder Structure**: Singles go inside artist folder (`Artist/Album/`, `Artist/Singles/`)
|
||||
- **Embed Lyrics Button**: Manually embed online lyrics into tracks from Track Info screen (preserves synced timestamps)
|
||||
- **Pause/Resume Button**: Added pause and resume controls next to "Downloading" header in History screen
|
||||
- **Instrumental Detection**: Tracks marked as instrumental on lrclib.net now show "Instrumental track" instead of "Lyrics not available"
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Lyrics**: Multi-artist tracks now search by primary artist first, then full string
|
||||
- **Lyrics**: Metadata tags (`[ti:...]`, `[ar:...]`, `[by:...]`) no longer shown in display
|
||||
- **Lyrics**: Embed button now correctly appears for tracks with online lyrics
|
||||
- **Lyrics**: Manual embed preserves original timestamps instead of plain text
|
||||
- **iOS**: Fixed "File not found" after 3.1.x → 3.2.0 update (container UUID migration)
|
||||
- **Home Feed**: Greeting now uses device local time
|
||||
- **Deezer**: Track position fallback to index+1 when API returns 0
|
||||
- **Localization**: Fixed 16 ICU plural syntax warnings in Spanish & Portuguese
|
||||
|
||||
### Performance
|
||||
|
||||
- **Home Feed**: Precomputed Quick Picks section flag and reduced per-page allocations; explore state now watched by field to cut rebuilds
|
||||
- **Home Recent**: Cached recent-access aggregation and limited list allocations for recent downloads
|
||||
- **Settings/Theme/Recent**: Cached SharedPreferences instance to avoid repeated `getInstance()` calls
|
||||
- **History/DB**: Batched iOS path migration updates to reduce write overhead
|
||||
- **Download Queue**: Reduced polling allocations and avoided double-load scheduling for history
|
||||
- **Misc**: Precompiled regex in share intent, update dialog, extensions error parsing, log analysis, and LRC cleanup; faster palette cache hits and log filtering
|
||||
|
||||
---
|
||||
|
||||
## [3.2.0] - 2026-01-22
|
||||
|
||||
> **Note:** Starting from v3.2.0, changelogs will be concise.
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Discography Download** (Highly Requested): Download entire artist discography with album selection mode
|
||||
- **Home Feed / Explore**: Personalized sections from spotify-web and ytmusic extensions
|
||||
- **SQLite History Database**: O(1) lookups, non-blocking writes
|
||||
|
||||
### Added
|
||||
|
||||
- Discography download with options: All, Albums Only, Singles Only, or Select Albums
|
||||
- Artist navigation from album screen (tap artist name)
|
||||
- Home feed sections with pull-to-refresh
|
||||
- YT Music Quick Picks swipeable UI
|
||||
- `gobackend.getLocalTime()` API for extensions
|
||||
- Track duration in home feed items
|
||||
- Release date badge in album info card
|
||||
|
||||
### Improved
|
||||
|
||||
- Album track list shows track number instead of cover image
|
||||
- Download buttons with more rounded corners
|
||||
- Downloaded songs in Recent show primary-colored subtitle
|
||||
|
||||
### Fixed
|
||||
|
||||
- Home feed timezone detection
|
||||
- Track duration 0:00 when downloading from home feed
|
||||
|
||||
### Extensions
|
||||
|
||||
- spotify-web v1.8.1: Home feed, artist_id support
|
||||
- ytmusic v1.6.1: Home feed, artist_id support
|
||||
|
||||
---
|
||||
|
||||
## [3.1.3] - 2026-01-19
|
||||
|
||||
### Added
|
||||
|
||||
- **External LRC Lyrics File Support**: Option to save lyrics as separate .lrc files for compatibility with external music players
|
||||
- New "Lyrics Mode" setting in Settings > Download > Lyrics section
|
||||
- Three modes available:
|
||||
- **Embed in file** (default): Lyrics stored inside FLAC metadata
|
||||
- **External .lrc file**: Save lyrics as separate .lrc file next to audio file
|
||||
- **Both**: Embed and save external .lrc file
|
||||
- Perfect for players like Samsung Music that prefer external .lrc files
|
||||
- LRC files include metadata headers (title, artist, by:SpotiFLAC-Mobile)
|
||||
- Works with all download services (Tidal, Qobuz, Amazon)
|
||||
- **CSV Import Quality Selection**: Choose audio quality when importing CSV playlists
|
||||
- Quality picker now appears before adding CSV tracks to download queue
|
||||
- Select between FLAC qualities (Lossless, Hi-Res, Hi-Res Max) or MP3
|
||||
- Respects "Ask quality before download" setting - uses default quality if disabled
|
||||
- **Persistent Cover Image Cache**: Album/track cover images now cached to persistent storage instead of temporary directory
|
||||
- Cover images no longer disappear when app is closed or device restarts
|
||||
- Cache stored in `app_flutter/cover_cache/` directory (not cleared by system)
|
||||
- Maximum 1000 images cached for up to 365 days
|
||||
- Covers are cached when displayed in History, Home, Album, Artist, or any other screen
|
||||
- New `CoverCacheManager` service with `clearCache()` and `getStats()` methods for future cache management
|
||||
- **Extended Metadata from Deezer Enrichment**: Track downloads now include label, copyright, and genre metadata from Deezer
|
||||
- New fields in `ExtTrackMetadata`: `label`, `copyright`, `genre`
|
||||
- Metadata fetched during `enrichTrack()` via Deezer album API
|
||||
- Embedded as FLAC Vorbis comments: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT`
|
||||
- Works for both extension downloads and built-in provider downloads (Tidal, Qobuz, Amazon)
|
||||
- **Track Metadata Screen Extended Info**: Genre, label, and copyright now displayed in track metadata screen
|
||||
- Added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` model
|
||||
- Metadata is stored in download history and persists across app restarts
|
||||
- New localization strings: `trackGenre`, `trackLabel`, `trackCopyright`
|
||||
- `**utils.randomUserAgent()` for Extensions\*\*: New utility function for extensions to get random browser User-Agent strings
|
||||
- Returns modern Chrome User-Agent format: `Chrome/{120-145}.0.{6000-7499}.{100-299}` with `Windows NT 10.0`
|
||||
- Useful for extensions that need to rotate User-Agents to avoid detection
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Portuguese Language Bug**: Fixed locale parsing for languages with country codes (e.g., pt_PT, es_ES)
|
||||
- App now correctly loads Portuguese and Spanish translations
|
||||
- Updated Portuguese label to "Português (Brasil)"
|
||||
- **VM Race Condition Panic**: Fixed `panic during execution: runtime error: index out of range [-2]` crash when switching search providers
|
||||
- Root cause: Goja VM was being accessed concurrently by multiple goroutines without synchronization
|
||||
- Added `VMMu sync.Mutex` to `LoadedExtension` struct
|
||||
- Added mutex lock/unlock to ALL `ExtensionProviderWrapper` methods:
|
||||
- `SearchTracks`, `GetTrack`, `GetAlbum`, `GetArtist`
|
||||
- `EnrichTrack`, `CheckAvailability`, `GetDownloadURL`, `Download`
|
||||
- `CustomSearch`, `HandleURL`, `MatchTrack`, `PostProcess`
|
||||
- Prevents race conditions when rapidly switching between extension search providers
|
||||
- **Tidal Release Date Fallback**: Fixed missing release date in FLAC metadata when downloading from Tidal
|
||||
- Now uses Tidal API's release date when `req.ReleaseDate` is empty
|
||||
- Ensures release date is always embedded in downloaded files
|
||||
- **Extended Metadata for M4A→FLAC Conversion**: Fixed genre, label, and copyright not being embedded when converting Amazon M4A to FLAC
|
||||
- Flutter now extracts extended metadata from Go backend response
|
||||
- Passes `genre`, `label`, `copyright` parameters to `_embedMetadataAndCover()`
|
||||
- Tags correctly embedded during FFmpeg conversion
|
||||
- **Extended Metadata for MP3 Conversion**: Genre, label, and copyright now embedded in MP3 files when converting from FLAC
|
||||
- Added `genre`, `label`, `copyright` parameters to `_embedMetadataToMp3()`
|
||||
- Tags embedded as ID3v2: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT`
|
||||
|
||||
### Extensions
|
||||
|
||||
- **spotify-web Extension**: Updated to v1.7.0
|
||||
- Added `getMetadataFromDeezer()` function to fetch extended metadata:
|
||||
- ISRC from track
|
||||
- Label from album
|
||||
- Copyright (generated as "YEAR LABEL")
|
||||
- Genre from album genres
|
||||
- Release date
|
||||
- `enrichTrack()` now returns all extended metadata to Go backend
|
||||
- Replaced all hardcoded User-Agent strings with `utils.randomUserAgent()`
|
||||
|
||||
### Performance
|
||||
|
||||
- **Faster App Startup**: Notification, Share Intent, and Cover Cache Manager initialization now run in parallel
|
||||
- **Download Queue Polling**: Batched progress updates reduce rebuilds and list allocations during active downloads
|
||||
- **Queue Item Updates**: Status/progress updates now skip no-op changes and update by index for fewer allocations
|
||||
- **Directory Creation**: Download output folders are created once per path, reducing repeated I/O for albums/singles
|
||||
- **Search Results Rendering**: Single-pass filtering avoids repeated `indexOf` calls for large result sets
|
||||
- **Queue Lookups in UI**: O(1) lookup for queue status in Home/Album/Playlist/Artist track lists
|
||||
- **History Filtering**: Album/single counts and grouping are computed once per build
|
||||
- **Downloaded Album View**: Tracks are grouped by disc in one pass to reduce filtering overhead
|
||||
- **Track Metadata Screen**:
|
||||
- Palette extraction deferred until after transition; reduced sample size for smoother navigation
|
||||
- File stat uses a single syscall and only triggers state updates on change
|
||||
- Static regex/month table avoids repeated allocations
|
||||
- Cover precached before opening metadata from history/queue/recents
|
||||
- **Flutter Provider Optimizations**:
|
||||
- Cache `SharedPreferences` instance in `DownloadHistoryNotifier` and `DownloadQueueNotifier` to avoid repeated `getInstance()` calls
|
||||
- Precompile regex for folder name sanitization and year extraction (top-level `final`)
|
||||
- Use `indexWhere` instead of `firstWhere` with placeholder object to reduce allocations in queue processing
|
||||
- **Flutter UI Optimizations**:
|
||||
- Selective `ref.watch()` for `downloadQueueProvider` (watch only `queuedCount` or `items` instead of entire state)
|
||||
- Pass `Track` directly to `_buildTrackTile()` instead of index lookup inside builder
|
||||
- Pass `historyItems` as parameter to `_buildRecentAccess()` to avoid `ref.read()` inside method
|
||||
- **M4A Metadata Embedding**: Streaming implementation reduces memory usage for large files
|
||||
- Uses `os.Open()` + `ReadAt` instead of `os.ReadFile()` (no full file load into memory)
|
||||
- Atomic file replacement via temp file + rename for safer writes
|
||||
- New helper functions: `findAtomInRange()`, `readAtomHeaderAt()`, `copyRange()`
|
||||
|
||||
### Backend
|
||||
|
||||
- **Deezer ISRC Fetching**: Uses ISRCs already present in payloads and caches them, cutting extra API calls
|
||||
- **SearchAll Allocation**: Preallocated slices to reduce allocations during Deezer search
|
||||
- **HTTP Client Helper**: Refactored HTTP client creation to use `NewHTTPClientWithTimeout()` helper function across `lyrics.go`, `qobuz.go`, `tidal.go`
|
||||
|
||||
### Technical
|
||||
|
||||
- **Go Backend Changes**:
|
||||
- `go_backend/extension_providers.go`: Added `Label`, `Copyright`, `Genre` fields to `ExtTrackMetadata`; added mutex locks to all provider methods
|
||||
- `go_backend/extension_manager.go`: Added `VMMu sync.Mutex` to `LoadedExtension` struct
|
||||
- `go_backend/extension_runtime.go`: Added `utils.randomUserAgent` function
|
||||
- `go_backend/extension_runtime_utils.go`: Added `randomUserAgent()` implementation
|
||||
- `go_backend/httputil.go`: Updated `getRandomUserAgent()` to use modern Chrome versions
|
||||
- `go_backend/tidal.go`: Added release date fallback logic
|
||||
- `go_backend/exports.go`: Added `Genre`, `Label`, `Copyright` fields to `DownloadResponse`
|
||||
- **Flutter Changes**:
|
||||
- `lib/services/cover_cache_manager.dart`: New persistent cache manager for cover images (365 days, 1000 images max)
|
||||
- `lib/widgets/cached_cover_image.dart`: Wrapper widget for CachedNetworkImage with persistent cache
|
||||
- `lib/main.dart`: Added `CoverCacheManager.initialize()` to app startup
|
||||
- `lib/screens/*.dart`: All 11 screens updated to use persistent cache manager for CachedNetworkImage
|
||||
- `lib/providers/download_queue_provider.dart`: Updated `_embedMetadataAndCover()` to accept and embed genre, label, copyright; added `genre`, `label`, `copyright` fields to `DownloadHistoryItem`
|
||||
- `lib/screens/track_metadata_screen.dart`: Display genre, label, copyright in metadata grid
|
||||
- `lib/l10n/arb/app_en.arb`: Added `trackGenre`, `trackLabel`, `trackCopyright` localization strings
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Added `flutter_cache_manager: ^3.4.1` (explicit dependency for persistent cache)
|
||||
- Added `path: ^1.9.0` (for cache directory path handling)
|
||||
|
||||
---
|
||||
|
||||
## [3.1.2] - 2026-01-19
|
||||
|
||||
### Added
|
||||
|
||||
- **New Languages**: Added Spanish (es) and Portuguese (pt) translations
|
||||
- Spanish: Credits 125 ([@credits125](https://crowdin.com/profile/credits125))
|
||||
- Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro))
|
||||
- Russian: Владислав ([@odinokiy_kot](https://crowdin.com/profile/odinokiy_kot))
|
||||
- **Quick Search Provider Switcher** ([#76](https://github.com/zarzet/SpotiFLAC-Mobile/issues/76)): Dropdown menu in search bar for instant provider switching
|
||||
- Tap the search icon to reveal a dropdown menu with all available search providers
|
||||
- Shows default provider (Deezer based on metadata source setting) at the top
|
||||
- Lists all enabled extensions with custom search capability
|
||||
- Displays extension icons when available
|
||||
- Checkmark indicates currently selected provider
|
||||
- Search hint text updates immediately when switching providers
|
||||
- Re-triggers search automatically if there's existing text in the search bar
|
||||
- Eliminates need to navigate to Settings > Extensions > Search Provider
|
||||
- **Extension Button Setting Type** ([#74](https://github.com/zarzet/SpotiFLAC-Mobile/issues/74)): New setting type for extension actions
|
||||
- Extensions can define `button` type in manifest settings
|
||||
- Triggers JavaScript function when tapped (e.g., start OAuth flow)
|
||||
- Useful for authentication, manual sync, or any custom action
|
||||
- **Genre & Label Metadata** ([#75](https://github.com/zarzet/SpotiFLAC-Mobile/issues/75)): Downloaded tracks now include genre and record label information
|
||||
- Fetches genre and label from Deezer album API for each track
|
||||
- Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files
|
||||
- Works automatically when Deezer track ID is available (via ISRC matching)
|
||||
- Supports all download services (Tidal, Qobuz, Amazon) and extension downloads
|
||||
- **MP3 Quality Option** ([#69](https://github.com/zarzet/SpotiFLAC-Mobile/issues/69)): Optional MP3 download format with FLAC-to-MP3 conversion
|
||||
- New "Enable MP3 Option" toggle in Settings > Download > Audio Quality
|
||||
- When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options
|
||||
- Available in both the quality picker dialog and default quality settings
|
||||
- Works with all services (Tidal, Qobuz, Amazon) and extensions
|
||||
- **MP3 Metadata Embedding**: Full metadata support for MP3 files
|
||||
- Cover art embedded using ID3v2 tags
|
||||
- Synced lyrics embedded (fetched from lrclib.net)
|
||||
- All metadata preserved: title, artist, album, album artist, track/disc number, date, ISRC
|
||||
- Automatic tag conversion from Vorbis comments (FLAC) to ID3v2 (MP3)
|
||||
- **Dominant Color Header**: Album, Playlist, Downloaded Album, and Track Metadata screens now feature dynamic header backgrounds
|
||||
- Extracts dominant color from cover art using `palette_generator`
|
||||
- Creates a gradient from dominant color to theme surface color
|
||||
- Smooth 500ms color transition animation
|
||||
- **Larger Cover Art**: Cover images on detail screens are now 50% of screen width (previously 140px fixed)
|
||||
- More prominent album artwork display
|
||||
- Larger shadow and rounded corners (20px radius)
|
||||
- Higher resolution cover caching
|
||||
- **Sticky Title**: Title appears in AppBar when scrolling past the info card
|
||||
- Smooth fade-in animation (200ms) when scrolling down
|
||||
- Title hidden when header is expanded (shows in info card instead)
|
||||
- AppBar uses theme color (surface) for clean, native look
|
||||
- Works on Album, Playlist, Downloaded Album, Track Metadata, and Artist screens
|
||||
- **Artist Name in Album Screen**: Album info card now displays artist name below album title
|
||||
- Extracted from first track's artist metadata
|
||||
- Styled with `onSurfaceVariant` color for visual hierarchy
|
||||
- **Disc Separation for Multi-Disc Albums** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloaded albums with multiple discs now display tracks grouped by disc
|
||||
- Visual disc separator header showing "Disc 1", "Disc 2", etc.
|
||||
- Tracks sorted by disc number first, then by track number
|
||||
- Single-disc albums display normally without separators
|
||||
- Fixes confusion when albums have duplicate track numbers across discs
|
||||
- **Album Grouping in Recents** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloads now show as albums instead of individual tracks in the Recent section
|
||||
- Prevents flooding the recents list when downloading full albums
|
||||
- Groups tracks by album name and artist
|
||||
- Tapping navigates directly to the downloaded album screen
|
||||
- Shows the most recent download time for each album
|
||||
|
||||
### Changed
|
||||
|
||||
- **FFmpeg FLAC-to-MP3 Conversion**: Improved conversion process
|
||||
- MP3 files now saved in the same folder as FLAC (no separate MP3 subfolder)
|
||||
- Original FLAC file automatically deleted after successful conversion
|
||||
- New `embedMetadataToMp3()` method for MP3-specific tag embedding
|
||||
- **Sticky Header Theme Integration**: AppBar background uses `colorScheme.surface` instead of dominant color when collapsed
|
||||
- Dark theme: Black background with white text
|
||||
- Light theme: White background with black text
|
||||
- Matches modern app behavior for better readability
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MP3 Quality Display in Track Metadata**: Fixed incorrect quality display for MP3 files
|
||||
- MP3 files now show "320kbps" instead of FLAC's bit depth/sample rate
|
||||
- History no longer stores FLAC audio specs for converted MP3 files
|
||||
- Both File Info badges and metadata grid show correct MP3 quality
|
||||
- **Empty Catch Blocks**: Fixed analyzer warnings for empty catch blocks
|
||||
- `download_queue_provider.dart`: Added comments explaining why polling errors are silently ignored
|
||||
- `track_provider.dart`: Added comments explaining why availability check errors are silently ignored
|
||||
- `ffmpeg_service.dart`: Added proper error logging for temp file cleanup failures
|
||||
- **Russian Plural Forms**: Fixed ICU syntax warnings in Russian localization
|
||||
- Removed redundant `=1` clauses that were overriding `one` plural category
|
||||
- Affected 10 plural strings including track counts and delete confirmations
|
||||
- Plurals now correctly handle Russian grammar (1 трек, 2 трека, 5 треков)
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Added `palette_generator: ^0.3.3+4` for cover art color extraction
|
||||
|
||||
---
|
||||
|
||||
## [3.1.1] - 2026-01-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Lyrics Caching**: Lyrics are now cached for 24 hours to reduce API calls and improve performance
|
||||
- Thread-safe cache with automatic expiration
|
||||
- Cache key based on artist, track, and duration
|
||||
- Log indicator shows "(cached)" when lyrics are served from cache
|
||||
- **Lyrics Duration Matching**: Improved lyrics accuracy with duration-based matching
|
||||
- Compares track duration with lrclib.net results
|
||||
- 10-second tolerance to handle version differences (radio edit, remaster, etc.)
|
||||
- Prioritizes synced lyrics over plain text when duration matches
|
||||
- Falls back gracefully if no duration match found
|
||||
- **Deezer Cover Art Upgrade**: Cover art from Deezer CDN now automatically upgraded to maximum quality
|
||||
- Detects Deezer CDN URLs (`cdn-images.dzcdn.net`)
|
||||
- Upgrades cover resolution to 1800x1800 (max available)
|
||||
- Works alongside existing cover upgrade
|
||||
- **Live Search for Extensions**: Search-as-you-type functionality for extension search
|
||||
- 800ms debounce delay to prevent excessive API calls
|
||||
- Minimum 3 characters required before searching
|
||||
- Concurrency control to prevent race conditions in extension runtime
|
||||
- Queues pending searches if a search is already in progress
|
||||
- **Russian Language Support**: Added Russian (Русский) translation - 99% complete
|
||||
- Translated via Crowdin community contributions
|
||||
- Covers all UI elements, settings, and error messages
|
||||
|
||||
### Fixed
|
||||
|
||||
- **ISRC Index Race Condition**: Fixed repeated index rebuilding during parallel downloads
|
||||
- Added per-directory build lock using `sync.Map` and `sync.Mutex`
|
||||
- Double-check locking pattern ensures index is built only once
|
||||
- Significantly improves performance during CSV import with many tracks
|
||||
- **Queue Tab Scroll Exception**: Fixed Flutter rendering exception with NestedScrollView
|
||||
- Disabled Material 3 stretch overscroll indicator that caused `_StretchController` assertion
|
||||
- Wrapped NestedScrollView with ScrollConfiguration to prevent `setState during build` errors
|
||||
- Issue was especially noticeable during rapid queue updates (CSV import)
|
||||
- **CSV Import**: Fixed CSV export not being parsed correctly
|
||||
- Added support for `Artist Name(s)` header (with parentheses)
|
||||
- Added support for `Track URI` header for track IDs
|
||||
- Added `artists` and `track_id` as alternative header names
|
||||
- Now correctly parses "Liked Songs" and playlist exports
|
||||
|
||||
---
|
||||
|
||||
## [3.1.0] - 2026-01-16
|
||||
|
||||
### Added
|
||||
|
||||
- **Recent Access History**: Quick access to recently visited content when tapping the search bar
|
||||
- **Artist Screen Redesign**: Full-width header, monthly listeners, top tracks section
|
||||
- **Extension Store Update Badge**: Badge indicator showing available extension updates
|
||||
- **Extension Compatibility Warning**: Warning for extensions requiring newer app version
|
||||
- **Year in Album Folder Name**: New folder structure options with release year
|
||||
- **Extension Album/Playlist/Artist Support**: Extensions can now return collections in search
|
||||
- **Odesli Integration**: YouTube Music extension can now match tracks to Deezer/Tidal/Qobuz
|
||||
- **Download Cancel**: Properly stops in-flight downloads
|
||||
|
||||
### Changed
|
||||
|
||||
- Search bar behavior improved with recent access history
|
||||
|
||||
### Fixed
|
||||
|
||||
- Multiple extension-related fixes for artist, album, and playlist handling
|
||||
- UI fixes for search, settings, and navigation
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0] - 2026-01-14
|
||||
|
||||
### Extension System (Major Feature)
|
||||
|
||||
SpotiFLAC 3.0 introduces a powerful extension system that allows third-party integrations for metadata, downloads, and more.
|
||||
|
||||
- **Extension Store**: Browse and install extensions directly from the app
|
||||
- **Web Extension**: Metadata provider for personalized playlists
|
||||
- **Extension Capabilities**: Custom search, URL handlers, thumbnail ratios, post-processing
|
||||
- **Extension APIs**: Full HTTP, storage, file, and crypto support
|
||||
- **Security**: Sandboxed JavaScript runtime with permission-based access
|
||||
|
||||
### Added
|
||||
|
||||
- Album folder structure settings
|
||||
- Separate singles folder option
|
||||
- Year in album folder name
|
||||
- Parallel API calls for faster downloads
|
||||
- Swipeable history filters
|
||||
|
||||
### Fixed
|
||||
|
||||
- Tab edge overscroll
|
||||
- Extension duplicate load error
|
||||
- Settings item highlight on swipe
|
||||
- Back gesture freeze on Android 13+
|
||||
- Bottom overflow in dialogs
|
||||
- Japanese artist name matching
|
||||
- Multi-artist matching
|
||||
- Max resolution cover download
|
||||
- Various extension-related fixes
|
||||
|
||||
---
|
||||
|
||||
_For older versions, see [GitHub Releases_](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
@@ -0,0 +1,133 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**[zarzet](https://github.com/zarzet)**.
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -0,0 +1,268 @@
|
||||
# Contributing to SpotiFLAC
|
||||
|
||||
First off, thank you for considering contributing to SpotiFLAC! 🎉
|
||||
|
||||
This document provides guidelines and steps for contributing. Following these guidelines helps maintain code quality and ensures a smooth collaboration process.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [How Can I Contribute?](#how-can-i-contribute)
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Suggesting Features](#suggesting-features)
|
||||
- [Code Contributions](#code-contributions)
|
||||
- [Translations](#translations)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Coding Guidelines](#coding-guidelines)
|
||||
- [Commit Guidelines](#commit-guidelines)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
|
||||
|
||||
## How Can I Contribute?
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
Before creating bug reports, please check the [existing issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues) to avoid duplicates.
|
||||
|
||||
When creating a bug report, please use the bug report template and include:
|
||||
|
||||
- **Clear and descriptive title**
|
||||
- **Steps to reproduce** the issue
|
||||
- **Expected behavior** vs **actual behavior**
|
||||
- **Screenshots or screen recordings** if applicable
|
||||
- **Device information** (model, OS version)
|
||||
- **App version**
|
||||
- **Logs** from Settings > About > View Logs
|
||||
|
||||
### Suggesting Features
|
||||
|
||||
Feature requests are welcome! Please use the feature request template and:
|
||||
|
||||
- **Check existing issues** to avoid duplicates
|
||||
- **Describe the feature** clearly
|
||||
- **Explain the use case** - why would this be useful?
|
||||
- **Consider the scope** - is this a small enhancement or a major feature?
|
||||
|
||||
### Code Contributions
|
||||
|
||||
1. **Fork the repository** and create your branch from `dev`
|
||||
2. **Make your changes** following our coding guidelines
|
||||
3. **Test your changes** thoroughly
|
||||
4. **Submit a pull request** to the `dev` branch
|
||||
|
||||
### Translations
|
||||
|
||||
We use [Crowdin](https://crowdin.com/project/spotiflac-mobile) for translations. To contribute:
|
||||
|
||||
1. Visit our [Crowdin project](https://crowdin.com/project/spotiflac-mobile)
|
||||
2. Select your language or request a new one
|
||||
3. Start translating!
|
||||
|
||||
Translation files are located in `lib/l10n/arb/`.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Flutter SDK** 3.10.0 or higher
|
||||
- **Dart SDK** 3.10.0 or higher
|
||||
- **Android Studio** or **VS Code** with Flutter extensions
|
||||
- **Git**
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. **Clone your fork**
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/SpotiFLAC-Mobile.git
|
||||
cd SpotiFLAC-Mobile
|
||||
```
|
||||
|
||||
2. **Add upstream remote**
|
||||
```bash
|
||||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||
```
|
||||
|
||||
3. **Install dependencies**
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
4. **Generate code** (for Riverpod, JSON serialization, etc.)
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
5. **Run the app**
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Debug build
|
||||
flutter build apk --debug
|
||||
|
||||
# Release build
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
├── l10n/ # Localization files
|
||||
│ └── arb/ # ARB translation files
|
||||
├── models/ # Data models
|
||||
├── providers/ # Riverpod providers
|
||||
├── screens/ # UI screens
|
||||
│ └── settings/ # Settings sub-screens
|
||||
├── services/ # Business logic services
|
||||
├── theme/ # App theming
|
||||
├── utils/ # Utility functions
|
||||
├── widgets/ # Reusable widgets
|
||||
├── app.dart # App configuration
|
||||
└── main.dart # Entry point
|
||||
```
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
### General
|
||||
|
||||
- Follow [Effective Dart](https://dart.dev/effective-dart) guidelines
|
||||
- Use meaningful variable and function names
|
||||
- Keep functions small and focused
|
||||
- Add comments for complex logic
|
||||
|
||||
### Formatting
|
||||
|
||||
- Use `dart format` before committing
|
||||
- Maximum line length: 80 characters
|
||||
- Use trailing commas for better formatting
|
||||
|
||||
```bash
|
||||
dart format .
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
Ensure your code passes all lints:
|
||||
|
||||
```bash
|
||||
flutter analyze
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
We use **Riverpod** for state management. Follow these patterns:
|
||||
|
||||
```dart
|
||||
// Use code generation with riverpod_annotation
|
||||
@riverpod
|
||||
class MyNotifier extends _$MyNotifier {
|
||||
@override
|
||||
MyState build() => MyState();
|
||||
|
||||
// Methods to update state
|
||||
}
|
||||
```
|
||||
|
||||
### Localization
|
||||
|
||||
All user-facing strings should be localized:
|
||||
|
||||
```dart
|
||||
// Good
|
||||
Text(AppLocalizations.of(context)!.downloadComplete)
|
||||
|
||||
// Bad
|
||||
Text('Download Complete')
|
||||
```
|
||||
|
||||
To add new strings:
|
||||
1. Add the key to `lib/l10n/arb/app_en.arb`
|
||||
2. Run `flutter gen-l10n`
|
||||
|
||||
## Commit Guidelines
|
||||
|
||||
We follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
- `feat`: New feature
|
||||
- `fix`: Bug fix
|
||||
- `docs`: Documentation changes
|
||||
- `style`: Code style changes (formatting, etc.)
|
||||
- `refactor`: Code refactoring
|
||||
- `perf`: Performance improvements
|
||||
- `test`: Adding or updating tests
|
||||
- `chore`: Maintenance tasks
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
feat(download): add batch download support
|
||||
fix(ui): resolve overflow on small screens
|
||||
docs: update contributing guidelines
|
||||
chore(deps): update flutter_riverpod to 3.1.0
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Update your fork**
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/dev
|
||||
```
|
||||
|
||||
2. **Create a feature branch**
|
||||
```bash
|
||||
git checkout -b feat/my-new-feature
|
||||
```
|
||||
|
||||
3. **Make your changes** and commit following our guidelines
|
||||
|
||||
4. **Push to your fork**
|
||||
```bash
|
||||
git push origin feat/my-new-feature
|
||||
```
|
||||
|
||||
5. **Create a Pull Request**
|
||||
- Target the `dev` branch
|
||||
- Fill in the PR template
|
||||
- Link related issues
|
||||
|
||||
6. **Address review feedback**
|
||||
- Make requested changes
|
||||
- Push additional commits
|
||||
- Request re-review when ready
|
||||
|
||||
### PR Requirements
|
||||
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] All tests pass
|
||||
- [ ] No new linting errors
|
||||
- [ ] Documentation updated (if needed)
|
||||
- [ ] Commit messages follow guidelines
|
||||
- [ ] PR description is clear and complete
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions, feel free to:
|
||||
|
||||
- Open a [Discussion](https://github.com/zarzet/SpotiFLAC-Mobile/discussions)
|
||||
- Check existing [Issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues)
|
||||
|
||||
Thank you for contributing! 💚
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 zarzet
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,42 +1,88 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Android/releases)
|
||||
|
||||

|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
<div align="center">
|
||||
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
<img src="icon.png" width="128" />
|
||||
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||
|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
### [Download](https://github.com/zarzet/SpotiFLAC-Android/releases)
|
||||
### [Download](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
|
||||
## Features
|
||||
## Screenshots
|
||||
|
||||
- 🔍 Search Spotify tracks, albums, and playlists
|
||||
- 📥 Download in FLAC quality from multiple sources
|
||||
- 🔄 Automatic fallback to available services
|
||||
- 🎵 Embedded metadata and cover art
|
||||
- 📝 Lyrics support (synced and plain)
|
||||
- 🎨 Material 3 Expressive UI with dynamic colors
|
||||
<p align="center">
|
||||
<img src="assets/images/1.jpg?v=2" width="200" />
|
||||
<img src="assets/images/2.jpg?v=2" width="200" />
|
||||
<img src="assets/images/3.jpg?v=2" width="200" />
|
||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||
</p>
|
||||
|
||||
## Screenshot
|
||||
## Extensions
|
||||
|
||||
<!-- Add your screenshot here -->
|
||||
<!--  -->
|
||||
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.
|
||||
|
||||
## Related Project
|
||||
### Installing Extensions
|
||||
1. Go to **Store** tab in the app
|
||||
2. Browse and install extensions with one tap
|
||||
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||
4. Configure extension settings if needed
|
||||
5. Set provider priority in **Settings > Extensions > Provider Priority**
|
||||
|
||||
### Developing Extensions
|
||||
Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
|
||||
|
||||
## Other project
|
||||
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
The original desktop version for Windows, macOS, and Linux.
|
||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||
|
||||
## Telegram
|
||||
|
||||
[](https://t.me/spotiflac)
|
||||
[](https://t.me/spotiflac_chat)
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Why is my download failing with "Song not found"?**
|
||||
A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.
|
||||
|
||||
**Q: Why are some tracks downloading in lower quality?**
|
||||
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
|
||||
|
||||
**Q: Can I download playlists?**
|
||||
A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||
|
||||
**Q: Why do I need to grant storage permission?**
|
||||
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
|
||||
|
||||
**Q: Is this app safe?**
|
||||
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
|
||||
|
||||
**Q: Why is download not working in my country?**
|
||||
A: Some countries have restricted access to certain streaming service APIs. If downloads are failing, try using a VPN to connect through a different region.
|
||||
|
||||
|
||||
### Want to support SpotiFLAC-Mobile?
|
||||
|
||||
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
|
||||
|
||||
[](https://ko-fi.com/zarzet)
|
||||
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||
|
||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service.
|
||||
|
||||
The application is purely a user interface that facilitates communication between your device and existing third-party services.
|
||||
|
||||
You are solely responsible for:
|
||||
1. Ensuring your use of this software complies with your local laws.
|
||||
@@ -44,3 +90,18 @@ You are solely responsible for:
|
||||
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.
|
||||
|
||||
|
||||
## API Credits
|
||||
|
||||
- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api), [music.binimum.org](https://music.binimum.org), [qqdl.site](https://qqdl.site), [squid.wtf](https://squid.wtf), [spotisaver.net](https://spotisaver.net)
|
||||
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev)
|
||||
- **Amazon**: [AfkarXYZ](https://github.com/afkarxyz)
|
||||
- **Lyrics**: [LRCLib](https://lrclib.net)
|
||||
- **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com)
|
||||
- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify)
|
||||
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -5,6 +8,13 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// Load keystore properties for local builds
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.zarz.spotiflac"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -22,20 +32,55 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||
storePassword = keystoreProperties.getProperty("storePassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.zarz.spotiflac"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = 34
|
||||
targetSdk = 36
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
multiDexEnabled = true
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
// For local builds: use release signing if key.properties exists
|
||||
// For CI builds: APK is signed by GitHub Action after build
|
||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Split APKs by ABI for smaller individual downloads
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
reset()
|
||||
include("arm64-v8a", "armeabi-v7a")
|
||||
isUniversalApk = true // Also generate universal APK
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,8 +96,13 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
implementation(files("libs/gobackend.aar"))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
|
||||
// Include all AAR and JAR files from libs folder
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation("androidx.activity:activity-ktx:1.12.3")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Flutter specific rules
|
||||
-keep class io.flutter.app.** { *; }
|
||||
-keep class io.flutter.plugin.** { *; }
|
||||
-keep class io.flutter.util.** { *; }
|
||||
-keep class io.flutter.view.** { *; }
|
||||
-keep class io.flutter.** { *; }
|
||||
-keep class io.flutter.plugins.** { *; }
|
||||
-keep class io.flutter.embedding.** { *; }
|
||||
|
||||
# Ignore missing Play Core classes (not used, but referenced by Flutter)
|
||||
-dontwarn com.google.android.play.core.splitcompat.**
|
||||
-dontwarn com.google.android.play.core.splitinstall.**
|
||||
-dontwarn com.google.android.play.core.tasks.**
|
||||
|
||||
# Ignore missing javax.xml.stream (not used on Android)
|
||||
-dontwarn javax.xml.stream.**
|
||||
|
||||
# Go backend (gobackend.aar) - CRITICAL for release builds
|
||||
-keep class gobackend.** { *; }
|
||||
-keep class go.** { *; }
|
||||
-keep interface gobackend.** { *; }
|
||||
-keepclassmembers class gobackend.** { *; }
|
||||
|
||||
# Go mobile binding internals
|
||||
-keep class org.golang.** { *; }
|
||||
-dontwarn org.golang.**
|
||||
|
||||
# FFmpeg Kit
|
||||
-keep class com.arthenica.ffmpegkit.** { *; }
|
||||
-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)
|
||||
-dontwarn org.apache.tika.**
|
||||
|
||||
# Keep native methods
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# Kotlin coroutines - expanded rules
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
-keepclassmembers class kotlinx.coroutines.** {
|
||||
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
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-keepattributes Signature
|
||||
-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 { *; }
|
||||
@@ -4,26 +4,30 @@
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
android:maxSdkVersion="29" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<!-- For Android 11+ (API 30-32) - full storage access -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<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_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:label="SpotiFLAC"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:usesCleartextTraffic="true">
|
||||
android:usesCleartextTraffic="false"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:localeConfig="@xml/locale_config">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
@@ -39,7 +43,7 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
|
||||
<!-- Handle Spotify URL sharing -->
|
||||
<!-- Handle music URL sharing (Spotify, Deezer, Tidal, YT Music) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -53,6 +57,33 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="open.spotify.com" />
|
||||
</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>
|
||||
</activity>
|
||||
|
||||
<!-- Download Service -->
|
||||
@@ -76,6 +107,17 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<!-- FileProvider for APK installation -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileProvider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<queries>
|
||||
|
||||
@@ -5,83 +5,227 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
/**
|
||||
* Foreground service to keep downloads running when app is in background.
|
||||
* This prevents Android from killing the download process or throttling network.
|
||||
*
|
||||
* Note: Android 15+ (API 35+) has a 6-hour timeout for dataSync foreground services.
|
||||
* The service will be stopped automatically after 6 hours of cumulative runtime in 24 hours.
|
||||
*/
|
||||
class DownloadService : Service() {
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "spotiflac_download_channel"
|
||||
const val NOTIFICATION_ID = 1
|
||||
const val ACTION_START = "com.zarz.spotiflac.START_DOWNLOAD"
|
||||
const val ACTION_STOP = "com.zarz.spotiflac.STOP_DOWNLOAD"
|
||||
private const val CHANNEL_ID = "download_channel"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val WAKELOCK_TAG = "SpotiFLAC:DownloadWakeLock"
|
||||
|
||||
const val ACTION_START = "com.zarz.spotiflac.action.START_DOWNLOAD"
|
||||
const val ACTION_STOP = "com.zarz.spotiflac.action.STOP_DOWNLOAD"
|
||||
const val ACTION_UPDATE_PROGRESS = "com.zarz.spotiflac.action.UPDATE_PROGRESS"
|
||||
|
||||
const val EXTRA_TRACK_NAME = "track_name"
|
||||
const val EXTRA_ARTIST_NAME = "artist_name"
|
||||
const val EXTRA_PROGRESS = "progress"
|
||||
const val EXTRA_TOTAL = "total"
|
||||
const val EXTRA_QUEUE_COUNT = "queue_count"
|
||||
|
||||
private var isRunning = false
|
||||
|
||||
fun isServiceRunning(): Boolean = isRunning
|
||||
|
||||
fun start(context: Context, trackName: String = "", artistName: String = "", queueCount: Int = 0) {
|
||||
val intent = Intent(context, DownloadService::class.java).apply {
|
||||
action = ACTION_START
|
||||
putExtra(EXTRA_TRACK_NAME, trackName)
|
||||
putExtra(EXTRA_ARTIST_NAME, artistName)
|
||||
putExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, DownloadService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
fun updateProgress(context: Context, trackName: String, artistName: String, progress: Long, total: Long, queueCount: Int) {
|
||||
val intent = Intent(context, DownloadService::class.java).apply {
|
||||
action = ACTION_UPDATE_PROGRESS
|
||||
putExtra(EXTRA_TRACK_NAME, trackName)
|
||||
putExtra(EXTRA_ARTIST_NAME, artistName)
|
||||
putExtra(EXTRA_PROGRESS, progress)
|
||||
putExtra(EXTRA_TOTAL, total)
|
||||
putExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var currentTrackName = ""
|
||||
private var currentArtistName = ""
|
||||
private var queueCount = 0
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> startForegroundService()
|
||||
ACTION_STOP -> stopSelf()
|
||||
ACTION_START -> {
|
||||
currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: ""
|
||||
currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: ""
|
||||
queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, 0)
|
||||
startForegroundService()
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
stopForegroundService()
|
||||
}
|
||||
ACTION_UPDATE_PROGRESS -> {
|
||||
currentTrackName = intent.getStringExtra(EXTRA_TRACK_NAME) ?: currentTrackName
|
||||
currentArtistName = intent.getStringExtra(EXTRA_ARTIST_NAME) ?: currentArtistName
|
||||
val progress = intent.getLongExtra(EXTRA_PROGRESS, 0)
|
||||
val total = intent.getLongExtra(EXTRA_TOTAL, 0)
|
||||
queueCount = intent.getIntExtra(EXTRA_QUEUE_COUNT, queueCount)
|
||||
updateNotification(progress, total)
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
|
||||
/**
|
||||
* Called when the foreground service timeout is reached (Android 15+, API 35+).
|
||||
* dataSync services have a 6-hour limit in a 24-hour period.
|
||||
* We must call stopSelf() within a few seconds to avoid a crash.
|
||||
*/
|
||||
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||
// Log the timeout for debugging
|
||||
android.util.Log.w("DownloadService", "Foreground service timeout reached (6 hours limit). Stopping service.")
|
||||
|
||||
// Gracefully stop the service
|
||||
stopForegroundService()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Download Progress",
|
||||
"Download Service",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Shows download progress for SpotiFLAC"
|
||||
description = "Shows download progress"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun startForegroundService() {
|
||||
val notification = createNotification("Downloading...", 0)
|
||||
isRunning = true
|
||||
|
||||
// Acquire wake lock to prevent CPU sleep
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = powerManager.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
WAKELOCK_TAG
|
||||
).apply {
|
||||
acquire(60 * 60 * 1000L) // 1 hour max
|
||||
}
|
||||
|
||||
val notification = buildNotification(0, 0)
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
fun updateProgress(trackName: String, progress: Int) {
|
||||
val notification = createNotification(trackName, progress)
|
||||
|
||||
private fun stopForegroundService() {
|
||||
isRunning = false
|
||||
wakeLock?.let {
|
||||
if (it.isHeld) {
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
wakeLock = null
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun updateNotification(progress: Long, total: Long) {
|
||||
if (!isRunning) return
|
||||
|
||||
val notification = buildNotification(progress, total)
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun createNotification(title: String, progress: Int): Notification {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
|
||||
private fun buildNotification(progress: Long, total: Long): Notification {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, intent,
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val stopIntent = Intent(this, DownloadService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
|
||||
val title = if (queueCount > 1) {
|
||||
"Downloading $queueCount tracks"
|
||||
} else if (currentTrackName.isNotEmpty()) {
|
||||
currentTrackName
|
||||
} else {
|
||||
"Downloading..."
|
||||
}
|
||||
val stopPendingIntent = PendingIntent.getService(
|
||||
this, 0, stopIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("SpotiFLAC")
|
||||
.setContentText(title)
|
||||
|
||||
val text = if (currentArtistName.isNotEmpty() && queueCount <= 1) {
|
||||
currentArtistName
|
||||
} else if (total > 0) {
|
||||
val progressPercent = (progress * 100 / total).toInt()
|
||||
val progressMB = progress / (1024.0 * 1024.0)
|
||||
val totalMB = total / (1024.0 * 1024.0)
|
||||
String.format("%.1f / %.1f MB (%d%%)", progressMB, totalMB, progressPercent)
|
||||
} else {
|
||||
"Preparing download..."
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setProgress(100, progress, progress == 0)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", stopPendingIntent)
|
||||
.build()
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
|
||||
if (total > 0) {
|
||||
builder.setProgress(100, (progress * 100 / total).toInt(), false)
|
||||
} else {
|
||||
builder.setProgress(0, 0, true)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
isRunning = false
|
||||
wakeLock?.let {
|
||||
if (it.isHeld) {
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 932 B |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 651 B |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-files-path name="external_files" path="." />
|
||||
<cache-path name="cache" path="." />
|
||||
<files-path name="files" path="." />
|
||||
</paths>
|
||||
@@ -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>
|
||||
@@ -10,10 +10,19 @@ subprojects {
|
||||
if (project.hasProperty("android")) {
|
||||
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
// Enable multidex for all subprojects
|
||||
defaultConfig {
|
||||
multiDexEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
// Add desugaring dependency to all Android subprojects
|
||||
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
}
|
||||
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||
|
||||
@@ -19,8 +19,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 70 KiB |
@@ -0,0 +1,19 @@
|
||||
files:
|
||||
- source: /lib/l10n/arb/app_en.arb
|
||||
translation: /lib/l10n/arb/app_%locale%.arb
|
||||
languages_mapping:
|
||||
locale:
|
||||
# Short codes for single-variant languages
|
||||
de: de
|
||||
es: es
|
||||
fr: fr
|
||||
hi: hi
|
||||
id: id
|
||||
ja: ja
|
||||
ko: ko
|
||||
nl: nl
|
||||
pt: pt
|
||||
ru: ru
|
||||
# Full codes for Chinese variants
|
||||
zh-CN: zh_CN
|
||||
zh-TW: zh_TW
|
||||
@@ -0,0 +1,3 @@
|
||||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
@@ -1,214 +1,297 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AmazonDownloader handles Amazon Music downloads using DoubleDouble service (same as PC)
|
||||
// Amazon API timeout and retry configuration for mobile networks
|
||||
const (
|
||||
amazonAPITimeoutMobile = 30 * time.Second // Longer timeout for unstable mobile networks
|
||||
amazonMaxRetries = 2 // Number of retry attempts
|
||||
amazonRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
type AmazonDownloader struct {
|
||||
client *http.Client
|
||||
regions []string // us, eu regions for DoubleDouble service
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
||||
type DoubleDoubleSubmitResponse struct {
|
||||
Success bool `json:"success"`
|
||||
ID string `json:"id"`
|
||||
var (
|
||||
globalAmazonDownloader *AmazonDownloader
|
||||
amazonDownloaderOnce sync.Once
|
||||
amazonASINRegex = regexp.MustCompile(`(?i)^B[0-9A-Z]{9}$`)
|
||||
amazonASINFindRegex = regexp.MustCompile(`(?i)B[0-9A-Z]{9}`)
|
||||
)
|
||||
|
||||
// AfkarXYZResponse is the response from AfkarXYZ API
|
||||
type AfkarXYZResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
DirectLink string `json:"direct_link"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// DoubleDoubleStatusResponse is the response from DoubleDouble status endpoint
|
||||
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"`
|
||||
// AmazonStreamResponse is the new response format from amazon.afkarxyz.fun/api/track/{asin}
|
||||
type AmazonStreamResponse struct {
|
||||
StreamURL string `json:"streamUrl"`
|
||||
DecryptionKey string `json:"decryptionKey"`
|
||||
}
|
||||
|
||||
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
|
||||
func NewAmazonDownloader() *AmazonDownloader {
|
||||
return &AmazonDownloader{
|
||||
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
|
||||
regions: []string{"us", "eu"}, // Same regions as PC
|
||||
}
|
||||
amazonDownloaderOnce.Do(func() {
|
||||
globalAmazonDownloader = &AmazonDownloader{
|
||||
client: NewHTTPClientWithTimeout(120 * time.Second),
|
||||
}
|
||||
})
|
||||
return globalAmazonDownloader
|
||||
}
|
||||
|
||||
// GetAvailableAPIs returns list of available DoubleDouble regions
|
||||
// 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))
|
||||
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks.
|
||||
// Returns downloadURL, suggested fileName, optional decryptionKey.
|
||||
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := amazonRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
|
||||
GoLog("[Amazon] Retry %d/%d after %v...\n", attempt, amazonMaxRetries, delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL)
|
||||
if err == nil {
|
||||
return downloadURL, fileName, decryptionKey, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
// Check if error is retryable
|
||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "eof") ||
|
||||
strings.Contains(errStr, "status 5") ||
|
||||
strings.Contains(errStr, "status 429") ||
|
||||
strings.Contains(errStr, "http 429")
|
||||
|
||||
if !isRetryable {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
|
||||
}
|
||||
return apis
|
||||
|
||||
return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
func normalizeAmazonASIN(candidate string) string {
|
||||
trimmed := strings.TrimSpace(candidate)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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, outputDir string) (string, string, string, error) {
|
||||
var lastError error
|
||||
if decoded, err := url.QueryUnescape(trimmed); err == nil {
|
||||
trimmed = decoded
|
||||
}
|
||||
|
||||
for _, region := range a.regions {
|
||||
fmt.Printf("[Amazon] Trying region: %s...\n", region)
|
||||
trimmed = strings.ToUpper(trimmed)
|
||||
if idx := strings.IndexAny(trimmed, "?#&/"); idx >= 0 {
|
||||
trimmed = trimmed[:idx]
|
||||
}
|
||||
|
||||
// Build base URL for DoubleDouble service
|
||||
// Decode base64 service URL (same as PC)
|
||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||
if amazonASINRegex.MatchString(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// Step 1: Submit download request
|
||||
encodedURL := url.QueryEscape(amazonURL)
|
||||
submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL)
|
||||
return ""
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", submitURL, nil)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to create request: %w", err)
|
||||
continue
|
||||
}
|
||||
func extractAmazonASIN(amazonURL string) string {
|
||||
raw := strings.TrimSpace(amazonURL)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
parsed, err := url.Parse(raw)
|
||||
if err == nil {
|
||||
query := parsed.Query()
|
||||
|
||||
fmt.Println("[Amazon] Submitting download request...")
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
lastError = fmt.Errorf("failed to submit request: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode)
|
||||
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
|
||||
fmt.Printf("[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
|
||||
// Prefer track-level ASIN when URL also contains albumAsin.
|
||||
for _, key := range []string{"trackAsin", "trackasin", "trackASIN", "asin", "ASIN", "i"} {
|
||||
if asin := normalizeAmazonASIN(query.Get(key)); asin != "" {
|
||||
return asin
|
||||
}
|
||||
}
|
||||
|
||||
statusReq.Header.Set("User-Agent", getRandomUserAgent())
|
||||
path := strings.Trim(parsed.Path, "/")
|
||||
if path != "" {
|
||||
segments := strings.Split(path, "/")
|
||||
|
||||
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!")
|
||||
|
||||
// Build download URL
|
||||
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)
|
||||
for i := 0; i < len(segments)-1; i++ {
|
||||
segment := strings.ToLower(strings.TrimSpace(segments[i]))
|
||||
if segment == "track" || segment == "tracks" {
|
||||
if asin := normalizeAmazonASIN(segments[i+1]); asin != "" {
|
||||
return asin
|
||||
}
|
||||
}
|
||||
|
||||
trackName := status.Current.Name
|
||||
artist := status.Current.Artist
|
||||
|
||||
fmt.Printf("[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)
|
||||
if asin := normalizeAmazonASIN(segments[len(segments)-1]); asin != "" {
|
||||
return asin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError)
|
||||
match := amazonASINFindRegex.FindString(strings.ToUpper(raw))
|
||||
return normalizeAmazonASIN(match)
|
||||
}
|
||||
|
||||
// doAfkarXYZRequest performs a single request to Amazon API.
|
||||
// It tries new endpoint first, then falls back to legacy /convert endpoint.
|
||||
func (a *AmazonDownloader) doAfkarXYZRequest(amazonURL string) (string, string, string, error) {
|
||||
asin := extractAmazonASIN(amazonURL)
|
||||
if asin != "" {
|
||||
GoLog("[Amazon] Using ASIN: %s\n", asin)
|
||||
downloadURL, fileName, decryptKey, err := a.doAfkarXYZRequestNew(asin)
|
||||
if err == nil {
|
||||
return downloadURL, fileName, decryptKey, nil
|
||||
}
|
||||
GoLog("[Amazon] New API failed for ASIN %s, trying legacy endpoint: %v\n", asin, err)
|
||||
}
|
||||
return a.doAfkarXYZRequestLegacy(amazonURL)
|
||||
}
|
||||
|
||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
|
||||
// Set current file being downloaded
|
||||
SetCurrentFile(filepath.Base(outputPath))
|
||||
SetDownloading(true)
|
||||
defer SetDownloading(false)
|
||||
func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||
apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to call Amazon API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var apiResp AmazonStreamResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(apiResp.StreamURL) == "" {
|
||||
return "", "", "", fmt.Errorf("Amazon API returned empty stream URL")
|
||||
}
|
||||
|
||||
fileName := asin + ".m4a"
|
||||
return apiResp.StreamURL, fileName, strings.TrimSpace(apiResp.DecryptionKey), nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, string, string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
|
||||
defer cancel()
|
||||
|
||||
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to create legacy request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to read legacy response: %w", err)
|
||||
}
|
||||
|
||||
var apiResp AfkarXYZResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err)
|
||||
}
|
||||
|
||||
if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" {
|
||||
return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found")
|
||||
}
|
||||
|
||||
fileName := apiResp.Data.FileName
|
||||
if fileName == "" {
|
||||
fileName = "track.flac"
|
||||
}
|
||||
|
||||
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
|
||||
fileName = reg.ReplaceAllString(fileName, "")
|
||||
|
||||
return apiResp.Data.DirectLink, fileName, "", nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) {
|
||||
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
|
||||
|
||||
downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
if decryptionKey != "" {
|
||||
GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n")
|
||||
}
|
||||
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
|
||||
return downloadURL, fileName, decryptionKey, nil
|
||||
}
|
||||
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -217,6 +300,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -225,64 +311,131 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string) error {
|
||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Set total bytes if available
|
||||
if resp.ContentLength > 0 {
|
||||
SetBytesTotal(resp.ContentLength)
|
||||
expectedSize := resp.ContentLength
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Track download progress
|
||||
pw := NewProgressWriter(out)
|
||||
_, err = io.Copy(pw, resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", 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)
|
||||
}
|
||||
|
||||
fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
GoLog("[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadFromAmazon downloads a track using the request parameters
|
||||
// Uses DoubleDouble service (same as PC version)
|
||||
func downloadFromAmazon(req DownloadRequest) (string, error) {
|
||||
// AmazonDownloadResult contains download result with quality info
|
||||
type AmazonDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
DecryptionKey string
|
||||
}
|
||||
|
||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
downloader := NewAmazonDownloader()
|
||||
|
||||
// Check for existing file first
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return "EXISTS:" + existingFile, nil
|
||||
}
|
||||
|
||||
// Get Amazon URL from SongLink
|
||||
songlink := NewSongLinkClient()
|
||||
availability, err := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
|
||||
}
|
||||
|
||||
if !availability.Amazon || availability.AmazonURL == "" {
|
||||
return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||
}
|
||||
|
||||
// Create output directory if needed
|
||||
if req.OutputDir != "." {
|
||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create output directory: %w", err)
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Download using DoubleDouble service (same as PC)
|
||||
downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get download URL: %w", err)
|
||||
amazonURL := ""
|
||||
if req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" {
|
||||
amazonURL = cached.AmazonURL
|
||||
GoLog("[Amazon] Cache hit! Using cached Amazon URL for ISRC %s\n", req.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
// Build filename using Spotify metadata (more accurate)
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
songlink := NewSongLinkClient()
|
||||
var availability *TrackAvailability
|
||||
var err error
|
||||
|
||||
if amazonURL == "" {
|
||||
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
|
||||
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)")
|
||||
}
|
||||
|
||||
amazonURL = availability.AmazonURL
|
||||
if req.ISRC != "" {
|
||||
GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL)
|
||||
}
|
||||
}
|
||||
|
||||
if !isSafOutput && req.OutputDir != "." {
|
||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Download using AfkarXYZ API
|
||||
downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL)
|
||||
if err != nil {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[Amazon] Match found: '%s' by '%s'\n", req.TrackName, req.ArtistName)
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
@@ -290,74 +443,219 @@ func downloadFromAmazon(req DownloadRequest) (string, error) {
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath := filepath.Join(req.OutputDir, filename)
|
||||
|
||||
// Check if file already exists
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return "EXISTS:" + outputPath, nil
|
||||
}
|
||||
|
||||
// Download file
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath); err != nil {
|
||||
return "", fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// Log track info from DoubleDouble (for debugging)
|
||||
if trackName != "" && artistName != "" {
|
||||
fmt.Printf("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
||||
}
|
||||
|
||||
// Embed metadata using Spotify data (more accurate than DoubleDouble)
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
}
|
||||
|
||||
// Download cover to memory (avoids file permission issues on Android)
|
||||
var coverData []byte
|
||||
if req.CoverURL != "" {
|
||||
fmt.Println("[Amazon] Downloading cover to memory...")
|
||||
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
|
||||
if err == nil {
|
||||
coverData = data
|
||||
fmt.Printf("[Amazon] Cover downloaded successfully (%d bytes)\n", len(coverData))
|
||||
} else {
|
||||
fmt.Printf("[Amazon] Warning: failed to download cover: %v\n", err)
|
||||
var outputPath string
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
outputExt := strings.ToLower(filepath.Ext(afkarFileName))
|
||||
if outputExt == "" {
|
||||
outputExt = ".flac"
|
||||
}
|
||||
filename = sanitizeFilename(filename) + outputExt
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
|
||||
// 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.OutputFD, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return AmazonDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// Embed lyrics if enabled
|
||||
if req.EmbedLyrics {
|
||||
fmt.Println("[Amazon] Fetching lyrics...")
|
||||
lyricsClient := NewLyricsClient()
|
||||
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
|
||||
if lyricsErr != nil {
|
||||
fmt.Printf("[Amazon] Warning: lyrics fetch error: %v\n", lyricsErr)
|
||||
} else if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||
fmt.Println("[Amazon] No lyrics found for this track")
|
||||
actualOutputPath := outputPath
|
||||
needsDecryption := strings.TrimSpace(decryptionKey) != ""
|
||||
if needsDecryption {
|
||||
GoLog("[Amazon] Download requires decryption; deferring decrypt to Flutter FFmpeg path\n")
|
||||
}
|
||||
|
||||
// Wait for parallel operations to complete
|
||||
<-parallelDone
|
||||
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
actualTrackNum := req.TrackNumber
|
||||
actualDiscNum := req.DiscNumber
|
||||
actualDate := req.ReleaseDate
|
||||
actualAlbum := req.AlbumName
|
||||
actualTitle := req.TrackName
|
||||
actualArtist := req.ArtistName
|
||||
|
||||
if !needsDecryption {
|
||||
existingMeta, metaErr := ReadMetadata(actualOutputPath)
|
||||
if metaErr == nil && existingMeta != nil {
|
||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||
actualTrackNum = existingMeta.TrackNumber
|
||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||
}
|
||||
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
|
||||
actualDiscNum = existingMeta.DiscNumber
|
||||
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
|
||||
}
|
||||
if existingMeta.Date != "" && req.ReleaseDate == "" {
|
||||
actualDate = existingMeta.Date
|
||||
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
|
||||
}
|
||||
if existingMeta.Album != "" && req.AlbumName == "" {
|
||||
actualAlbum = existingMeta.Album
|
||||
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
|
||||
}
|
||||
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
|
||||
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
|
||||
}
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: actualTitle,
|
||||
Artist: actualArtist,
|
||||
Album: actualAlbum,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
Date: actualDate,
|
||||
TrackNumber: actualTrackNum,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: actualDiscNum,
|
||||
ISRC: req.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 {
|
||||
coverData = parallelResult.CoverData
|
||||
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
|
||||
} else {
|
||||
existingCover, coverErr := ExtractCoverArt(actualOutputPath)
|
||||
if coverErr == nil && len(existingCover) > 0 {
|
||||
coverData = existingCover
|
||||
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
|
||||
} else {
|
||||
fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
|
||||
lrcContent := convertToLRC(lyrics)
|
||||
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
|
||||
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
fmt.Println("[Amazon] Lyrics embedded successfully")
|
||||
GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\n")
|
||||
}
|
||||
}
|
||||
|
||||
if isSafOutput || needsDecryption {
|
||||
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||
} else {
|
||||
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
|
||||
if isFlacOutput {
|
||||
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath))
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
GoLog("[Amazon] Saving external LRC file...\n")
|
||||
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput {
|
||||
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
|
||||
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
} else {
|
||||
GoLog("[Amazon] Lyrics embedded successfully\n")
|
||||
}
|
||||
} else if (lyricsMode == "embed" || lyricsMode == "both") && !isFlacOutput {
|
||||
GoLog("[Amazon] Skipping embedded lyrics for non-FLAC output\n")
|
||||
}
|
||||
} else if req.EmbedLyrics {
|
||||
GoLog("[Amazon] No lyrics available from parallel fetch\n")
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
|
||||
|
||||
quality := AudioQuality{}
|
||||
if isSafOutput || needsDecryption {
|
||||
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
|
||||
} else {
|
||||
quality, err = GetAudioQuality(actualOutputPath)
|
||||
if err != nil {
|
||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||
} else {
|
||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
|
||||
finalMeta, metaReadErr := ReadMetadata(actualOutputPath)
|
||||
if metaReadErr == nil && finalMeta != nil {
|
||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
|
||||
actualTrackNum = finalMeta.TrackNumber
|
||||
actualDiscNum = finalMeta.DiscNumber
|
||||
if finalMeta.Date != "" {
|
||||
req.ReleaseDate = finalMeta.Date
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
||||
return outputPath, nil
|
||||
// Add to ISRC index for fast duplicate checking.
|
||||
// When decryption is pending in Flutter, postpone indexing until final file is settled.
|
||||
if !isSafOutput && !needsDecryption {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
|
||||
}
|
||||
|
||||
bitDepth := 0
|
||||
sampleRate := 0
|
||||
if err == nil {
|
||||
bitDepth = quality.BitDepth
|
||||
sampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
return AmazonDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: actualTrackNum,
|
||||
DiscNumber: actualDiscNum,
|
||||
ISRC: req.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
DecryptionKey: decryptionKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractAmazonASIN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "prefers trackAsin over albumAsin",
|
||||
url: "https://music.amazon.com/albums/B0ALBUM123?trackAsin=B0TRACK456&musicTerritory=US",
|
||||
want: "B0TRACK456",
|
||||
},
|
||||
{
|
||||
name: "extract from tracks path",
|
||||
url: "https://music.amazon.com/tracks/B0CYQHGWZJ?musicTerritory=US",
|
||||
want: "B0CYQHGWZJ",
|
||||
},
|
||||
{
|
||||
name: "extract from plain query asin",
|
||||
url: "https://example.com/?asin=B0CYQHGWZJ",
|
||||
want: "B0CYQHGWZJ",
|
||||
},
|
||||
{
|
||||
name: "fallback regex",
|
||||
url: "https://example.com/path/B0CYQHGWZJ",
|
||||
want: "B0CYQHGWZJ",
|
||||
},
|
||||
{
|
||||
name: "invalid url",
|
||||
url: "https://music.amazon.com/tracks/not-valid",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractAmazonASIN(tt.url)
|
||||
if got != tt.want {
|
||||
t.Fatalf("extractAmazonASIN() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ErrDownloadCancelled is returned when a download is cancelled by the user.
|
||||
var ErrDownloadCancelled = errors.New("download cancelled")
|
||||
|
||||
type cancelEntry struct {
|
||||
cancel context.CancelFunc
|
||||
canceled bool
|
||||
}
|
||||
|
||||
var (
|
||||
cancelMu sync.Mutex
|
||||
cancelMap = make(map[string]*cancelEntry)
|
||||
)
|
||||
|
||||
func initDownloadCancel(itemID string) context.Context {
|
||||
if itemID == "" {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
defer cancelMu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancelMap[itemID] = &cancelEntry{
|
||||
cancel: cancel,
|
||||
canceled: false,
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func cancelDownload(itemID string) {
|
||||
if itemID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
entry, ok := cancelMap[itemID]
|
||||
if ok {
|
||||
entry.canceled = true
|
||||
if entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
} else {
|
||||
cancelMap[itemID] = &cancelEntry{canceled: true}
|
||||
}
|
||||
cancelMu.Unlock()
|
||||
|
||||
RemoveItemProgress(itemID)
|
||||
}
|
||||
|
||||
func isDownloadCancelled(itemID string) bool {
|
||||
if itemID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
entry, ok := cancelMap[itemID]
|
||||
canceled := ok && entry.canceled
|
||||
cancelMu.Unlock()
|
||||
return canceled
|
||||
}
|
||||
|
||||
func clearDownloadCancel(itemID string) {
|
||||
if itemID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
delete(cancelMap, itemID)
|
||||
cancelMu.Unlock()
|
||||
}
|
||||
@@ -4,36 +4,53 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Spotify image size codes (same as PC version)
|
||||
const (
|
||||
spotifySize640 = "ab67616d0000b273" // 640x640
|
||||
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
|
||||
spotifySize300 = "ab67616d00001e02"
|
||||
spotifySize640 = "ab67616d0000b273"
|
||||
spotifySizeMax = "ab67616d000082c1"
|
||||
)
|
||||
|
||||
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
|
||||
// This avoids file permission issues on Android
|
||||
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize300) {
|
||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||
}
|
||||
return imageURL
|
||||
}
|
||||
|
||||
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
if coverURL == "" {
|
||||
return nil, fmt.Errorf("no cover URL provided")
|
||||
}
|
||||
|
||||
fmt.Printf("[Cover] Downloading cover from: %s\n", coverURL)
|
||||
GoLog("[Cover] Original URL: %s", coverURL)
|
||||
|
||||
downloadURL := convertSmallToMedium(coverURL)
|
||||
if downloadURL != coverURL {
|
||||
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
||||
}
|
||||
|
||||
// Upgrade to max quality if requested
|
||||
downloadURL := coverURL
|
||||
if maxQuality {
|
||||
downloadURL = upgradeToMaxQuality(coverURL)
|
||||
if downloadURL != coverURL {
|
||||
fmt.Printf("[Cover] Upgraded to max quality URL: %s\n", downloadURL)
|
||||
maxURL := upgradeToMaxQuality(downloadURL)
|
||||
if maxURL != downloadURL {
|
||||
downloadURL = maxURL
|
||||
// Log already printed by upgradeToMaxQuality for Deezer
|
||||
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Cover] Final URL: %s", downloadURL)
|
||||
|
||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||
|
||||
// Create request with User-Agent (required by Spotify CDN)
|
||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
@@ -54,48 +71,58 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
return nil, fmt.Errorf("failed to read cover data: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[Cover] Downloaded %d bytes\n", len(data))
|
||||
sizeKB := len(data) / 1024
|
||||
var resolution string
|
||||
if sizeKB > 200 {
|
||||
resolution = "~2000x2000 (hi-res)"
|
||||
} else if sizeKB > 50 {
|
||||
resolution = "~640x640"
|
||||
} else {
|
||||
resolution = "~300x300"
|
||||
}
|
||||
GoLog("[Cover] Downloaded %d KB (%s)", sizeKB, resolution)
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
|
||||
// Uses same logic as PC version - replaces 640x640 size code with max resolution
|
||||
func upgradeToMaxQuality(coverURL string) string {
|
||||
// Spotify image URLs can be upgraded by changing the size parameter
|
||||
// Format: https://i.scdn.co/image/ab67616d0000b273...
|
||||
// ab67616d0000b273 = 640x640
|
||||
// ab67616d000082c1 = Max resolution (~2000x2000)
|
||||
|
||||
// Spotify CDN upgrade
|
||||
if strings.Contains(coverURL, spotifySize640) {
|
||||
// Try max resolution first
|
||||
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
}
|
||||
|
||||
// Verify max resolution URL is available
|
||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||
req, err := http.NewRequest("HEAD", maxURL, nil)
|
||||
if err == nil {
|
||||
resp, err := DoRequestWithUserAgent(client, req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return maxURL
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deezer CDN upgrade
|
||||
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||
return upgradeDeezerCover(coverURL)
|
||||
}
|
||||
|
||||
return coverURL
|
||||
}
|
||||
|
||||
// GetCoverFromSpotify gets cover URL from Spotify metadata
|
||||
func upgradeDeezerCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
// Replace any size pattern with 1800x1800
|
||||
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
||||
}
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||
if imageURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Always upgrade small to medium first
|
||||
result := convertSmallToMedium(imageURL)
|
||||
|
||||
if maxQuality {
|
||||
return upgradeToMaxQuality(imageURL)
|
||||
result = upgradeToMaxQuality(result)
|
||||
}
|
||||
|
||||
return imageURL
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,59 +1,172 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// checkISRCExistsInternal checks if a file with the given ISRC exists (internal use)
|
||||
type ISRCIndex struct {
|
||||
index map[string]string // ISRC (uppercase) -> file path
|
||||
outputDir string
|
||||
buildTime time.Time
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
isrcIndexCache = make(map[string]*ISRCIndex)
|
||||
isrcIndexCacheMu sync.RWMutex
|
||||
isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
|
||||
isrcIndexTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
// Fast path: check cache first
|
||||
isrcIndexCacheMu.RLock()
|
||||
idx, exists := isrcIndexCache[outputDir]
|
||||
isrcIndexCacheMu.RUnlock()
|
||||
|
||||
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||
return idx
|
||||
}
|
||||
|
||||
// Slow path: need to build index
|
||||
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
|
||||
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
||||
mu := buildLock.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// Double-check cache after acquiring lock (another goroutine may have built it)
|
||||
isrcIndexCacheMu.RLock()
|
||||
idx, exists = isrcIndexCache[outputDir]
|
||||
isrcIndexCacheMu.RUnlock()
|
||||
|
||||
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||
return idx
|
||||
}
|
||||
|
||||
return buildISRCIndex(outputDir)
|
||||
}
|
||||
|
||||
func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||
idx := &ISRCIndex{
|
||||
index: make(map[string]string),
|
||||
outputDir: outputDir,
|
||||
buildTime: time.Now(),
|
||||
}
|
||||
|
||||
if outputDir == "" {
|
||||
return idx
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
fileCount := 0
|
||||
|
||||
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext != ".flac" {
|
||||
return nil
|
||||
}
|
||||
|
||||
metadata, err := ReadMetadata(path)
|
||||
if err != nil || metadata.ISRC == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
idx.index[strings.ToUpper(metadata.ISRC)] = path
|
||||
fileCount++
|
||||
return nil
|
||||
})
|
||||
|
||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
||||
|
||||
isrcIndexCacheMu.Lock()
|
||||
isrcIndexCache[outputDir] = idx
|
||||
isrcIndexCacheMu.Unlock()
|
||||
|
||||
return idx
|
||||
}
|
||||
|
||||
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
||||
if isrc == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
|
||||
path, exists := idx.index[strings.ToUpper(isrc)]
|
||||
return path, exists
|
||||
}
|
||||
|
||||
func (idx *ISRCIndex) remove(isrc string) {
|
||||
if isrc == "" {
|
||||
return
|
||||
}
|
||||
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
|
||||
delete(idx.index, strings.ToUpper(isrc))
|
||||
}
|
||||
|
||||
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
||||
path, _ := idx.lookup(isrc)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (idx *ISRCIndex) Add(isrc, filePath string) {
|
||||
if isrc == "" || filePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
|
||||
idx.index[strings.ToUpper(isrc)] = filePath
|
||||
}
|
||||
|
||||
func InvalidateISRCCache(outputDir string) {
|
||||
isrcIndexCacheMu.Lock()
|
||||
delete(isrcIndexCache, outputDir)
|
||||
isrcIndexCacheMu.Unlock()
|
||||
}
|
||||
|
||||
func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||
if isrc == "" || outputDir == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Walk through directory looking for FLAC files
|
||||
var foundFile string
|
||||
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only check FLAC files
|
||||
if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".flac") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read metadata from file
|
||||
metadata, err := ReadMetadata(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if ISRC matches
|
||||
if metadata.ISRC == isrc {
|
||||
foundFile = path
|
||||
return filepath.SkipAll // Stop walking
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if foundFile != "" {
|
||||
return foundFile, true
|
||||
idx := GetISRCIndex(outputDir)
|
||||
filePath, exists := idx.lookup(isrc)
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return "", false
|
||||
if !CheckFileExists(filePath) {
|
||||
// Stale index entry; remove it and return not found.
|
||||
idx.remove(isrc)
|
||||
return "", false
|
||||
}
|
||||
|
||||
return filePath, true
|
||||
}
|
||||
|
||||
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
||||
// Returns the filepath if exists, empty string if not
|
||||
func CheckISRCExists(outputDir, isrc string) (string, error) {
|
||||
filepath, _ := checkISRCExistsInternal(outputDir, isrc)
|
||||
return filepath, nil
|
||||
}
|
||||
|
||||
// CheckFileExists checks if a file with the given name exists
|
||||
func CheckFileExists(filePath string) bool {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
@@ -61,3 +174,86 @@ func CheckFileExists(filePath string) bool {
|
||||
}
|
||||
return !info.IsDir() && info.Size() > 0
|
||||
}
|
||||
|
||||
type FileExistenceResult struct {
|
||||
ISRC string `json:"isrc"`
|
||||
Exists bool `json:"exists"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
TrackName string `json:"track_name,omitempty"`
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
}
|
||||
|
||||
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
|
||||
var tracks []struct {
|
||||
ISRC string `json:"isrc"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
|
||||
return "", fmt.Errorf("failed to parse tracks JSON: %w", err)
|
||||
}
|
||||
|
||||
results := make([]FileExistenceResult, len(tracks))
|
||||
|
||||
isrcIdx := GetISRCIndex(outputDir)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i, track := range tracks {
|
||||
wg.Add(1)
|
||||
go func(resultIdx int, t struct {
|
||||
ISRC string `json:"isrc"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
}) {
|
||||
defer wg.Done()
|
||||
|
||||
result := FileExistenceResult{
|
||||
ISRC: t.ISRC,
|
||||
TrackName: t.TrackName,
|
||||
ArtistName: t.ArtistName,
|
||||
Exists: false,
|
||||
}
|
||||
|
||||
if t.ISRC != "" {
|
||||
if filePath, exists := isrcIdx.lookup(t.ISRC); exists {
|
||||
result.Exists = true
|
||||
result.FilePath = filePath
|
||||
}
|
||||
}
|
||||
|
||||
results[resultIdx] = result
|
||||
}(i, track)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
resultJSON, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal results: %w", err)
|
||||
}
|
||||
|
||||
return string(resultJSON), nil
|
||||
}
|
||||
|
||||
func PreBuildISRCIndex(outputDir string) error {
|
||||
if outputDir == "" {
|
||||
return fmt.Errorf("output directory is required")
|
||||
}
|
||||
|
||||
buildISRCIndex(outputDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func AddToISRCIndex(outputDir, isrc, filePath string) {
|
||||
if outputDir == "" || isrc == "" || filePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
isrcIndexCacheMu.RLock()
|
||||
idx, exists := isrcIndexCache[outputDir]
|
||||
isrcIndexCacheMu.RUnlock()
|
||||
|
||||
if exists {
|
||||
idx.Add(isrc, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,970 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func compareVersions(v1, v2 string) int {
|
||||
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
|
||||
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
|
||||
|
||||
maxLen := len(parts1)
|
||||
if len(parts2) > maxLen {
|
||||
maxLen = len(parts2)
|
||||
}
|
||||
|
||||
for i := 0; i < maxLen; i++ {
|
||||
var n1, n2 int
|
||||
if i < len(parts1) {
|
||||
n1, _ = strconv.Atoi(parts1[i])
|
||||
}
|
||||
if i < len(parts2) {
|
||||
n2, _ = strconv.Atoi(parts2[i])
|
||||
}
|
||||
|
||||
if n1 < n2 {
|
||||
return -1
|
||||
}
|
||||
if n1 > n2 {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
type LoadedExtension struct {
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
SourceDir string `json:"source_dir"`
|
||||
IconPath string `json:"icon_path"`
|
||||
}
|
||||
|
||||
type ExtensionManager struct {
|
||||
mu sync.RWMutex
|
||||
extensions map[string]*LoadedExtension
|
||||
extensionsDir string
|
||||
dataDir string
|
||||
}
|
||||
|
||||
var (
|
||||
globalExtManager *ExtensionManager
|
||||
globalExtManagerOnce sync.Once
|
||||
)
|
||||
|
||||
func GetExtensionManager() *ExtensionManager {
|
||||
globalExtManagerOnce.Do(func() {
|
||||
globalExtManager = &ExtensionManager{
|
||||
extensions: make(map[string]*LoadedExtension),
|
||||
}
|
||||
})
|
||||
return globalExtManager
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.extensionsDir = extensionsDir
|
||||
m.dataDir = dataDir
|
||||
|
||||
if err := os.MkdirAll(extensionsDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create extensions directory: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
||||
}
|
||||
defer zipReader.Close()
|
||||
|
||||
var manifestData []byte
|
||||
var hasIndexJS bool
|
||||
for _, file := range zipReader.File {
|
||||
name := filepath.Base(file.Name)
|
||||
if name == "manifest.json" {
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
|
||||
}
|
||||
manifestData, err = io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
|
||||
}
|
||||
}
|
||||
if name == "index.js" {
|
||||
hasIndexJS = true
|
||||
}
|
||||
}
|
||||
|
||||
if manifestData == nil {
|
||||
return nil, fmt.Errorf("Invalid extension package: manifest.json not found")
|
||||
}
|
||||
|
||||
if !hasIndexJS {
|
||||
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
||||
}
|
||||
|
||||
manifest, err := ParseManifest(manifestData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
existing, exists := m.extensions[manifest.Name]
|
||||
var existingVersion string
|
||||
var existingDisplayName string
|
||||
if exists {
|
||||
existingVersion = existing.Manifest.Version
|
||||
existingDisplayName = existing.Manifest.DisplayName
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||
if versionCompare > 0 {
|
||||
// This is an upgrade - call UpgradeExtension
|
||||
return m.UpgradeExtension(filePath)
|
||||
} else if versionCompare == 0 {
|
||||
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
||||
} else {
|
||||
return nil, fmt.Errorf("Cannot downgrade '%s' from v%s to v%s", existingDisplayName, existingVersion, manifest.Version)
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, exists := m.extensions[manifest.Name]; exists {
|
||||
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
|
||||
}
|
||||
|
||||
extDir := filepath.Join(m.extensionsDir, manifest.Name)
|
||||
if err := os.MkdirAll(extDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
||||
}
|
||||
|
||||
for _, file := range zipReader.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
relPath := filepath.Clean(file.Name)
|
||||
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
||||
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
|
||||
continue
|
||||
}
|
||||
destPath := filepath.Join(extDir, relPath)
|
||||
|
||||
destDir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
||||
}
|
||||
|
||||
destFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
||||
}
|
||||
|
||||
srcFile, err := file.Open()
|
||||
if err != nil {
|
||||
destFile.Close()
|
||||
return nil, fmt.Errorf("failed to open file in archive: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(destFile, srcFile)
|
||||
srcFile.Close()
|
||||
destFile.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
||||
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||
}
|
||||
|
||||
ext := &LoadedExtension{
|
||||
ID: manifest.Name,
|
||||
Manifest: manifest,
|
||||
Enabled: false, // New extensions start disabled
|
||||
DataDir: extDataDir,
|
||||
SourceDir: extDir,
|
||||
}
|
||||
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
||||
}
|
||||
|
||||
m.extensions[manifest.Name] = ext
|
||||
GoLog("[Extension] Loaded extension: %s v%s\n", manifest.DisplayName, manifest.Version)
|
||||
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
vm := goja.New()
|
||||
ext.VM = vm
|
||||
|
||||
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||
jsCode, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read index.js: %w", err)
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime.RegisterAPIs(vm)
|
||||
runtime.RegisterGoBackendAPIs(vm)
|
||||
|
||||
console := vm.NewObject()
|
||||
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
||||
args := make([]interface{}, len(call.Arguments))
|
||||
for i, arg := range call.Arguments {
|
||||
args[i] = arg.Export()
|
||||
}
|
||||
GoLog("[Extension:%s] %v\n", ext.ID, args)
|
||||
return goja.Undefined()
|
||||
})
|
||||
vm.Set("console", console)
|
||||
|
||||
var registeredExtension goja.Value
|
||||
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 0 {
|
||||
registeredExtension = call.Arguments[0]
|
||||
vm.Set("extension", call.Arguments[0])
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
_, err = vm.RunString(string(jsCode))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute extension code: %w", err)
|
||||
}
|
||||
|
||||
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
|
||||
return fmt.Errorf("extension did not call registerExtension()")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
ext, exists := m.extensions[extensionID]
|
||||
if !exists {
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
if ext.VM != nil {
|
||||
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
||||
if err != nil {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
|
||||
} else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) {
|
||||
GoLog("[Extension] Cleanup called for %s\n", extensionID)
|
||||
}
|
||||
}
|
||||
|
||||
delete(m.extensions, extensionID)
|
||||
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
ext, exists := m.extensions[extensionID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("Extension not found")
|
||||
}
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make([]*LoadedExtension, 0, len(m.extensions))
|
||||
for _, ext := range m.extensions {
|
||||
result = append(result, ext)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
ext, exists := m.extensions[extensionID]
|
||||
if !exists {
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
ext.Enabled = enabled
|
||||
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
||||
|
||||
store := GetExtensionSettingsStore()
|
||||
if err := store.Set(extensionID, "_enabled", enabled); err != nil {
|
||||
GoLog("[Extension] Failed to persist enabled state for %s: %v\n", extensionID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
||||
var loaded []string
|
||||
var errors []error
|
||||
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return loaded, errors
|
||||
}
|
||||
return nil, []error{fmt.Errorf("failed to read extensions directory: %w", err)}
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
manifestPath := filepath.Join(dirPath, entry.Name(), "manifest.json")
|
||||
if _, err := os.Stat(manifestPath); err == nil {
|
||||
ext, err := m.loadExtensionFromDirectory(filepath.Join(dirPath, entry.Name()))
|
||||
if err != nil {
|
||||
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
||||
errors = append(errors, fmt.Errorf("%s: %w", entry.Name(), err))
|
||||
} else {
|
||||
loaded = append(loaded, ext.ID)
|
||||
}
|
||||
}
|
||||
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
|
||||
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
||||
if err != nil {
|
||||
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
||||
errors = append(errors, fmt.Errorf("%s: %w", entry.Name(), err))
|
||||
} else {
|
||||
loaded = append(loaded, ext.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return loaded, errors
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
manifestPath := filepath.Join(dirPath, "manifest.json")
|
||||
manifestData, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
|
||||
}
|
||||
|
||||
// Parse and validate manifest
|
||||
manifest, err := ParseManifest(manifestData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||
}
|
||||
|
||||
indexPath := filepath.Join(dirPath, "index.js")
|
||||
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("Extension is missing index.js file")
|
||||
}
|
||||
|
||||
if existing, exists := m.extensions[manifest.Name]; exists {
|
||||
GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName)
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
||||
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||
}
|
||||
|
||||
ext := &LoadedExtension{
|
||||
ID: manifest.Name,
|
||||
Manifest: manifest,
|
||||
Enabled: false, // Will be restored from settings store
|
||||
DataDir: extDataDir,
|
||||
SourceDir: dirPath,
|
||||
}
|
||||
|
||||
// Restore enabled state from settings store
|
||||
store := GetExtensionSettingsStore()
|
||||
if enabledVal, err := store.Get(manifest.Name, "_enabled"); err == nil {
|
||||
if enabled, ok := enabledVal.(bool); ok {
|
||||
ext.Enabled = enabled
|
||||
GoLog("[Extension] Restored enabled state for %s: %v\n", manifest.Name, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
||||
}
|
||||
|
||||
m.extensions[manifest.Name] = ext
|
||||
GoLog("[Extension] Loaded extension: %s v%s\n", manifest.DisplayName, manifest.Version)
|
||||
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
||||
ext, err := m.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.UnloadExtension(extensionID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ext.SourceDir != "" {
|
||||
if err := os.RemoveAll(ext.SourceDir); err != nil {
|
||||
GoLog("[Extension] Warning: failed to remove source dir: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally remove data directory (keep for now to preserve settings)
|
||||
// if ext.DataDir != "" {
|
||||
// os.RemoveAll(ext.DataDir)
|
||||
// }
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only allows upgrades (new version > current version), not downgrades
|
||||
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
|
||||
// Validate file extension
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
||||
}
|
||||
defer zipReader.Close()
|
||||
|
||||
var manifestData []byte
|
||||
var hasIndexJS bool
|
||||
for _, file := range zipReader.File {
|
||||
name := filepath.Base(file.Name)
|
||||
if name == "manifest.json" {
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open manifest.json: %w", err)
|
||||
}
|
||||
manifestData, err = io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read manifest.json: %w", err)
|
||||
}
|
||||
}
|
||||
if name == "index.js" {
|
||||
hasIndexJS = true
|
||||
}
|
||||
}
|
||||
|
||||
if manifestData == nil {
|
||||
return nil, fmt.Errorf("Invalid extension package: manifest.json not found")
|
||||
}
|
||||
|
||||
if !hasIndexJS {
|
||||
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
||||
}
|
||||
|
||||
newManifest, err := ParseManifest(manifestData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
existing, exists := m.extensions[newManifest.Name]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName)
|
||||
}
|
||||
|
||||
// Compare versions - only allow upgrade, not downgrade
|
||||
versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version)
|
||||
if versionCompare < 0 {
|
||||
return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version)
|
||||
}
|
||||
if versionCompare == 0 {
|
||||
return nil, fmt.Errorf("Extension is already at version %s", existing.Manifest.Version)
|
||||
}
|
||||
|
||||
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
|
||||
|
||||
// Save data directory path and enabled state (we want to preserve them)
|
||||
extDataDir := existing.DataDir
|
||||
extDir := existing.SourceDir
|
||||
wasEnabled := existing.Enabled
|
||||
|
||||
m.CleanupExtension(existing.ID)
|
||||
m.UnloadExtension(existing.ID)
|
||||
|
||||
if extDir != "" {
|
||||
if err := os.RemoveAll(extDir); err != nil {
|
||||
GoLog("[Extension] Warning: failed to remove old source dir: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(extDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
||||
}
|
||||
|
||||
for _, file := range zipReader.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
relPath := filepath.Clean(file.Name)
|
||||
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
||||
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
|
||||
continue
|
||||
}
|
||||
destPath := filepath.Join(extDir, relPath)
|
||||
|
||||
destDir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
||||
}
|
||||
|
||||
destFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
||||
}
|
||||
|
||||
srcFile, err := file.Open()
|
||||
if err != nil {
|
||||
destFile.Close()
|
||||
return nil, fmt.Errorf("failed to open file in archive: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(destFile, srcFile)
|
||||
srcFile.Close()
|
||||
destFile.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
ext := &LoadedExtension{
|
||||
ID: newManifest.Name,
|
||||
Manifest: newManifest,
|
||||
Enabled: wasEnabled, // Preserve enabled state from before upgrade
|
||||
DataDir: extDataDir,
|
||||
SourceDir: extDir,
|
||||
}
|
||||
|
||||
// Initialize Goja VM
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.extensions[newManifest.Name] = ext
|
||||
m.mu.Unlock()
|
||||
|
||||
GoLog("[Extension] Upgraded extension: %s to v%s\n", newManifest.DisplayName, newManifest.Version)
|
||||
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
type ExtensionUpgradeInfo struct {
|
||||
ExtensionID string `json:"extension_id"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
NewVersion string `json:"new_version"`
|
||||
CanUpgrade bool `json:"can_upgrade"`
|
||||
IsInstalled bool `json:"is_installed"`
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||
// Validate file extension
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot open extension file")
|
||||
}
|
||||
defer zipReader.Close()
|
||||
|
||||
var manifestData []byte
|
||||
for _, file := range zipReader.File {
|
||||
name := filepath.Base(file.Name)
|
||||
if name == "manifest.json" {
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open manifest.json")
|
||||
}
|
||||
manifestData, err = io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read manifest.json")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if manifestData == nil {
|
||||
return nil, fmt.Errorf("manifest.json not found")
|
||||
}
|
||||
|
||||
newManifest, err := ParseManifest(manifestData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid manifest: %w", err)
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
existing, exists := m.extensions[newManifest.Name]
|
||||
m.mu.RUnlock()
|
||||
|
||||
info := &ExtensionUpgradeInfo{
|
||||
ExtensionID: newManifest.Name,
|
||||
NewVersion: newManifest.Version,
|
||||
IsInstalled: exists,
|
||||
}
|
||||
|
||||
if !exists {
|
||||
// Not installed - this is a new install, not upgrade
|
||||
info.CurrentVersion = ""
|
||||
info.CanUpgrade = false
|
||||
} else {
|
||||
info.CurrentVersion = existing.Manifest.Version
|
||||
info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
||||
info, err := m.checkExtensionUpgradeInternal(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
extensions := m.GetAllExtensions()
|
||||
|
||||
type ExtensionInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
IconPath string `json:"icon_path,omitempty"`
|
||||
Types []ExtensionType `json:"types"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error_message,omitempty"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"quality_options,omitempty"`
|
||||
Permissions []string `json:"permissions"`
|
||||
HasMetadataProvider bool `json:"has_metadata_provider"`
|
||||
HasDownloadProvider bool `json:"has_download_provider"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
infos := make([]ExtensionInfo, len(extensions))
|
||||
for i, ext := range extensions {
|
||||
permissions := []string{}
|
||||
for _, domain := range ext.Manifest.Permissions.Network {
|
||||
permissions = append(permissions, "network:"+domain)
|
||||
}
|
||||
if ext.Manifest.Permissions.Storage {
|
||||
permissions = append(permissions, "storage:enabled")
|
||||
}
|
||||
|
||||
// Determine status
|
||||
status := "loaded"
|
||||
if ext.Error != "" {
|
||||
status = "error"
|
||||
} else if !ext.Enabled {
|
||||
status = "disabled"
|
||||
}
|
||||
|
||||
iconPath := ""
|
||||
if ext.Manifest.Icon != "" && ext.SourceDir != "" {
|
||||
possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon)
|
||||
if _, err := os.Stat(possibleIcon); err == nil {
|
||||
iconPath = possibleIcon
|
||||
}
|
||||
}
|
||||
if iconPath == "" && ext.SourceDir != "" {
|
||||
possibleIcon := filepath.Join(ext.SourceDir, "icon.png")
|
||||
if _, err := os.Stat(possibleIcon); err == nil {
|
||||
iconPath = possibleIcon
|
||||
}
|
||||
}
|
||||
|
||||
infos[i] = ExtensionInfo{
|
||||
ID: ext.ID,
|
||||
Name: ext.Manifest.Name,
|
||||
DisplayName: ext.Manifest.DisplayName,
|
||||
Version: ext.Manifest.Version,
|
||||
Author: ext.Manifest.Author,
|
||||
Description: ext.Manifest.Description,
|
||||
Homepage: ext.Manifest.Homepage,
|
||||
IconPath: iconPath,
|
||||
Types: ext.Manifest.Types,
|
||||
Enabled: ext.Enabled,
|
||||
Status: status,
|
||||
Error: ext.Error,
|
||||
Settings: ext.Manifest.Settings,
|
||||
QualityOptions: ext.Manifest.QualityOptions,
|
||||
Permissions: permissions,
|
||||
HasMetadataProvider: ext.Manifest.IsMetadataProvider(),
|
||||
HasDownloadProvider: ext.Manifest.IsDownloadProvider(),
|
||||
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
||||
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||
TrackMatching: ext.Manifest.TrackMatching,
|
||||
PostProcessing: ext.Manifest.PostProcessing,
|
||||
Capabilities: ext.Manifest.Capabilities,
|
||||
}
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(infos)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
ext, exists := m.extensions[extensionID]
|
||||
if !exists {
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
if ext.VM == nil {
|
||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||
}
|
||||
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to save settings")
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
var settings = %s;
|
||||
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
||||
try {
|
||||
extension.initialize(settings);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no initialize function' };
|
||||
})()
|
||||
`, string(settingsJSON))
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
ext.Error = errMsg
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
|
||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension] Initialized %s\n", extensionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
ext, exists := m.extensions[extensionID]
|
||||
if !exists {
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
if ext.VM == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
script := `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||
try {
|
||||
extension.cleanup();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no cleanup function' };
|
||||
})()
|
||||
`
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg)
|
||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension] Cleaned up %s\n", extensionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) UnloadAllExtensions() {
|
||||
m.mu.Lock()
|
||||
extensionIDs := make([]string, 0, len(m.extensions))
|
||||
for id := range m.extensions {
|
||||
extensionIDs = append(extensionIDs, id)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, id := range extensionIDs {
|
||||
m.CleanupExtension(id)
|
||||
m.UnloadExtension(id)
|
||||
}
|
||||
|
||||
GoLog("[Extension] All extensions unloaded\n")
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
ext, exists := m.extensions[extensionID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
||||
}
|
||||
|
||||
if ext.VM == nil {
|
||||
return nil, fmt.Errorf("extension VM not initialized")
|
||||
}
|
||||
|
||||
if !ext.Enabled {
|
||||
return nil, fmt.Errorf("extension is disabled")
|
||||
}
|
||||
|
||||
// Call the action function on the extension object
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||
try {
|
||||
var result = extension.%s();
|
||||
if (result && typeof result.then === 'function') {
|
||||
// Handle promise - return pending status
|
||||
return { success: true, pending: true, message: 'Action started' };
|
||||
}
|
||||
return { success: true, result: result };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: false, error: 'Action function not found: %s' };
|
||||
})()
|
||||
`, actionName, actionName, actionName)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout)
|
||||
if err != nil {
|
||||
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
|
||||
return nil, fmt.Errorf("action failed: %v", err)
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) {
|
||||
return map[string]interface{}{"success": true}, nil
|
||||
}
|
||||
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
GoLog("[Extension] InvokeAction %s.%s result: %v\n", extensionID, actionName, resultMap)
|
||||
return resultMap, nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{"success": true, "result": exported}, nil
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// Package gobackend provides extension manifest parsing and validation
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ExtensionType string
|
||||
|
||||
const (
|
||||
ExtensionTypeMetadataProvider ExtensionType = "metadata_provider"
|
||||
ExtensionTypeDownloadProvider ExtensionType = "download_provider"
|
||||
)
|
||||
|
||||
type SettingType string
|
||||
|
||||
const (
|
||||
SettingTypeString SettingType = "string"
|
||||
SettingTypeNumber SettingType = "number"
|
||||
SettingTypeBool SettingType = "boolean"
|
||||
SettingTypeSelect SettingType = "select"
|
||||
SettingTypeButton SettingType = "button" // Action button that calls a JS function
|
||||
)
|
||||
|
||||
type ExtensionPermissions struct {
|
||||
Network []string `json:"network"`
|
||||
Storage bool `json:"storage"`
|
||||
File bool `json:"file"`
|
||||
}
|
||||
|
||||
type ExtensionSetting struct {
|
||||
Key string `json:"key"`
|
||||
Type SettingType `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
}
|
||||
|
||||
type QualityOption struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Settings []QualitySpecificSetting `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
type QualitySpecificSetting struct {
|
||||
Key string `json:"key"`
|
||||
Type SettingType `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type SearchFilter struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
type SearchBehaviorConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
ThumbnailRatio string `json:"thumbnailRatio,omitempty"`
|
||||
ThumbnailWidth int `json:"thumbnailWidth,omitempty"`
|
||||
ThumbnailHeight int `json:"thumbnailHeight,omitempty"`
|
||||
Filters []SearchFilter `json:"filters,omitempty"`
|
||||
}
|
||||
|
||||
type URLHandlerConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Patterns []string `json:"patterns,omitempty"`
|
||||
}
|
||||
|
||||
type TrackMatchingConfig struct {
|
||||
CustomMatching bool `json:"customMatching"`
|
||||
Strategy string `json:"strategy,omitempty"`
|
||||
DurationTolerance int `json:"durationTolerance,omitempty"`
|
||||
}
|
||||
|
||||
type PostProcessingHook struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DefaultEnabled bool `json:"defaultEnabled,omitempty"`
|
||||
SupportedFormats []string `json:"supportedFormats,omitempty"`
|
||||
}
|
||||
|
||||
type PostProcessingConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Hooks []PostProcessingHook `json:"hooks,omitempty"`
|
||||
}
|
||||
|
||||
type ExtensionManifest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ManifestValidationError) Error() string {
|
||||
return fmt.Sprintf("manifest validation error: %s - %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
func ParseManifest(data []byte) (*ExtensionManifest, error) {
|
||||
var manifest ExtensionManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse manifest JSON: %w", err)
|
||||
}
|
||||
|
||||
if err := manifest.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) Validate() error {
|
||||
if strings.TrimSpace(m.Name) == "" {
|
||||
return &ManifestValidationError{Field: "name", Message: "name is required"}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.Version) == "" {
|
||||
return &ManifestValidationError{Field: "version", Message: "version is required"}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.Author) == "" {
|
||||
return &ManifestValidationError{Field: "author", Message: "author is required"}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.Description) == "" {
|
||||
return &ManifestValidationError{Field: "description", Message: "description is required"}
|
||||
}
|
||||
|
||||
if len(m.Types) == 0 {
|
||||
return &ManifestValidationError{Field: "type", Message: "at least one type is required"}
|
||||
}
|
||||
|
||||
for _, t := range m.Types {
|
||||
if t != ExtensionTypeMetadataProvider && t != ExtensionTypeDownloadProvider {
|
||||
return &ManifestValidationError{
|
||||
Field: "type",
|
||||
Message: fmt.Sprintf("invalid extension type: %s (must be 'metadata_provider' or 'download_provider')", t),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, setting := range m.Settings {
|
||||
if strings.TrimSpace(setting.Key) == "" {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].key", i),
|
||||
Message: "setting key is required",
|
||||
}
|
||||
}
|
||||
|
||||
if setting.Type == "" {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].type", i),
|
||||
Message: "setting type is required",
|
||||
}
|
||||
}
|
||||
|
||||
// Select type requires options
|
||||
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].options", i),
|
||||
Message: "select type requires options",
|
||||
}
|
||||
}
|
||||
|
||||
if setting.Type == SettingTypeButton && setting.Action == "" {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].action", i),
|
||||
Message: "button type requires action (JS function name)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
||||
for _, et := range m.Types {
|
||||
if et == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) IsMetadataProvider() bool {
|
||||
return m.HasType(ExtensionTypeMetadataProvider)
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) IsDownloadProvider() bool {
|
||||
return m.HasType(ExtensionTypeDownloadProvider)
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
for _, allowed := range m.Permissions.Network {
|
||||
allowed = strings.ToLower(strings.TrimSpace(allowed))
|
||||
if allowed == domain {
|
||||
return true
|
||||
}
|
||||
// Support wildcard subdomains (e.g., *.example.com)
|
||||
if strings.HasPrefix(allowed, "*.") {
|
||||
suffix := allowed[1:]
|
||||
if strings.HasSuffix(domain, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) HasCustomSearch() bool {
|
||||
return m.SearchBehavior != nil && m.SearchBehavior.Enabled
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) HasCustomMatching() bool {
|
||||
return m.TrackMatching != nil && m.TrackMatching.CustomMatching
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) HasPostProcessing() bool {
|
||||
return m.PostProcessing != nil && m.PostProcessing.Enabled
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) HasURLHandler() bool {
|
||||
return m.URLHandler != nil && m.URLHandler.Enabled && len(m.URLHandler.Patterns) > 0
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) MatchesURL(urlStr string) bool {
|
||||
if !m.HasURLHandler() {
|
||||
return false
|
||||
}
|
||||
|
||||
urlStr = strings.ToLower(strings.TrimSpace(urlStr))
|
||||
for _, pattern := range m.URLHandler.Patterns {
|
||||
pattern = strings.ToLower(strings.TrimSpace(pattern))
|
||||
if strings.Contains(urlStr, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) GetPostProcessingHooks() []PostProcessingHook {
|
||||
if m.PostProcessing == nil {
|
||||
return nil
|
||||
}
|
||||
return m.PostProcessing.Hooks
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) ToJSON() ([]byte, error) {
|
||||
return json.Marshal(m)
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
const DefaultJSTimeout = 30 * time.Second
|
||||
|
||||
var (
|
||||
extensionAuthState = make(map[string]*ExtensionAuthState)
|
||||
extensionAuthStateMu sync.RWMutex
|
||||
)
|
||||
|
||||
type ExtensionAuthState struct {
|
||||
PendingAuthURL string
|
||||
AuthCode string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time
|
||||
IsAuthenticated bool
|
||||
PKCEVerifier string
|
||||
PKCEChallenge string
|
||||
}
|
||||
|
||||
type PendingAuthRequest struct {
|
||||
ExtensionID string
|
||||
AuthURL string
|
||||
CallbackURL string
|
||||
}
|
||||
|
||||
var (
|
||||
pendingAuthRequests = make(map[string]*PendingAuthRequest)
|
||||
pendingAuthRequestsMu sync.RWMutex
|
||||
)
|
||||
|
||||
func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
||||
pendingAuthRequestsMu.RLock()
|
||||
defer pendingAuthRequestsMu.RUnlock()
|
||||
return pendingAuthRequests[extensionID]
|
||||
}
|
||||
|
||||
func ClearPendingAuthRequest(extensionID string) {
|
||||
pendingAuthRequestsMu.Lock()
|
||||
defer pendingAuthRequestsMu.Unlock()
|
||||
delete(pendingAuthRequests, extensionID)
|
||||
}
|
||||
|
||||
func SetExtensionAuthCode(extensionID string, authCode string) {
|
||||
extensionAuthStateMu.Lock()
|
||||
defer extensionAuthStateMu.Unlock()
|
||||
|
||||
state, exists := extensionAuthState[extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[extensionID] = state
|
||||
}
|
||||
state.AuthCode = authCode
|
||||
}
|
||||
|
||||
func SetExtensionTokens(extensionID string, accessToken, refreshToken string, expiresAt time.Time) {
|
||||
extensionAuthStateMu.Lock()
|
||||
defer extensionAuthStateMu.Unlock()
|
||||
|
||||
state, exists := extensionAuthState[extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[extensionID] = state
|
||||
}
|
||||
state.AccessToken = accessToken
|
||||
state.RefreshToken = refreshToken
|
||||
state.ExpiresAt = expiresAt
|
||||
state.IsAuthenticated = accessToken != ""
|
||||
}
|
||||
|
||||
type ExtensionRuntime struct {
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
dataDir string
|
||||
vm *goja.Runtime
|
||||
}
|
||||
|
||||
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
jar, _ := newSimpleCookieJar()
|
||||
|
||||
runtime := &ExtensionRuntime{
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]interface{}),
|
||||
cookieJar: jar,
|
||||
dataDir: ext.DataDir,
|
||||
vm: ext.VM,
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Jar: jar,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" {
|
||||
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
|
||||
return fmt.Errorf("redirect blocked: only https is allowed")
|
||||
}
|
||||
|
||||
domain := req.URL.Hostname()
|
||||
if domain == "" {
|
||||
GoLog("[Extension:%s] Redirect blocked: missing hostname\n", ext.ID)
|
||||
return fmt.Errorf("redirect blocked: hostname is required")
|
||||
}
|
||||
if !ext.Manifest.IsDomainAllowed(domain) {
|
||||
GoLog("[Extension:%s] Redirect blocked: domain '%s' not in allowed list\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain}
|
||||
}
|
||||
if isPrivateIP(domain) {
|
||||
GoLog("[Extension:%s] Redirect blocked: private IP '%s'\n", ext.ID, domain)
|
||||
return &RedirectBlockedError{Domain: domain, IsPrivate: true}
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
runtime.httpClient = client
|
||||
|
||||
return runtime
|
||||
}
|
||||
|
||||
type RedirectBlockedError struct {
|
||||
Domain string
|
||||
IsPrivate bool
|
||||
}
|
||||
|
||||
func (e *RedirectBlockedError) Error() string {
|
||||
if e.IsPrivate {
|
||||
return "redirect blocked: private/local network access denied"
|
||||
}
|
||||
return "redirect blocked: domain '" + e.Domain + "' not in allowed list"
|
||||
}
|
||||
|
||||
// isPrivateIP checks if a hostname resolves to a private/local IP address
|
||||
func isPrivateIP(host string) bool {
|
||||
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||||
if hostLower == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if hostLower == "localhost" || strings.HasSuffix(hostLower, ".local") {
|
||||
return true
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(hostLower); ip != nil {
|
||||
return isPrivateIPAddr(ip)
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(hostLower)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
if isPrivateIPAddr(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isPrivateIPAddr(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if ip.IsLoopback() ||
|
||||
ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsMulticast() ||
|
||||
ip.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
if !ip.IsGlobalUnicast() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type simpleCookieJar struct {
|
||||
cookies map[string][]*http.Cookie
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newSimpleCookieJar() (*simpleCookieJar, error) {
|
||||
return &simpleCookieJar{
|
||||
cookies: make(map[string][]*http.Cookie),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
key := u.Host
|
||||
j.cookies[key] = append(j.cookies[key], cookies...)
|
||||
}
|
||||
|
||||
func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
return j.cookies[u.Host]
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
r.settings = settings
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
r.vm = vm
|
||||
|
||||
httpObj := vm.NewObject()
|
||||
httpObj.Set("get", r.httpGet)
|
||||
httpObj.Set("post", r.httpPost)
|
||||
httpObj.Set("put", r.httpPut)
|
||||
httpObj.Set("delete", r.httpDelete)
|
||||
httpObj.Set("patch", r.httpPatch)
|
||||
httpObj.Set("request", r.httpRequest)
|
||||
httpObj.Set("clearCookies", r.httpClearCookies)
|
||||
vm.Set("http", httpObj)
|
||||
|
||||
storageObj := vm.NewObject()
|
||||
storageObj.Set("get", r.storageGet)
|
||||
storageObj.Set("set", r.storageSet)
|
||||
storageObj.Set("remove", r.storageRemove)
|
||||
vm.Set("storage", storageObj)
|
||||
|
||||
credentialsObj := vm.NewObject()
|
||||
credentialsObj.Set("store", r.credentialsStore)
|
||||
credentialsObj.Set("get", r.credentialsGet)
|
||||
credentialsObj.Set("remove", r.credentialsRemove)
|
||||
credentialsObj.Set("has", r.credentialsHas)
|
||||
vm.Set("credentials", credentialsObj)
|
||||
|
||||
authObj := vm.NewObject()
|
||||
authObj.Set("openAuthUrl", r.authOpenUrl)
|
||||
authObj.Set("getAuthCode", r.authGetCode)
|
||||
authObj.Set("setAuthCode", r.authSetCode)
|
||||
authObj.Set("clearAuth", r.authClear)
|
||||
authObj.Set("isAuthenticated", r.authIsAuthenticated)
|
||||
authObj.Set("getTokens", r.authGetTokens)
|
||||
authObj.Set("generatePKCE", r.authGeneratePKCE)
|
||||
authObj.Set("getPKCE", r.authGetPKCE)
|
||||
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
|
||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||
vm.Set("auth", authObj)
|
||||
|
||||
fileObj := vm.NewObject()
|
||||
fileObj.Set("download", r.fileDownload)
|
||||
fileObj.Set("exists", r.fileExists)
|
||||
fileObj.Set("delete", r.fileDelete)
|
||||
fileObj.Set("read", r.fileRead)
|
||||
fileObj.Set("write", r.fileWrite)
|
||||
fileObj.Set("copy", r.fileCopy)
|
||||
fileObj.Set("move", r.fileMove)
|
||||
fileObj.Set("getSize", r.fileGetSize)
|
||||
vm.Set("file", fileObj)
|
||||
|
||||
ffmpegObj := vm.NewObject()
|
||||
ffmpegObj.Set("execute", r.ffmpegExecute)
|
||||
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
|
||||
ffmpegObj.Set("convert", r.ffmpegConvert)
|
||||
vm.Set("ffmpeg", ffmpegObj)
|
||||
|
||||
matchingObj := vm.NewObject()
|
||||
matchingObj.Set("compareStrings", r.matchingCompareStrings)
|
||||
matchingObj.Set("compareDuration", r.matchingCompareDuration)
|
||||
matchingObj.Set("normalizeString", r.matchingNormalizeString)
|
||||
vm.Set("matching", matchingObj)
|
||||
|
||||
utilsObj := vm.NewObject()
|
||||
utilsObj.Set("base64Encode", r.base64Encode)
|
||||
utilsObj.Set("base64Decode", r.base64Decode)
|
||||
utilsObj.Set("md5", r.md5Hash)
|
||||
utilsObj.Set("sha256", r.sha256Hash)
|
||||
utilsObj.Set("hmacSHA256", r.hmacSHA256)
|
||||
utilsObj.Set("hmacSHA256Base64", r.hmacSHA256Base64)
|
||||
utilsObj.Set("hmacSHA1", r.hmacSHA1)
|
||||
utilsObj.Set("parseJSON", r.parseJSON)
|
||||
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
||||
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||
vm.Set("utils", utilsObj)
|
||||
|
||||
logObj := vm.NewObject()
|
||||
logObj.Set("debug", r.logDebug)
|
||||
logObj.Set("info", r.logInfo)
|
||||
logObj.Set("warn", r.logWarn)
|
||||
logObj.Set("error", r.logError)
|
||||
vm.Set("log", logObj)
|
||||
|
||||
gobackendObj := vm.NewObject()
|
||||
gobackendObj.Set("sanitizeFilename", r.sanitizeFilenameWrapper)
|
||||
vm.Set("gobackend", gobackendObj)
|
||||
|
||||
vm.Set("fetch", r.fetchPolyfill)
|
||||
|
||||
vm.Set("atob", r.atobPolyfill)
|
||||
vm.Set("btoa", r.btoaPolyfill)
|
||||
|
||||
r.registerTextEncoderDecoder(vm)
|
||||
|
||||
r.registerURLClass(vm)
|
||||
|
||||
r.registerJSONGlobal(vm)
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
// Package gobackend provides Auth API and PKCE support for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Auth API (OAuth Support) ====================
|
||||
|
||||
func validateExtensionAuthURL(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid auth URL: %w", err)
|
||||
}
|
||||
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("invalid auth URL: only https is allowed")
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("invalid auth URL: hostname is required")
|
||||
}
|
||||
|
||||
if parsed.User != nil {
|
||||
return fmt.Errorf("invalid auth URL: embedded credentials are not allowed")
|
||||
}
|
||||
|
||||
if isPrivateIP(host) {
|
||||
return fmt.Errorf("invalid auth URL: private/local network is not allowed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func summarizeURLForLog(urlStr string) string {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return urlStr
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return parsed.Scheme + "://"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "auth URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
authURL := call.Arguments[0].String()
|
||||
callbackURL := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
||||
callbackURL = call.Arguments[1].String()
|
||||
}
|
||||
|
||||
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
AuthURL: authURL,
|
||||
CallbackURL: callbackURL,
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.PendingAuthURL = authURL
|
||||
state.AuthCode = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, summarizeURLForLog(authURL))
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Auth URL will be opened by the app",
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists || state.AuthCode == "" {
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(state.AuthCode)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
arg := call.Arguments[0].Export()
|
||||
|
||||
extensionAuthStateMu.Lock()
|
||||
defer extensionAuthStateMu.Unlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
|
||||
switch v := arg.(type) {
|
||||
case string:
|
||||
state.AuthCode = v
|
||||
case map[string]interface{}:
|
||||
if code, ok := v["code"].(string); ok {
|
||||
state.AuthCode = code
|
||||
}
|
||||
if accessToken, ok := v["access_token"].(string); ok {
|
||||
state.AccessToken = accessToken
|
||||
state.IsAuthenticated = true
|
||||
}
|
||||
if refreshToken, ok := v["refresh_token"].(string); ok {
|
||||
state.RefreshToken = refreshToken
|
||||
}
|
||||
if expiresIn, ok := v["expires_in"].(float64); ok {
|
||||
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.Lock()
|
||||
delete(extensionAuthState, r.extensionID)
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
pendingAuthRequestsMu.Lock()
|
||||
delete(pendingAuthRequests, r.extensionID)
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] Auth state cleared\n", r.extensionID)
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(state.IsAuthenticated)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
return r.vm.ToValue(map[string]interface{}{})
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"access_token": state.AccessToken,
|
||||
"refresh_token": state.RefreshToken,
|
||||
"is_authenticated": state.IsAuthenticated,
|
||||
}
|
||||
|
||||
if !state.ExpiresAt.IsZero() {
|
||||
result["expires_at"] = state.ExpiresAt.Unix()
|
||||
result["is_expired"] = time.Now().After(state.ExpiresAt)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
// ==================== PKCE Support ====================
|
||||
|
||||
// generatePKCEVerifier generates a cryptographically random code verifier
|
||||
// Length should be between 43-128 characters (RFC 7636)
|
||||
func generatePKCEVerifier(length int) (string, error) {
|
||||
if length < 43 {
|
||||
length = 43
|
||||
}
|
||||
if length > 128 {
|
||||
length = 128
|
||||
}
|
||||
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
verifier := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
|
||||
if len(verifier) > length {
|
||||
verifier = verifier[:length]
|
||||
}
|
||||
|
||||
return verifier, nil
|
||||
}
|
||||
|
||||
func generatePKCEChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
// Base64url encode without padding (RFC 7636)
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
length := 64
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
||||
length = int(l)
|
||||
}
|
||||
}
|
||||
|
||||
verifier, err := generatePKCEVerifier(length)
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] PKCE generation error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
challenge := generatePKCEChallenge(verifier)
|
||||
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.PKCEVerifier = verifier
|
||||
state.PKCEChallenge = challenge
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE generated (verifier length: %d)\n", r.extensionID, len(verifier))
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"verifier": verifier,
|
||||
"challenge": challenge,
|
||||
"method": "S256",
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists || state.PKCEVerifier == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"verifier": state.PKCEVerifier,
|
||||
"challenge": state.PKCEChallenge,
|
||||
"method": "S256",
|
||||
})
|
||||
}
|
||||
|
||||
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
||||
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config object is required",
|
||||
})
|
||||
}
|
||||
|
||||
configObj := call.Arguments[0].Export()
|
||||
config, ok := configObj.(map[string]interface{})
|
||||
if !ok {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config must be an object",
|
||||
})
|
||||
}
|
||||
|
||||
authURL, _ := config["authUrl"].(string)
|
||||
clientID, _ := config["clientId"].(string)
|
||||
redirectURI, _ := config["redirectUri"].(string)
|
||||
|
||||
if authURL == "" || clientID == "" || redirectURI == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "authUrl, clientId, and redirectUri are required",
|
||||
})
|
||||
}
|
||||
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
scope, _ := config["scope"].(string)
|
||||
extraParams, _ := config["extraParams"].(map[string]interface{})
|
||||
|
||||
verifier, err := generatePKCEVerifier(64)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to generate PKCE: %v", err),
|
||||
})
|
||||
}
|
||||
challenge := generatePKCEChallenge(verifier)
|
||||
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.PKCEVerifier = verifier
|
||||
state.PKCEChallenge = challenge
|
||||
state.AuthCode = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
parsedURL, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("invalid authUrl: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
query := parsedURL.Query()
|
||||
query.Set("client_id", clientID)
|
||||
query.Set("redirect_uri", redirectURI)
|
||||
query.Set("response_type", "code")
|
||||
query.Set("code_challenge", challenge)
|
||||
query.Set("code_challenge_method", "S256")
|
||||
|
||||
if scope != "" {
|
||||
query.Set("scope", scope)
|
||||
}
|
||||
|
||||
for k, v := range extraParams {
|
||||
query.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
|
||||
parsedURL.RawQuery = query.Encode()
|
||||
fullAuthURL := parsedURL.String()
|
||||
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
AuthURL: fullAuthURL,
|
||||
CallbackURL: redirectURI,
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, summarizeURLForLog(fullAuthURL))
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"authUrl": fullAuthURL,
|
||||
"pkce": map[string]interface{}{
|
||||
"verifier": verifier,
|
||||
"challenge": challenge,
|
||||
"method": "S256",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// authExchangeCodeWithPKCE exchanges auth code for tokens using PKCE
|
||||
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
|
||||
// Uses the stored PKCE verifier automatically
|
||||
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config object is required",
|
||||
})
|
||||
}
|
||||
|
||||
configObj := call.Arguments[0].Export()
|
||||
config, ok := configObj.(map[string]interface{})
|
||||
if !ok {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config must be an object",
|
||||
})
|
||||
}
|
||||
|
||||
// Required fields
|
||||
tokenURL, _ := config["tokenUrl"].(string)
|
||||
clientID, _ := config["clientId"].(string)
|
||||
redirectURI, _ := config["redirectUri"].(string)
|
||||
code, _ := config["code"].(string)
|
||||
|
||||
if tokenURL == "" || clientID == "" || code == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "tokenUrl, clientId, and code are required",
|
||||
})
|
||||
}
|
||||
|
||||
extensionAuthStateMu.RLock()
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
var verifier string
|
||||
if exists {
|
||||
verifier = state.PKCEVerifier
|
||||
}
|
||||
extensionAuthStateMu.RUnlock()
|
||||
|
||||
if verifier == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "no PKCE verifier found - call generatePKCE or startOAuthWithPKCE first",
|
||||
})
|
||||
}
|
||||
|
||||
if err := r.validateDomain(tokenURL); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
formData.Set("client_id", clientID)
|
||||
formData.Set("code", code)
|
||||
formData.Set("code_verifier", verifier)
|
||||
if redirectURI != "" {
|
||||
formData.Set("redirect_uri", redirectURI)
|
||||
}
|
||||
|
||||
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
|
||||
for k, v := range extraParams {
|
||||
formData.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
bodyPreview := sanitizeSensitiveLogText(string(body))
|
||||
if len(bodyPreview) > 1000 {
|
||||
bodyPreview = bodyPreview[:1000] + "...[truncated]"
|
||||
}
|
||||
|
||||
var tokenResp map[string]interface{}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to parse token response: %v", err),
|
||||
"body": bodyPreview,
|
||||
})
|
||||
}
|
||||
|
||||
if errMsg, ok := tokenResp["error"].(string); ok {
|
||||
errDesc, _ := tokenResp["error_description"].(string)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": errMsg,
|
||||
"error_description": errDesc,
|
||||
})
|
||||
}
|
||||
|
||||
accessToken, _ := tokenResp["access_token"].(string)
|
||||
refreshToken, _ := tokenResp["refresh_token"].(string)
|
||||
expiresIn, _ := tokenResp["expires_in"].(float64)
|
||||
|
||||
if accessToken == "" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "no access_token in response",
|
||||
"body": bodyPreview,
|
||||
})
|
||||
}
|
||||
|
||||
extensionAuthStateMu.Lock()
|
||||
state, exists = extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
state = &ExtensionAuthState{}
|
||||
extensionAuthState[r.extensionID] = state
|
||||
}
|
||||
state.AccessToken = accessToken
|
||||
state.RefreshToken = refreshToken
|
||||
state.IsAuthenticated = true
|
||||
if expiresIn > 0 {
|
||||
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
}
|
||||
state.PKCEVerifier = ""
|
||||
state.PKCEChallenge = ""
|
||||
extensionAuthStateMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"success": true,
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
"token_type": tokenResp["token_type"],
|
||||
}
|
||||
if expiresIn > 0 {
|
||||
result["expires_in"] = expiresIn
|
||||
}
|
||||
if scope, ok := tokenResp["scope"].(string); ok {
|
||||
result["scope"] = scope
|
||||
}
|
||||
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Package gobackend provides FFmpeg API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== FFmpeg API (Post-Processing) ====================
|
||||
|
||||
// FFmpegCommand holds a pending FFmpeg command for Flutter to execute
|
||||
type FFmpegCommand struct {
|
||||
ExtensionID string
|
||||
Command string
|
||||
InputPath string
|
||||
OutputPath string
|
||||
Completed bool
|
||||
Success bool
|
||||
Error string
|
||||
Output string
|
||||
}
|
||||
|
||||
// Global FFmpeg command queue
|
||||
var (
|
||||
ffmpegCommands = make(map[string]*FFmpegCommand)
|
||||
ffmpegCommandsMu sync.RWMutex
|
||||
ffmpegCommandID int64
|
||||
)
|
||||
|
||||
func GetPendingFFmpegCommand(commandID string) *FFmpegCommand {
|
||||
ffmpegCommandsMu.RLock()
|
||||
defer ffmpegCommandsMu.RUnlock()
|
||||
return ffmpegCommands[commandID]
|
||||
}
|
||||
|
||||
func SetFFmpegCommandResult(commandID string, success bool, output, errorMsg string) {
|
||||
ffmpegCommandsMu.Lock()
|
||||
defer ffmpegCommandsMu.Unlock()
|
||||
if cmd, exists := ffmpegCommands[commandID]; exists {
|
||||
cmd.Completed = true
|
||||
cmd.Success = success
|
||||
cmd.Output = output
|
||||
cmd.Error = errorMsg
|
||||
}
|
||||
}
|
||||
|
||||
func ClearFFmpegCommand(commandID string) {
|
||||
ffmpegCommandsMu.Lock()
|
||||
defer ffmpegCommandsMu.Unlock()
|
||||
delete(ffmpegCommands, commandID)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "command is required",
|
||||
})
|
||||
}
|
||||
|
||||
command := call.Arguments[0].String()
|
||||
|
||||
ffmpegCommandsMu.Lock()
|
||||
ffmpegCommandID++
|
||||
cmdID := fmt.Sprintf("%s_%d", r.extensionID, ffmpegCommandID)
|
||||
ffmpegCommands[cmdID] = &FFmpegCommand{
|
||||
ExtensionID: r.extensionID,
|
||||
Command: command,
|
||||
Completed: false,
|
||||
}
|
||||
ffmpegCommandsMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] FFmpeg command queued: %s\n", r.extensionID, cmdID)
|
||||
|
||||
timeout := 5 * time.Minute
|
||||
start := time.Now()
|
||||
for {
|
||||
ffmpegCommandsMu.RLock()
|
||||
cmd := ffmpegCommands[cmdID]
|
||||
completed := cmd != nil && cmd.Completed
|
||||
ffmpegCommandsMu.RUnlock()
|
||||
|
||||
if completed {
|
||||
ffmpegCommandsMu.RLock()
|
||||
result := map[string]interface{}{
|
||||
"success": cmd.Success,
|
||||
"output": cmd.Output,
|
||||
}
|
||||
if cmd.Error != "" {
|
||||
result["error"] = cmd.Error
|
||||
}
|
||||
ffmpegCommandsMu.RUnlock()
|
||||
|
||||
ClearFFmpegCommand(cmdID)
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
if time.Since(start) > timeout {
|
||||
ClearFFmpegCommand(cmdID)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "FFmpeg command timed out",
|
||||
})
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "file path is required",
|
||||
})
|
||||
}
|
||||
|
||||
filePath := call.Arguments[0].String()
|
||||
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"bit_depth": quality.BitDepth,
|
||||
"sample_rate": quality.SampleRate,
|
||||
"total_samples": quality.TotalSamples,
|
||||
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "input and output paths are required",
|
||||
})
|
||||
}
|
||||
|
||||
inputPath := call.Arguments[0].String()
|
||||
outputPath := call.Arguments[1].String()
|
||||
|
||||
options := map[string]interface{}{}
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok {
|
||||
options = opts
|
||||
}
|
||||
}
|
||||
|
||||
var cmdParts []string
|
||||
cmdParts = append(cmdParts, "-i", fmt.Sprintf("%q", inputPath))
|
||||
|
||||
if codec, ok := options["codec"].(string); ok {
|
||||
cmdParts = append(cmdParts, "-c:a", codec)
|
||||
}
|
||||
|
||||
if bitrate, ok := options["bitrate"].(string); ok {
|
||||
cmdParts = append(cmdParts, "-b:a", bitrate)
|
||||
}
|
||||
|
||||
if sampleRate, ok := options["sample_rate"].(float64); ok {
|
||||
cmdParts = append(cmdParts, "-ar", fmt.Sprintf("%d", int(sampleRate)))
|
||||
}
|
||||
|
||||
if channels, ok := options["channels"].(float64); ok {
|
||||
cmdParts = append(cmdParts, "-ac", fmt.Sprintf("%d", int(channels)))
|
||||
}
|
||||
|
||||
cmdParts = append(cmdParts, "-y", fmt.Sprintf("%q", outputPath))
|
||||
|
||||
command := strings.Join(cmdParts, " ")
|
||||
|
||||
execCall := goja.FunctionCall{
|
||||
Arguments: []goja.Value{r.vm.ToValue(command)},
|
||||
}
|
||||
return r.ffmpegExecute(execCall)
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
// Package gobackend provides File API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== File API (Sandboxed) ====================
|
||||
|
||||
var (
|
||||
allowedDownloadDirs []string
|
||||
allowedDownloadDirsMu sync.RWMutex
|
||||
)
|
||||
|
||||
func SetAllowedDownloadDirs(dirs []string) {
|
||||
allowedDownloadDirsMu.Lock()
|
||||
defer allowedDownloadDirsMu.Unlock()
|
||||
allowedDownloadDirs = dirs
|
||||
GoLog("[Extension] Allowed download directories set: %v\n", dirs)
|
||||
}
|
||||
|
||||
func AddAllowedDownloadDir(dir string) {
|
||||
allowedDownloadDirsMu.Lock()
|
||||
defer allowedDownloadDirsMu.Unlock()
|
||||
absDir, err := filepath.Abs(dir)
|
||||
if err == nil {
|
||||
allowedDownloadDirs = append(allowedDownloadDirs, absDir)
|
||||
}
|
||||
}
|
||||
|
||||
func isPathInAllowedDirs(absPath string) bool {
|
||||
allowedDownloadDirsMu.RLock()
|
||||
defer allowedDownloadDirsMu.RUnlock()
|
||||
|
||||
for _, allowedDir := range allowedDownloadDirs {
|
||||
if isPathWithinBase(allowedDir, absPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPathWithinBase(baseDir, targetPath string) bool {
|
||||
baseAbs, err := filepath.Abs(baseDir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
targetAbs, err := filepath.Abs(targetPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(baseAbs, targetAbs)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel = filepath.Clean(rel)
|
||||
if rel == "." {
|
||||
return true
|
||||
}
|
||||
|
||||
prefix := ".." + string(filepath.Separator)
|
||||
if rel == ".." || strings.HasPrefix(rel, prefix) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
if !r.manifest.Permissions.File {
|
||||
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||
}
|
||||
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
if filepath.IsAbs(cleanPath) {
|
||||
absPath, err := filepath.Abs(cleanPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
if isPathInAllowedDirs(absPath) {
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(r.dataDir, cleanPath)
|
||||
|
||||
absPath, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
absDataDir, _ := filepath.Abs(r.dataDir)
|
||||
if !isPathWithinBase(absDataDir, absPath) {
|
||||
return "", fmt.Errorf("file access denied: path '%s' is outside sandbox", path)
|
||||
}
|
||||
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "URL and output path are required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
outputPath := call.Arguments[1].String()
|
||||
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
fullPath, err := r.validatePath(outputPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
var onProgress goja.Callable
|
||||
var headers map[string]string
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
optionsObj := call.Arguments[2].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||
headers = make(map[string]string)
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
if progressVal, ok := opts["onProgress"]; ok {
|
||||
if callable, ok := goja.AssertFunction(r.vm.ToValue(progressVal)); ok {
|
||||
onProgress = callable
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
|
||||
})
|
||||
}
|
||||
|
||||
out, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create file: %v", err),
|
||||
})
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
contentLength := resp.ContentLength
|
||||
|
||||
var written int64
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
nr, er := resp.Body.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, ew := out.Write(buf[0:nr])
|
||||
if nw < 0 || nr < nw {
|
||||
nw = 0
|
||||
if ew == nil {
|
||||
ew = fmt.Errorf("invalid write result")
|
||||
}
|
||||
}
|
||||
written += int64(nw)
|
||||
if ew != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to write file: %v", ew),
|
||||
})
|
||||
}
|
||||
if nr != nw {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "short write",
|
||||
})
|
||||
}
|
||||
|
||||
if onProgress != nil && contentLength > 0 {
|
||||
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(written), r.vm.ToValue(contentLength))
|
||||
}
|
||||
}
|
||||
if er != nil {
|
||||
if er != io.EOF {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read response: %v", er),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullPath,
|
||||
"size": written,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
_, err = os.Stat(fullPath)
|
||||
return r.vm.ToValue(err == nil)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path is required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.Remove(fullPath); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path is required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": string(data),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path and data are required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
data := call.Arguments[1].String()
|
||||
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.WriteFile(fullPath, []byte(data), 0644); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullPath,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "source and destination paths are required",
|
||||
})
|
||||
}
|
||||
|
||||
srcPath := call.Arguments[0].String()
|
||||
dstPath := call.Arguments[1].String()
|
||||
|
||||
fullSrc, err := r.validatePath(srcPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
fullDst, err := r.validatePath(dstPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(fullSrc)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read source: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fullDst)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.WriteFile(fullDst, data, 0644); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to write destination: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullDst,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "source and destination paths are required",
|
||||
})
|
||||
}
|
||||
|
||||
srcPath := call.Arguments[0].String()
|
||||
dstPath := call.Arguments[1].String()
|
||||
|
||||
fullSrc, err := r.validatePath(srcPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
fullDst, err := r.validatePath(dstPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fullDst)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.Rename(fullSrc, fullDst); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to move file: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullDst,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path is required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"size": info.Size(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
// Package gobackend provides HTTP API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== HTTP API (Sandboxed) ====================
|
||||
|
||||
type HTTPResponse struct {
|
||||
StatusCode int `json:"statusCode"`
|
||||
Body string `json:"body"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
if parsed.Scheme == "" {
|
||||
return fmt.Errorf("invalid URL: scheme is required")
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("network access denied: only https is allowed")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return fmt.Errorf("invalid URL: embedded credentials are not allowed")
|
||||
}
|
||||
|
||||
domain := parsed.Hostname()
|
||||
if domain == "" {
|
||||
return fmt.Errorf("invalid URL: hostname is required")
|
||||
}
|
||||
|
||||
if isPrivateIP(domain) {
|
||||
return fmt.Errorf("network access denied: private/local network '%s' not allowed", domain)
|
||||
}
|
||||
|
||||
if !r.manifest.IsDomainAllowed(domain) {
|
||||
return fmt.Errorf("network access denied: domain '%s' not in allowed list", domain)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
headersObj := call.Arguments[1].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
var bodyStr string
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
bodyArg := call.Arguments[1].Export()
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = call.Arguments[1].String()
|
||||
}
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
headersObj := call.Arguments[2].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
method := "GET"
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
optionsObj := call.Arguments[1].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
|
||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PUT", call)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("DELETE", call)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PATCH", call)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
if method == "DELETE" {
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
headersObj := call.Arguments[1].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
bodyArg := call.Arguments[1].Export()
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = call.Arguments[1].String()
|
||||
}
|
||||
}
|
||||
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
headersObj := call.Arguments[2].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||
jar.mu.Lock()
|
||||
jar.cookies = make(map[string][]*http.Cookie)
|
||||
jar.mu.Unlock()
|
||||
GoLog("[Extension:%s] Cookies cleared\n", r.extensionID)
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Package gobackend provides Track Matching API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Track Matching API ====================
|
||||
|
||||
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(0.0)
|
||||
}
|
||||
|
||||
str1 := strings.ToLower(strings.TrimSpace(call.Arguments[0].String()))
|
||||
str2 := strings.ToLower(strings.TrimSpace(call.Arguments[1].String()))
|
||||
|
||||
if str1 == str2 {
|
||||
return r.vm.ToValue(1.0)
|
||||
}
|
||||
|
||||
similarity := calculateStringSimilarity(str1, str2)
|
||||
return r.vm.ToValue(similarity)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
dur1 := int(call.Arguments[0].ToInteger())
|
||||
dur2 := int(call.Arguments[1].ToInteger())
|
||||
|
||||
tolerance := 3000
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) {
|
||||
tolerance = int(call.Arguments[2].ToInteger())
|
||||
}
|
||||
|
||||
diff := dur1 - dur2
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
|
||||
return r.vm.ToValue(diff <= tolerance)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
|
||||
str := call.Arguments[0].String()
|
||||
normalized := normalizeStringForMatching(str)
|
||||
return r.vm.ToValue(normalized)
|
||||
}
|
||||
|
||||
func calculateStringSimilarity(s1, s2 string) float64 {
|
||||
if len(s1) == 0 && len(s2) == 0 {
|
||||
return 1.0
|
||||
}
|
||||
if len(s1) == 0 || len(s2) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
distance := levenshteinDistance(s1, s2)
|
||||
maxLen := len(s1)
|
||||
if len(s2) > maxLen {
|
||||
maxLen = len(s2)
|
||||
}
|
||||
|
||||
return 1.0 - float64(distance)/float64(maxLen)
|
||||
}
|
||||
|
||||
func levenshteinDistance(s1, s2 string) int {
|
||||
if len(s1) == 0 {
|
||||
return len(s2)
|
||||
}
|
||||
if len(s2) == 0 {
|
||||
return len(s1)
|
||||
}
|
||||
|
||||
matrix := make([][]int, len(s1)+1)
|
||||
for i := range matrix {
|
||||
matrix[i] = make([]int, len(s2)+1)
|
||||
matrix[i][0] = i
|
||||
}
|
||||
for j := range matrix[0] {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
|
||||
for i := 1; i <= len(s1); i++ {
|
||||
for j := 1; j <= len(s2); j++ {
|
||||
cost := 1
|
||||
if s1[i-1] == s2[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
matrix[i][j] = min(
|
||||
matrix[i-1][j]+1,
|
||||
matrix[i][j-1]+1,
|
||||
matrix[i-1][j-1]+cost,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[len(s1)][len(s2)]
|
||||
}
|
||||
|
||||
func normalizeStringForMatching(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
|
||||
suffixes := []string{
|
||||
" (remastered)", " (remaster)", " - remastered", " - remaster",
|
||||
" (deluxe)", " (deluxe edition)", " - deluxe", " - deluxe edition",
|
||||
" (explicit)", " (clean)", " [explicit]", " [clean]",
|
||||
" (album version)", " (single version)", " (radio edit)",
|
||||
" (feat.", " (ft.", " feat.", " ft.",
|
||||
}
|
||||
for _, suffix := range suffixes {
|
||||
if idx := strings.Index(s, suffix); idx != -1 {
|
||||
s = s[:idx]
|
||||
}
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == ' ' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
s = strings.Join(strings.Fields(result.String()), " ")
|
||||
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
// Package gobackend provides Browser-like Polyfills for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Browser-like Polyfills ====================
|
||||
// These polyfills make porting browser/Node.js libraries easier
|
||||
// without compromising sandbox security
|
||||
|
||||
// fetchPolyfill implements browser-compatible fetch() API
|
||||
// Returns a Promise-like object with json(), text() methods
|
||||
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.createFetchError("URL is required")
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] fetch blocked: %v\n", r.extensionID, err)
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
method := "GET"
|
||||
var bodyStr string
|
||||
headers := make(map[string]string)
|
||||
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
optionsObj := call.Arguments[1].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
|
||||
// Body - support string, object (auto-stringify), or nil
|
||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.createFetchError(fmt.Sprintf("failed to stringify body: %v", err))
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
if h, ok := opts["headers"]; ok && h != nil {
|
||||
switch hv := h.(type) {
|
||||
case map[string]interface{}:
|
||||
for k, v := range hv {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
responseObj := r.vm.NewObject()
|
||||
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
|
||||
responseObj.Set("status", resp.StatusCode)
|
||||
responseObj.Set("statusText", http.StatusText(resp.StatusCode))
|
||||
responseObj.Set("headers", respHeaders)
|
||||
responseObj.Set("url", urlStr)
|
||||
|
||||
bodyString := string(body)
|
||||
|
||||
responseObj.Set("text", func(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(bodyString)
|
||||
})
|
||||
|
||||
responseObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
GoLog("[Extension:%s] fetch json() parse error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
return r.vm.ToValue(result)
|
||||
})
|
||||
|
||||
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
|
||||
byteArray := make([]interface{}, len(body))
|
||||
for i, b := range body {
|
||||
byteArray[i] = int(b)
|
||||
}
|
||||
return r.vm.ToValue(byteArray)
|
||||
})
|
||||
|
||||
return responseObj
|
||||
}
|
||||
|
||||
// createFetchError creates a fetch error response
|
||||
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||
errorObj := r.vm.NewObject()
|
||||
errorObj.Set("ok", false)
|
||||
errorObj.Set("status", 0)
|
||||
errorObj.Set("statusText", "Network Error")
|
||||
errorObj.Set("error", message)
|
||||
errorObj.Set("text", func(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue("")
|
||||
})
|
||||
errorObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
||||
return goja.Undefined()
|
||||
})
|
||||
return errorObj
|
||||
}
|
||||
|
||||
// atobPolyfill implements browser atob() - decode base64 to string
|
||||
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
decoded, err := base64.StdEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
decoded, err = base64.URLEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] atob decode error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
}
|
||||
return r.vm.ToValue(string(decoded))
|
||||
}
|
||||
|
||||
// btoaPolyfill implements browser btoa() - encode string to base64
|
||||
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||
}
|
||||
|
||||
// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes
|
||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
encoder := call.This
|
||||
encoder.Set("encoding", "utf-8")
|
||||
|
||||
encoder.Set("encode", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue([]byte{})
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
bytes := []byte(input)
|
||||
|
||||
result := make([]interface{}, len(bytes))
|
||||
for i, b := range bytes {
|
||||
result[i] = int(b)
|
||||
}
|
||||
return vm.ToValue(result)
|
||||
})
|
||||
|
||||
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
||||
// Simplified implementation
|
||||
if len(call.Arguments) < 2 {
|
||||
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"read": len(input),
|
||||
"written": len([]byte(input)),
|
||||
})
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
vm.Set("TextDecoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
decoder := call.This
|
||||
|
||||
encoding := "utf-8"
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
encoding = call.Arguments[0].String()
|
||||
}
|
||||
decoder.Set("encoding", encoding)
|
||||
decoder.Set("fatal", false)
|
||||
decoder.Set("ignoreBOM", false)
|
||||
|
||||
decoder.Set("decode", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
input := call.Arguments[0].Export()
|
||||
var bytes []byte
|
||||
|
||||
switch v := input.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case []interface{}:
|
||||
bytes = make([]byte, len(v))
|
||||
for i, val := range v {
|
||||
switch n := val.(type) {
|
||||
case int64:
|
||||
bytes[i] = byte(n)
|
||||
case float64:
|
||||
bytes[i] = byte(n)
|
||||
case int:
|
||||
bytes[i] = byte(n)
|
||||
}
|
||||
}
|
||||
case string:
|
||||
return vm.ToValue(v)
|
||||
default:
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
return vm.ToValue(string(bytes))
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
||||
urlObj := call.This
|
||||
|
||||
if len(call.Arguments) < 1 {
|
||||
urlObj.Set("href", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
||||
baseStr := call.Arguments[1].String()
|
||||
baseURL, err := url.Parse(baseStr)
|
||||
if err == nil {
|
||||
relURL, err := url.Parse(urlStr)
|
||||
if err == nil {
|
||||
urlStr = baseURL.ResolveReference(relURL).String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
urlObj.Set("href", urlStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
urlObj.Set("href", parsed.String())
|
||||
urlObj.Set("protocol", parsed.Scheme+":")
|
||||
urlObj.Set("host", parsed.Host)
|
||||
urlObj.Set("hostname", parsed.Hostname())
|
||||
urlObj.Set("port", parsed.Port())
|
||||
urlObj.Set("pathname", parsed.Path)
|
||||
urlObj.Set("search", "")
|
||||
if parsed.RawQuery != "" {
|
||||
urlObj.Set("search", "?"+parsed.RawQuery)
|
||||
}
|
||||
urlObj.Set("hash", "")
|
||||
if parsed.Fragment != "" {
|
||||
urlObj.Set("hash", "#"+parsed.Fragment)
|
||||
}
|
||||
urlObj.Set("origin", parsed.Scheme+"://"+parsed.Host)
|
||||
urlObj.Set("username", parsed.User.Username())
|
||||
password, _ := parsed.User.Password()
|
||||
urlObj.Set("password", password)
|
||||
|
||||
queryValues := parsed.Query()
|
||||
|
||||
searchParams := vm.NewObject()
|
||||
searchParams.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Null()
|
||||
}
|
||||
key := call.Arguments[0].String()
|
||||
if val := queryValues.Get(key); val != "" {
|
||||
return vm.ToValue(val)
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
|
||||
searchParams.Set("getAll", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue([]string{})
|
||||
}
|
||||
key := call.Arguments[0].String()
|
||||
return vm.ToValue(queryValues[key])
|
||||
})
|
||||
|
||||
searchParams.Set("has", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
key := call.Arguments[0].String()
|
||||
return vm.ToValue(queryValues.Has(key))
|
||||
})
|
||||
|
||||
searchParams.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(queryValues.Encode())
|
||||
})
|
||||
|
||||
urlObj.Set("searchParams", searchParams)
|
||||
|
||||
urlObj.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(parsed.String())
|
||||
})
|
||||
|
||||
urlObj.Set("toJSON", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(parsed.String())
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
vm.Set("URLSearchParams", func(call goja.ConstructorCall) *goja.Object {
|
||||
paramsObj := call.This
|
||||
values := url.Values{}
|
||||
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
init := call.Arguments[0].Export()
|
||||
switch v := init.(type) {
|
||||
case string:
|
||||
parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?"))
|
||||
values = parsed
|
||||
case map[string]interface{}:
|
||||
for k, val := range v {
|
||||
values.Set(k, fmt.Sprintf("%v", val))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paramsObj.Set("append", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) >= 2 {
|
||||
values.Add(call.Arguments[0].String(), call.Arguments[1].String())
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
paramsObj.Set("delete", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) >= 1 {
|
||||
values.Del(call.Arguments[0].String())
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
paramsObj.Set("get", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Null()
|
||||
}
|
||||
if val := values.Get(call.Arguments[0].String()); val != "" {
|
||||
return vm.ToValue(val)
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
|
||||
paramsObj.Set("getAll", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue([]string{})
|
||||
}
|
||||
return vm.ToValue(values[call.Arguments[0].String()])
|
||||
})
|
||||
|
||||
paramsObj.Set("has", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
return vm.ToValue(values.Has(call.Arguments[0].String()))
|
||||
})
|
||||
|
||||
paramsObj.Set("set", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) >= 2 {
|
||||
values.Set(call.Arguments[0].String(), call.Arguments[1].String())
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
paramsObj.Set("toString", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(values.Encode())
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// registerJSONGlobal ensures JSON global is properly set up
|
||||
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||
// JSON is already built-in to Goja, but we can enhance it
|
||||
jsonScript := `
|
||||
if (typeof JSON === 'undefined') {
|
||||
var JSON = {
|
||||
parse: function(text) {
|
||||
return utils.parseJSON(text);
|
||||
},
|
||||
stringify: function(value, replacer, space) {
|
||||
return utils.stringifyJSON(value);
|
||||
}
|
||||
};
|
||||
}
|
||||
`
|
||||
_, _ = vm.RunString(jsonScript)
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// Package gobackend provides Storage and Credentials API for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Storage API ====================
|
||||
|
||||
func (r *ExtensionRuntime) getStoragePath() string {
|
||||
return filepath.Join(r.dataDir, "storage.json")
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
storagePath := r.getStoragePath()
|
||||
data, err := os.ReadFile(storagePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var storage map[string]interface{}
|
||||
if err := json.Unmarshal(data, &storage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
|
||||
storagePath := r.getStoragePath()
|
||||
data, err := json.MarshalIndent(storage, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(storagePath, data, 0600)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
storage, err := r.loadStorage()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
value, exists := storage[key]
|
||||
if !exists {
|
||||
if len(call.Arguments) > 1 {
|
||||
return call.Arguments[1]
|
||||
}
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(value)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
value := call.Arguments[1].Export()
|
||||
|
||||
storage, err := r.loadStorage()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
storage[key] = value
|
||||
|
||||
if err := r.saveStorage(storage); err != nil {
|
||||
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
storage, err := r.loadStorage()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
delete(storage, key)
|
||||
|
||||
if err := r.saveStorage(storage); err != nil {
|
||||
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getCredentialsPath() string {
|
||||
return filepath.Join(r.dataDir, ".credentials.enc")
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getSaltPath() string {
|
||||
return filepath.Join(r.dataDir, ".cred_salt")
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
saltPath := r.getSaltPath()
|
||||
|
||||
salt, err := os.ReadFile(saltPath)
|
||||
if err == nil && len(salt) == 32 {
|
||||
return salt, nil
|
||||
}
|
||||
|
||||
salt = make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(saltPath, salt, 0600); err != nil {
|
||||
return nil, fmt.Errorf("failed to save salt: %w", err)
|
||||
}
|
||||
|
||||
return salt, nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||
salt, err := r.getOrCreateSalt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
combined := append([]byte(r.extensionID), salt...)
|
||||
hash := sha256.Sum256(combined)
|
||||
return hash[:], nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
credPath := r.getCredentialsPath()
|
||||
data, err := os.ReadFile(credPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := r.getEncryptionKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get encryption key: %w", err)
|
||||
}
|
||||
decrypted, err := decryptAES(data, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt credentials: %w", err)
|
||||
}
|
||||
|
||||
var creds map[string]interface{}
|
||||
if err := json.Unmarshal(decrypted, &creds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
data, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key, err := r.getEncryptionKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get encryption key: %w", err)
|
||||
}
|
||||
encrypted, err := encryptAES(data, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt credentials: %w", err)
|
||||
}
|
||||
|
||||
credPath := r.getCredentialsPath()
|
||||
return os.WriteFile(credPath, encrypted, 0600)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "key and value are required",
|
||||
})
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
value := call.Arguments[1].Export()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
creds[key] = value
|
||||
|
||||
if err := r.saveCredentials(creds); err != nil {
|
||||
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
value, exists := creds[key]
|
||||
if !exists {
|
||||
if len(call.Arguments) > 1 {
|
||||
return call.Arguments[1]
|
||||
}
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(value)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
delete(creds, key)
|
||||
|
||||
if err := r.saveCredentials(creds); err != nil {
|
||||
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
creds, err := r.loadCredentials()
|
||||
if err != nil {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
_, exists := creds[key]
|
||||
return r.vm.ToValue(exists)
|
||||
}
|
||||
|
||||
func encryptAES(plaintext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
func decryptAES(ciphertext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Package gobackend provides Utility functions for extension runtime
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ==================== Utility Functions ====================
|
||||
|
||||
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
decoded, err := base64.StdEncoding.DecodeString(input)
|
||||
if err != nil {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
return r.vm.ToValue(string(decoded))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
hash := md5.Sum([]byte(input))
|
||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
hash := sha256.Sum256([]byte(input))
|
||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
message := call.Arguments[0].String()
|
||||
key := call.Arguments[1].String()
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
mac.Write([]byte(message))
|
||||
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
message := call.Arguments[0].String()
|
||||
key := call.Arguments[1].String()
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
mac.Write([]byte(message))
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
var keyBytes []byte
|
||||
keyArg := call.Arguments[0].Export()
|
||||
switch k := keyArg.(type) {
|
||||
case string:
|
||||
keyBytes = []byte(k)
|
||||
case []interface{}:
|
||||
keyBytes = make([]byte, len(k))
|
||||
for i, v := range k {
|
||||
if num, ok := v.(int64); ok {
|
||||
keyBytes[i] = byte(num)
|
||||
} else if num, ok := v.(float64); ok {
|
||||
keyBytes[i] = byte(int(num))
|
||||
}
|
||||
}
|
||||
default:
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
var msgBytes []byte
|
||||
msgArg := call.Arguments[1].Export()
|
||||
switch m := msgArg.(type) {
|
||||
case string:
|
||||
msgBytes = []byte(m)
|
||||
case []interface{}:
|
||||
msgBytes = make([]byte, len(m))
|
||||
for i, v := range m {
|
||||
if num, ok := v.(int64); ok {
|
||||
msgBytes[i] = byte(num)
|
||||
} else if num, ok := v.(float64); ok {
|
||||
msgBytes[i] = byte(int(num))
|
||||
}
|
||||
}
|
||||
default:
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
|
||||
mac := hmac.New(sha1.New, keyBytes)
|
||||
mac.Write(msgBytes)
|
||||
result := mac.Sum(nil)
|
||||
|
||||
jsArray := make([]interface{}, len(result))
|
||||
for i, b := range result {
|
||||
jsArray[i] = int(b)
|
||||
}
|
||||
return r.vm.ToValue(jsArray)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
|
||||
var result interface{}
|
||||
if err := json.Unmarshal([]byte(input), &result); err != nil {
|
||||
GoLog("[Extension:%s] JSON parse error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].Export()
|
||||
|
||||
data, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] JSON stringify error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
|
||||
return r.vm.ToValue(string(data))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "plaintext and key are required",
|
||||
})
|
||||
}
|
||||
|
||||
plaintext := call.Arguments[0].String()
|
||||
keyStr := call.Arguments[1].String()
|
||||
|
||||
keyHash := sha256.Sum256([]byte(keyStr))
|
||||
|
||||
encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": base64.StdEncoding.EncodeToString(encrypted),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "ciphertext and key are required",
|
||||
})
|
||||
}
|
||||
|
||||
ciphertextB64 := call.Arguments[0].String()
|
||||
keyStr := call.Arguments[1].String()
|
||||
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "invalid base64 ciphertext",
|
||||
})
|
||||
}
|
||||
|
||||
keyHash := sha256.Sum256([]byte(keyStr))
|
||||
|
||||
decrypted, err := decryptAES(ciphertext, keyHash[:])
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "invalid base64 ciphertext",
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": string(decrypted),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||
length := 32
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
if l, ok := call.Arguments[0].Export().(float64); ok {
|
||||
length = int(l)
|
||||
}
|
||||
}
|
||||
|
||||
key := make([]byte, length)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"key": base64.StdEncoding.EncodeToString(key),
|
||||
"hex": hex.EncodeToString(key),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(getRandomUserAgent())
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||
parts := make([]string, len(args))
|
||||
for i, arg := range args {
|
||||
parts[i] = fmt.Sprintf("%v", arg.Export())
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
return r.vm.ToValue(sanitizeFilename(input))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
gobackendObj := vm.Get("gobackend")
|
||||
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
||||
gobackendObj = vm.NewObject()
|
||||
vm.Set("gobackend", gobackendObj)
|
||||
}
|
||||
|
||||
obj := gobackendObj.(*goja.Object)
|
||||
|
||||
obj.Set("sanitizeFilename", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
return vm.ToValue(sanitizeFilename(call.Arguments[0].String()))
|
||||
})
|
||||
|
||||
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": "file path is required",
|
||||
})
|
||||
}
|
||||
|
||||
filePath := call.Arguments[0].String()
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err != nil {
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"bitDepth": quality.BitDepth,
|
||||
"sampleRate": quality.SampleRate,
|
||||
"totalSamples": quality.TotalSamples,
|
||||
})
|
||||
})
|
||||
|
||||
obj.Set("buildFilename", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
template := call.Arguments[0].String()
|
||||
metadataObj := call.Arguments[1].Export()
|
||||
|
||||
metadata, ok := metadataObj.(map[string]interface{})
|
||||
if !ok {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
|
||||
return vm.ToValue(buildFilenameFromTemplate(template, metadata))
|
||||
})
|
||||
|
||||
obj.Set("getLocalTime", func(call goja.FunctionCall) goja.Value {
|
||||
now := time.Now()
|
||||
_, offsetSeconds := now.Zone()
|
||||
offsetMinutes := offsetSeconds / 60
|
||||
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"year": now.Year(),
|
||||
"month": int(now.Month()),
|
||||
"day": now.Day(),
|
||||
"hour": now.Hour(),
|
||||
"minute": now.Minute(),
|
||||
"second": now.Second(),
|
||||
"weekday": int(now.Weekday()),
|
||||
"offsetMinutes": -offsetMinutes, // JS convention: negative for east of UTC
|
||||
"timezone": now.Location().String(),
|
||||
"timestamp": now.Unix(),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Package gobackend provides extension settings storage
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type ExtensionSettingsStore struct {
|
||||
mu sync.RWMutex
|
||||
dataDir string
|
||||
settings map[string]map[string]interface{} // extensionID -> settings
|
||||
}
|
||||
|
||||
var (
|
||||
globalSettingsStore *ExtensionSettingsStore
|
||||
globalSettingsStoreOnce sync.Once
|
||||
)
|
||||
|
||||
func GetExtensionSettingsStore() *ExtensionSettingsStore {
|
||||
globalSettingsStoreOnce.Do(func() {
|
||||
globalSettingsStore = &ExtensionSettingsStore{
|
||||
settings: make(map[string]map[string]interface{}),
|
||||
}
|
||||
})
|
||||
return globalSettingsStore
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) SetDataDir(dataDir string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.dataDir = dataDir
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create settings directory: %w", err)
|
||||
}
|
||||
|
||||
return s.loadAllSettings()
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) getSettingsPath(extensionID string) string {
|
||||
return filepath.Join(s.dataDir, extensionID, "settings.json")
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) loadAllSettings() error {
|
||||
entries, err := os.ReadDir(s.dataDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
extensionID := entry.Name()
|
||||
settings, err := s.loadSettings(extensionID)
|
||||
if err != nil {
|
||||
GoLog("[ExtensionSettings] Failed to load settings for %s: %v\n", extensionID, err)
|
||||
continue
|
||||
}
|
||||
s.settings[extensionID] = settings
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
|
||||
dir := filepath.Dir(settingsPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(settingsPath, data, 0644)
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
extSettings, exists := s.settings[extensionID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("extension '%s' settings not found", extensionID)
|
||||
}
|
||||
|
||||
value, exists := extSettings[key]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("setting '%s' not found for extension '%s'", key, extensionID)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
extSettings, exists := s.settings[extensionID]
|
||||
if !exists {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range extSettings {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, exists := s.settings[extensionID]; !exists {
|
||||
s.settings[extensionID] = make(map[string]interface{})
|
||||
}
|
||||
|
||||
s.settings[extensionID][key] = value
|
||||
|
||||
return s.saveSettings(extensionID, s.settings[extensionID])
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.settings[extensionID] = settings
|
||||
|
||||
return s.saveSettings(extensionID, settings)
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) Remove(extensionID, key string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
extSettings, exists := s.settings[extensionID]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
delete(extSettings, key)
|
||||
|
||||
return s.saveSettings(extensionID, extSettings)
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) RemoveAll(extensionID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
delete(s.settings, extensionID)
|
||||
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
if err := os.Remove(settingsPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) GetAllExtensionSettingsJSON() (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
data, err := json.Marshal(s.settings)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CategoryMetadata = "metadata"
|
||||
CategoryDownload = "download"
|
||||
CategoryUtility = "utility"
|
||||
CategoryLyrics = "lyrics"
|
||||
CategoryIntegration = "integration"
|
||||
)
|
||||
|
||||
type StoreExtension struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Downloads int `json:"downloads"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||
DisplayNameAlt string `json:"displayName,omitempty"`
|
||||
DownloadURLAlt string `json:"downloadUrl,omitempty"`
|
||||
IconURLAlt string `json:"iconUrl,omitempty"`
|
||||
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||
}
|
||||
|
||||
func (e *StoreExtension) getDisplayName() string {
|
||||
if e.DisplayName != "" {
|
||||
return e.DisplayName
|
||||
}
|
||||
if e.DisplayNameAlt != "" {
|
||||
return e.DisplayNameAlt
|
||||
}
|
||||
return e.Name
|
||||
}
|
||||
|
||||
func (e *StoreExtension) getDownloadURL() string {
|
||||
if e.DownloadURL != "" {
|
||||
return e.DownloadURL
|
||||
}
|
||||
return e.DownloadURLAlt
|
||||
}
|
||||
|
||||
func (e *StoreExtension) getIconURL() string {
|
||||
if e.IconURL != "" {
|
||||
return e.IconURL
|
||||
}
|
||||
return e.IconURLAlt
|
||||
}
|
||||
|
||||
func (e *StoreExtension) getMinAppVersion() string {
|
||||
if e.MinAppVersion != "" {
|
||||
return e.MinAppVersion
|
||||
}
|
||||
return e.MinAppVersionAlt
|
||||
}
|
||||
|
||||
type StoreRegistry struct {
|
||||
Version int `json:"version"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Extensions []StoreExtension `json:"extensions"`
|
||||
}
|
||||
|
||||
// StoreExtensionResponse is the normalized response sent to Flutter
|
||||
type StoreExtensionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Downloads int `json:"downloads"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
MinAppVersion string `json:"min_app_version,omitempty"`
|
||||
IsInstalled bool `json:"is_installed"`
|
||||
InstalledVersion string `json:"installed_version,omitempty"`
|
||||
HasUpdate bool `json:"has_update"`
|
||||
}
|
||||
|
||||
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
||||
return StoreExtensionResponse{
|
||||
ID: e.ID,
|
||||
Name: e.Name,
|
||||
DisplayName: e.getDisplayName(),
|
||||
Version: e.Version,
|
||||
Author: e.Author,
|
||||
Description: e.Description,
|
||||
DownloadURL: e.getDownloadURL(),
|
||||
IconURL: e.getIconURL(),
|
||||
Category: e.Category,
|
||||
Tags: e.Tags,
|
||||
Downloads: e.Downloads,
|
||||
UpdatedAt: e.UpdatedAt,
|
||||
MinAppVersion: e.getMinAppVersion(),
|
||||
}
|
||||
}
|
||||
|
||||
type ExtensionStore struct {
|
||||
registryURL string
|
||||
cacheDir string
|
||||
cache *StoreRegistry
|
||||
cacheMu sync.RWMutex
|
||||
cacheTime time.Time
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
extensionStore *ExtensionStore
|
||||
extensionStoreMu sync.Mutex
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRegistryURL = "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Extension/main/registry.json"
|
||||
cacheTTL = 30 * time.Minute
|
||||
cacheFileName = "store_cache.json"
|
||||
)
|
||||
|
||||
func InitExtensionStore(cacheDir string) *ExtensionStore {
|
||||
extensionStoreMu.Lock()
|
||||
defer extensionStoreMu.Unlock()
|
||||
|
||||
if extensionStore == nil {
|
||||
extensionStore = &ExtensionStore{
|
||||
registryURL: defaultRegistryURL,
|
||||
cacheDir: cacheDir,
|
||||
cacheTTL: cacheTTL,
|
||||
}
|
||||
extensionStore.loadDiskCache()
|
||||
}
|
||||
return extensionStore
|
||||
}
|
||||
|
||||
func GetExtensionStore() *ExtensionStore {
|
||||
extensionStoreMu.Lock()
|
||||
defer extensionStoreMu.Unlock()
|
||||
return extensionStore
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) loadDiskCache() {
|
||||
if s.cacheDir == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||
data, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var cacheData struct {
|
||||
Registry StoreRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &cacheData); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.cache = &cacheData.Registry
|
||||
s.cacheTime = time.Unix(cacheData.CacheTime, 0)
|
||||
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) saveDiskCache() {
|
||||
if s.cacheDir == "" || s.cache == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cacheData := struct {
|
||||
Registry StoreRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
}{
|
||||
Registry: *s.cache,
|
||||
CacheTime: s.cacheTime.Unix(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cacheData)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||
os.WriteFile(cachePath, data, 0644)
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
||||
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
||||
return s.cache, nil
|
||||
}
|
||||
|
||||
if err := requireHTTPSURL(s.registryURL, "registry"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Get(s.registryURL)
|
||||
if err != nil {
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
||||
return s.cache, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to fetch registry: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionStore", "HTTP %d, using cached registry", resp.StatusCode)
|
||||
return s.cache, nil
|
||||
}
|
||||
return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read registry: %w", err)
|
||||
}
|
||||
|
||||
var registry StoreRegistry
|
||||
if err := json.Unmarshal(body, ®istry); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||
}
|
||||
|
||||
s.cache = ®istry
|
||||
s.cacheTime = time.Now()
|
||||
s.saveDiskCache()
|
||||
|
||||
LogInfo("ExtensionStore", "Fetched %d extensions from registry", len(registry.Extensions))
|
||||
return ®istry, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
||||
registry, err := s.FetchRegistry(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manager := GetExtensionManager()
|
||||
installed := make(map[string]string) // id -> version
|
||||
|
||||
if manager != nil {
|
||||
for _, ext := range manager.GetAllExtensions() {
|
||||
installed[ext.ID] = ext.Manifest.Version
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]StoreExtensionResponse, len(registry.Extensions))
|
||||
for i, ext := range registry.Extensions {
|
||||
resp := ext.ToResponse()
|
||||
|
||||
if installedVersion, ok := installed[ext.ID]; ok {
|
||||
resp.IsInstalled = true
|
||||
resp.InstalledVersion = installedVersion
|
||||
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
|
||||
}
|
||||
|
||||
result[i] = resp
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
||||
registry, err := s.FetchRegistry(false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var ext *StoreExtension
|
||||
for _, e := range registry.Extensions {
|
||||
if e.ID == extensionID {
|
||||
ext = &e
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ext == nil {
|
||||
return fmt.Errorf("extension %s not found in store", extensionID)
|
||||
}
|
||||
|
||||
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Minute}
|
||||
resp, err := client.Get(ext.getDownloadURL())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
os.Remove(destPath)
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Downloaded %s to %s", ext.getDisplayName(), destPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireHTTPSURL(rawURL string, context string) error {
|
||||
if rawURL == "" {
|
||||
return fmt.Errorf("%s URL is empty", context)
|
||||
}
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return fmt.Errorf("%s URL is invalid: %s", context, rawURL)
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("%s URL must use https: %s", context, rawURL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetCategories() []string {
|
||||
return []string{
|
||||
CategoryMetadata,
|
||||
CategoryDownload,
|
||||
CategoryUtility,
|
||||
CategoryLyrics,
|
||||
CategoryIntegration,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
|
||||
extensions, err := s.GetExtensionsWithStatus()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if query == "" && category == "" {
|
||||
return extensions, nil
|
||||
}
|
||||
|
||||
var result []StoreExtensionResponse
|
||||
queryLower := toLower(query)
|
||||
|
||||
for _, ext := range extensions {
|
||||
// Filter by category
|
||||
if category != "" && ext.Category != category {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by query
|
||||
if query != "" {
|
||||
if !containsIgnoreCase(ext.Name, queryLower) &&
|
||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||
!containsIgnoreCase(ext.Author, queryLower) {
|
||||
// Check tags
|
||||
found := false
|
||||
for _, tag := range ext.Tags {
|
||||
if containsIgnoreCase(tag, queryLower) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, ext)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) ClearCache() {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
s.cache = nil
|
||||
s.cacheTime = time.Time{}
|
||||
|
||||
if s.cacheDir != "" {
|
||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||
os.Remove(cachePath)
|
||||
}
|
||||
|
||||
LogInfo("ExtensionStore", "Cache cleared")
|
||||
}
|
||||
|
||||
// Helper: case-insensitive contains
|
||||
func containsIgnoreCase(s, substr string) bool {
|
||||
return containsStr(toLower(s), substr)
|
||||
}
|
||||
|
||||
func toLower(s string) string {
|
||||
result := make([]byte, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
c += 'a' - 'A'
|
||||
}
|
||||
result[i] = c
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func containsStr(s, substr string) bool {
|
||||
return len(substr) == 0 || (len(s) >= len(substr) && findSubstring(s, substr) >= 0)
|
||||
}
|
||||
|
||||
func findSubstring(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TestParseManifest_Valid(t *testing.T) {
|
||||
validManifest := `{
|
||||
"name": "test-provider",
|
||||
"displayName": "Test Provider",
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension",
|
||||
"type": ["metadata_provider"],
|
||||
"permissions": {
|
||||
"network": ["api.test.com"],
|
||||
"storage": true
|
||||
}
|
||||
}`
|
||||
|
||||
manifest, err := ParseManifest([]byte(validManifest))
|
||||
if err != nil {
|
||||
t.Fatalf("Expected valid manifest to parse, got error: %v", err)
|
||||
}
|
||||
|
||||
if manifest.Name != "test-provider" {
|
||||
t.Errorf("Expected name 'test-provider', got '%s'", manifest.Name)
|
||||
}
|
||||
|
||||
if manifest.Version != "1.0.0" {
|
||||
t.Errorf("Expected version '1.0.0', got '%s'", manifest.Version)
|
||||
}
|
||||
|
||||
if !manifest.IsMetadataProvider() {
|
||||
t.Error("Expected IsMetadataProvider() to return true")
|
||||
}
|
||||
|
||||
if manifest.IsDownloadProvider() {
|
||||
t.Error("Expected IsDownloadProvider() to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifest_MissingName(t *testing.T) {
|
||||
invalidManifest := `{
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension",
|
||||
"type": ["metadata_provider"]
|
||||
}`
|
||||
|
||||
_, err := ParseManifest([]byte(invalidManifest))
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifest_MissingType(t *testing.T) {
|
||||
invalidManifest := `{
|
||||
"name": "test-provider",
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension"
|
||||
}`
|
||||
|
||||
_, err := ParseManifest([]byte(invalidManifest))
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDomainAllowed(t *testing.T) {
|
||||
manifest := &ExtensionManifest{
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"api.test.com", "*.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
domain string
|
||||
expected bool
|
||||
}{
|
||||
{"api.test.com", true},
|
||||
{"api.example.com", true},
|
||||
{"sub.example.com", true},
|
||||
{"notallowed.com", false},
|
||||
{"test.com", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := manifest.IsDomainAllowed(tt.domain)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsDomainAllowed(%s) = %v, expected %v", tt.domain, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
// Create a mock extension with limited network permissions
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"api.allowed.com", "*.wildcard.com"},
|
||||
},
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
||||
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
||||
}
|
||||
|
||||
if err := runtime.validateDomain("https://sub.wildcard.com/path"); err != nil {
|
||||
t.Errorf("Expected sub.wildcard.com to be allowed (wildcard), got error: %v", err)
|
||||
}
|
||||
|
||||
if err := runtime.validateDomain("https://blocked.com/path"); err == nil {
|
||||
t.Error("Expected blocked.com to be denied")
|
||||
}
|
||||
|
||||
if err := runtime.validateDomain("https://notallowed.com/path"); err == nil {
|
||||
t.Error("Expected notallowed.com to be denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: true,
|
||||
},
|
||||
},
|
||||
DataDir: tempDir,
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
validPath, err := runtime.validatePath("test.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Expected relative path to be valid, got error: %v", err)
|
||||
}
|
||||
if validPath == "" {
|
||||
t.Error("Expected non-empty path")
|
||||
}
|
||||
|
||||
_, err = runtime.validatePath("../../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("Expected path traversal to be blocked")
|
||||
}
|
||||
|
||||
nestedPath, err := runtime.validatePath("subdir/file.txt")
|
||||
if err != nil {
|
||||
t.Errorf("Expected nested path to be valid, got error: %v", err)
|
||||
}
|
||||
if nestedPath == "" {
|
||||
t.Error("Expected non-empty nested path")
|
||||
}
|
||||
|
||||
var absPath string
|
||||
if filepath.IsAbs("C:\\Windows\\System32") {
|
||||
absPath = "C:\\Windows\\System32\\test.txt"
|
||||
} else {
|
||||
absPath = "/etc/passwd"
|
||||
}
|
||||
_, err = runtime.validatePath(absPath)
|
||||
if err == nil {
|
||||
t.Error("Expected absolute path to be blocked")
|
||||
}
|
||||
|
||||
extNoFile := &LoadedExtension{
|
||||
ID: "test-ext-no-file",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext-no-file",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: false,
|
||||
},
|
||||
},
|
||||
DataDir: tempDir,
|
||||
}
|
||||
runtimeNoFile := NewExtensionRuntime(extNoFile)
|
||||
_, err = runtimeNoFile.validatePath("test.txt")
|
||||
if err == nil {
|
||||
t.Error("Expected file access to be denied without file permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
vm := goja.New()
|
||||
runtime.RegisterAPIs(vm)
|
||||
|
||||
result, err := vm.RunString(`utils.base64Encode("hello")`)
|
||||
if err != nil {
|
||||
t.Fatalf("base64Encode failed: %v", err)
|
||||
}
|
||||
if result.String() != "aGVsbG8=" {
|
||||
t.Errorf("Expected 'aGVsbG8=', got '%s'", result.String())
|
||||
}
|
||||
|
||||
result, err = vm.RunString(`utils.base64Decode("aGVsbG8=")`)
|
||||
if err != nil {
|
||||
t.Fatalf("base64Decode failed: %v", err)
|
||||
}
|
||||
if result.String() != "hello" {
|
||||
t.Errorf("Expected 'hello', got '%s'", result.String())
|
||||
}
|
||||
|
||||
result, err = vm.RunString(`utils.md5("hello")`)
|
||||
if err != nil {
|
||||
t.Fatalf("md5 failed: %v", err)
|
||||
}
|
||||
if result.String() != "5d41402abc4b2a76b9719d911017c592" {
|
||||
t.Errorf("Expected '5d41402abc4b2a76b9719d911017c592', got '%s'", result.String())
|
||||
}
|
||||
|
||||
result, err = vm.RunString(`utils.stringifyJSON({name: "test", value: 123})`)
|
||||
if err != nil {
|
||||
t.Fatalf("stringifyJSON failed: %v", err)
|
||||
}
|
||||
// JSON output may vary in order, just check it's valid
|
||||
if result.String() == "" {
|
||||
t.Error("Expected non-empty JSON string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
// Create extension with limited network permissions
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"api.example.com"},
|
||||
},
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
|
||||
privateIPs := []string{
|
||||
"http://localhost/admin",
|
||||
"http://127.0.0.1/admin",
|
||||
"http://192.168.1.1/admin",
|
||||
"http://10.0.0.1/admin",
|
||||
"http://172.16.0.1/admin",
|
||||
"http://169.254.169.254/latest/meta-data/", // AWS metadata
|
||||
"http://router.local/admin",
|
||||
}
|
||||
|
||||
for _, url := range privateIPs {
|
||||
err := runtime.validateDomain(url)
|
||||
if err == nil {
|
||||
t.Errorf("Expected private IP/host '%s' to be blocked", url)
|
||||
}
|
||||
}
|
||||
|
||||
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
|
||||
t.Errorf("Expected api.example.com to be allowed, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPrivateIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
host string
|
||||
expected bool
|
||||
}{
|
||||
{"localhost", true},
|
||||
{"127.0.0.1", true},
|
||||
{"127.0.0.2", true},
|
||||
{"10.0.0.1", true},
|
||||
{"10.255.255.255", true},
|
||||
{"172.16.0.1", true},
|
||||
{"172.31.255.255", true},
|
||||
{"192.168.0.1", true},
|
||||
{"192.168.255.255", true},
|
||||
{"169.254.169.254", true},
|
||||
{"router.local", true},
|
||||
{"mydevice.local", true},
|
||||
|
||||
{"8.8.8.8", false},
|
||||
{"1.1.1.1", false},
|
||||
{"api.example.com", false},
|
||||
{"google.com", false},
|
||||
{"172.15.0.1", false},
|
||||
{"172.32.0.1", false},
|
||||
{"192.167.0.1", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := isPrivateIP(tt.host)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isPrivateIP(%s) = %v, expected %v", tt.host, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Package gobackend provides timeout execution for extension JS code
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type JSExecutionError struct {
|
||||
Message string
|
||||
IsTimeout bool
|
||||
}
|
||||
|
||||
func (e *JSExecutionError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultJSTimeout
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
type result struct {
|
||||
value goja.Value
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
|
||||
var interrupted bool
|
||||
var interruptMu sync.Mutex
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
interruptMu.Lock()
|
||||
wasInterrupted := interrupted
|
||||
interruptMu.Unlock()
|
||||
|
||||
if wasInterrupted {
|
||||
resultCh <- result{nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded",
|
||||
IsTimeout: true,
|
||||
}}
|
||||
} else {
|
||||
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
||||
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
val, err := vm.RunString(script)
|
||||
resultCh <- result{val, err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
return res.value, res.err
|
||||
case <-ctx.Done():
|
||||
interruptMu.Lock()
|
||||
interrupted = true
|
||||
interruptMu.Unlock()
|
||||
|
||||
vm.Interrupt("execution timeout")
|
||||
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
return nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded",
|
||||
IsTimeout: true,
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
return nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded (force)",
|
||||
IsTimeout: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after
|
||||
// This should be used when you want to continue using the VM after a timeout
|
||||
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
result, err := RunWithTimeout(vm, script, timeout)
|
||||
|
||||
// Clear any interrupt state so VM can be reused
|
||||
vm.ClearInterrupt()
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func IsTimeoutError(err error) bool {
|
||||
if jsErr, ok := err.(*JSExecutionError); ok {
|
||||
return jsErr.IsTimeout
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -6,28 +6,21 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Invalid filename characters for Android/Windows/Linux
|
||||
var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||
|
||||
// sanitizeFilename removes invalid characters from filename
|
||||
func sanitizeFilename(filename string) string {
|
||||
// Replace invalid characters with underscore
|
||||
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
||||
|
||||
// Remove leading/trailing spaces and dots
|
||||
sanitized = strings.TrimSpace(sanitized)
|
||||
sanitized = strings.Trim(sanitized, ".")
|
||||
|
||||
// Collapse multiple underscores
|
||||
multiUnderscore := regexp.MustCompile(`_+`)
|
||||
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
||||
|
||||
// Limit length (Android has 255 byte limit for filenames)
|
||||
if len(sanitized) > 200 {
|
||||
sanitized = sanitized[:200]
|
||||
}
|
||||
|
||||
// Ensure not empty
|
||||
if sanitized == "" {
|
||||
sanitized = "untitled"
|
||||
}
|
||||
@@ -35,7 +28,6 @@ func sanitizeFilename(filename string) string {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// buildFilenameFromTemplate builds a filename from template and metadata
|
||||
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
|
||||
if template == "" {
|
||||
template = "{artist} - {title}"
|
||||
@@ -43,7 +35,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
||||
|
||||
result := template
|
||||
|
||||
// Replace placeholders
|
||||
placeholders := map[string]string{
|
||||
"{title}": getString(metadata, "title"),
|
||||
"{artist}": getString(metadata, "artist"),
|
||||
@@ -63,7 +54,7 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
@@ -97,7 +88,6 @@ func formatDiscNumber(n int) string {
|
||||
return fmt.Sprintf("%d", n)
|
||||
}
|
||||
|
||||
// extractYear extracts year from date string (YYYY-MM-DD or YYYY)
|
||||
func extractYear(date string) string {
|
||||
if len(date) >= 4 {
|
||||
return date[:4]
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
module github.com/zarz/spotiflac_android/go_backend
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.24.5
|
||||
toolchain go1.26.0
|
||||
|
||||
require (
|
||||
github.com/go-flac/flacpicture v0.3.0
|
||||
github.com/go-flac/flacvorbis v0.2.0
|
||||
github.com/go-flac/go-flac v1.0.0
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||
github.com/go-flac/go-flac/v2 v2.0.4
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af
|
||||
golang.org/x/net v0.50.0
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||
github.com/klauspost/compress v1.17.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,14 +1,64 @@
|
||||
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
|
||||
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
|
||||
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
|
||||
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
|
||||
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
|
||||
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294 h1:Cr6kbEvA6nqvdHynE4CtVKlqpZB9dS1Jva/6IsHA19g=
|
||||
golang.org/x/mobile v0.0.0-20251209145715-2553ed8ce294/go.mod h1:RdZ+3sb4CVgpCFnzv+I4haEpwqFfsfzlLHs3L7ok+e0=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
|
||||
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
|
||||
github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
|
||||
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
|
||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,57 +1,128 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTP utility functions for consistent request handling across all downloaders
|
||||
|
||||
// User-Agent pool for Android Chrome browsers
|
||||
var userAgentTemplates = []string{
|
||||
"Mozilla/5.0 (Linux; Android %d; SM-G%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (Linux; Android %d; Pixel %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (Linux; Android %d; SM-A%d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (Linux; Android %d; Redmi Note %d) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
||||
}
|
||||
|
||||
// getRandomUserAgent generates a random browser-like User-Agent string (Android Chrome format)
|
||||
func getRandomUserAgent() string {
|
||||
template := userAgentTemplates[rand.Intn(len(userAgentTemplates))]
|
||||
|
||||
androidVersion := rand.Intn(5) + 10 // Android 10-14
|
||||
deviceModel := rand.Intn(900) + 100 // Random model number
|
||||
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
||||
chromeBuild := rand.Intn(5000) + 5000
|
||||
chromeVersion := rand.Intn(26) + 120
|
||||
chromeBuild := rand.Intn(1500) + 6000
|
||||
chromePatch := rand.Intn(200) + 100
|
||||
|
||||
return fmt.Sprintf(template, androidVersion, deviceModel, chromeVersion, chromeBuild, chromePatch)
|
||||
return fmt.Sprintf(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
||||
chromeVersion,
|
||||
chromeBuild,
|
||||
chromePatch,
|
||||
)
|
||||
}
|
||||
|
||||
// Default timeout values
|
||||
const (
|
||||
DefaultTimeout = 60 * time.Second // Default HTTP timeout
|
||||
DownloadTimeout = 120 * time.Second // Timeout for file downloads
|
||||
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
|
||||
DefaultMaxRetries = 3 // Default retry count
|
||||
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
||||
DefaultTimeout = 60 * time.Second
|
||||
DownloadTimeout = 120 * time.Second
|
||||
SongLinkTimeout = 30 * time.Second
|
||||
DefaultMaxRetries = 3
|
||||
DefaultRetryDelay = 1 * time.Second
|
||||
Second = time.Second
|
||||
)
|
||||
|
||||
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
|
||||
var sharedTransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
MaxConnsPerHost: 20,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
ForceAttemptHTTP2: true,
|
||||
WriteBufferSize: 64 * 1024,
|
||||
ReadBufferSize: 64 * 1024,
|
||||
DisableCompression: true,
|
||||
}
|
||||
|
||||
// metadataTransport is a separate transport for metadata API calls (Deezer, Spotify, SongLink).
|
||||
// Isolated from download traffic so that download failures cannot poison
|
||||
// the connection pool used by metadata enrichment.
|
||||
var metadataTransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 30,
|
||||
MaxIdleConnsPerHost: 5,
|
||||
MaxConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
ForceAttemptHTTP2: true,
|
||||
WriteBufferSize: 32 * 1024,
|
||||
ReadBufferSize: 32 * 1024,
|
||||
DisableCompression: true,
|
||||
}
|
||||
|
||||
var sharedClient = &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Timeout: DefaultTimeout,
|
||||
}
|
||||
|
||||
var downloadClient = &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Timeout: DownloadTimeout,
|
||||
}
|
||||
|
||||
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: sharedTransport,
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// DoRequestWithUserAgent executes an HTTP request with a random User-Agent header
|
||||
// NewMetadataHTTPClient creates an HTTP client using the isolated metadata transport.
|
||||
// Use this for API calls that should not be affected by download traffic.
|
||||
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: metadataTransport,
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
func GetSharedClient() *http.Client {
|
||||
return sharedClient
|
||||
}
|
||||
|
||||
func GetDownloadClient() *http.Client {
|
||||
return downloadClient
|
||||
}
|
||||
|
||||
func CloseIdleConnections() {
|
||||
sharedTransport.CloseIdleConnections()
|
||||
metadataTransport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
// Also checks for ISP blocking on errors
|
||||
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
return client.Do(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// RetryConfig holds configuration for retry logic
|
||||
@@ -62,7 +133,6 @@ type RetryConfig struct {
|
||||
BackoffFactor float64
|
||||
}
|
||||
|
||||
// DefaultRetryConfig returns default retry configuration
|
||||
func DefaultRetryConfig() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxRetries: DefaultMaxRetries,
|
||||
@@ -72,33 +142,36 @@ func DefaultRetryConfig() RetryConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// DoRequestWithRetry executes an HTTP request with retry logic and exponential backoff
|
||||
// Handles 429 (Too Many Requests) responses with Retry-After header
|
||||
func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConfig) (*http.Response, error) {
|
||||
var lastErr error
|
||||
delay := config.InitialDelay
|
||||
requestURL := req.URL.String()
|
||||
|
||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||
// Clone request for retry (body needs to be re-readable)
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := client.Do(reqCopy)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
|
||||
if CheckAndLogISPBlocking(err, requestURL, "HTTP") {
|
||||
return nil, WrapErrorWithISPCheck(err, requestURL, "HTTP")
|
||||
}
|
||||
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
|
||||
attempt+1, config.MaxRetries+1, err, delay)
|
||||
time.Sleep(delay)
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Success
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Handle rate limiting (429)
|
||||
if resp.StatusCode == 429 {
|
||||
resp.Body.Close()
|
||||
retryAfter := getRetryAfterDuration(resp)
|
||||
@@ -107,40 +180,59 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
}
|
||||
lastErr = fmt.Errorf("rate limited (429)")
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Rate limited, waiting %v before retry...\n", delay)
|
||||
time.Sleep(delay)
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Server errors (5xx) - retry
|
||||
// Check for ISP blocking via HTTP status codes
|
||||
// Some ISPs return 403 or 451 when blocking content
|
||||
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
bodyStr := strings.ToLower(string(body))
|
||||
|
||||
// Check if response looks like ISP blocking page
|
||||
ispBlockingIndicators := []string{
|
||||
"blocked", "forbidden", "access denied", "not available in your",
|
||||
"restricted", "censored", "unavailable for legal", "blocked by",
|
||||
}
|
||||
|
||||
for _, indicator := range ispBlockingIndicators {
|
||||
if strings.Contains(bodyStr, indicator) {
|
||||
LogError("HTTP", "ISP BLOCKING DETECTED via HTTP %d response", resp.StatusCode)
|
||||
LogError("HTTP", "Domain: %s", req.URL.Host)
|
||||
LogError("HTTP", "Response contains: %s", indicator)
|
||||
LogError("HTTP", "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
|
||||
return nil, fmt.Errorf("ISP blocking detected for %s (HTTP %d) - try using VPN or change DNS", req.URL.Host, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Server error %d, retrying in %v...\n", resp.StatusCode, delay)
|
||||
time.Sleep(delay)
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Client errors (4xx except 429) - don't retry
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
// calculateNextDelay calculates the next delay with exponential backoff
|
||||
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
|
||||
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
|
||||
if nextDelay > config.MaxDelay {
|
||||
nextDelay = config.MaxDelay
|
||||
}
|
||||
return nextDelay
|
||||
return min(nextDelay, config.MaxDelay)
|
||||
}
|
||||
|
||||
// getRetryAfterDuration parses Retry-After header and returns duration
|
||||
// Returns 60 seconds as default if header is missing or invalid
|
||||
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
retryAfter := resp.Header.Get("Retry-After")
|
||||
@@ -148,12 +240,10 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
return 60 * time.Second // Default wait time
|
||||
}
|
||||
|
||||
// Try parsing as seconds
|
||||
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
// Try parsing as HTTP date
|
||||
if t, err := http.ParseTime(retryAfter); err == nil {
|
||||
duration := time.Until(t)
|
||||
if duration > 0 {
|
||||
@@ -164,8 +254,6 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
return 60 * time.Second // Default
|
||||
}
|
||||
|
||||
// ReadResponseBody reads and returns the response body
|
||||
// Returns error if body is empty
|
||||
func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||
if resp == nil {
|
||||
return nil, fmt.Errorf("response is nil")
|
||||
@@ -183,7 +271,6 @@ func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// ValidateResponse checks if response is valid (non-nil, status 2xx)
|
||||
func ValidateResponse(resp *http.Response) error {
|
||||
if resp == nil {
|
||||
return fmt.Errorf("response is nil")
|
||||
@@ -196,14 +283,12 @@ func ValidateResponse(resp *http.Response) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildErrorMessage creates a detailed error message for API failures
|
||||
func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string {
|
||||
msg := fmt.Sprintf("API %s failed", apiURL)
|
||||
if statusCode > 0 {
|
||||
msg += fmt.Sprintf(" (HTTP %d)", statusCode)
|
||||
}
|
||||
if responsePreview != "" {
|
||||
// Truncate preview if too long
|
||||
if len(responsePreview) > 100 {
|
||||
responsePreview = responsePreview[:100] + "..."
|
||||
}
|
||||
@@ -211,3 +296,161 @@ func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) st
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
type ISPBlockingError struct {
|
||||
Domain string
|
||||
Reason string
|
||||
OriginalErr error
|
||||
}
|
||||
|
||||
func (e *ISPBlockingError) Error() string {
|
||||
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
||||
}
|
||||
|
||||
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
domain := extractDomain(requestURL)
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "DNS resolution failed - domain may be blocked by ISP",
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
if opErr.Op == "dial" {
|
||||
var syscallErr syscall.Errno
|
||||
if errors.As(opErr.Err, &syscallErr) {
|
||||
switch syscallErr {
|
||||
case syscall.ECONNREFUSED:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Connection refused - port may be blocked by ISP/firewall",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.ECONNRESET:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Connection reset - ISP may be intercepting traffic",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.ETIMEDOUT:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Connection timed out - ISP may be blocking access",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.ENETUNREACH:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Network unreachable - ISP may be blocking route",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.EHOSTUNREACH:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Host unreachable - ISP may be blocking destination",
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tlsErr *tls.RecordHeaderError
|
||||
if errors.As(err, &tlsErr) {
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Check error message patterns for common ISP blocking indicators
|
||||
blockingPatterns := []struct {
|
||||
pattern string
|
||||
reason string
|
||||
}{
|
||||
{"connection reset by peer", "Connection reset - ISP may be intercepting traffic"},
|
||||
{"connection refused", "Connection refused - port may be blocked"},
|
||||
{"no such host", "DNS lookup failed - domain may be blocked by ISP"},
|
||||
{"i/o timeout", "Connection timed out - ISP may be blocking access"},
|
||||
{"network is unreachable", "Network unreachable - ISP may be blocking route"},
|
||||
{"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"},
|
||||
{"certificate", "Certificate error - ISP may be using MITM proxy"},
|
||||
{"eof", "Connection closed unexpectedly - ISP may be blocking"},
|
||||
{"context deadline exceeded", "Request timed out - ISP may be throttling"},
|
||||
}
|
||||
|
||||
for _, bp := range blockingPatterns {
|
||||
if strings.Contains(errStr, bp.pattern) {
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: bp.reason,
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Returns true if ISP blocking was detected
|
||||
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||
ispErr := IsISPBlocking(err, requestURL)
|
||||
if ispErr != nil {
|
||||
LogError(tag, "ISP BLOCKING DETECTED: %s", ispErr.Error())
|
||||
LogError(tag, "Domain: %s", ispErr.Domain)
|
||||
LogError(tag, "Reason: %s", ispErr.Reason)
|
||||
LogError(tag, "Original error: %v", ispErr.OriginalErr)
|
||||
LogError(tag, "Suggestion: Try using a VPN or changing your DNS to 1.1.1.1 or 8.8.8.8")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractDomain extracts the domain from a URL string
|
||||
func extractDomain(rawURL string) string {
|
||||
if rawURL == "" {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
rawURL = strings.TrimPrefix(rawURL, "https://")
|
||||
rawURL = strings.TrimPrefix(rawURL, "http://")
|
||||
if idx := strings.Index(rawURL, "/"); idx > 0 {
|
||||
return rawURL[:idx]
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
if parsed.Host != "" {
|
||||
return parsed.Host
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// If ISP blocking is detected, returns a more descriptive error
|
||||
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if CheckAndLogISPBlocking(err, requestURL, tag) {
|
||||
domain := extractDomain(requestURL)
|
||||
return fmt.Errorf("ISP blocking detected for %s - try using VPN or change DNS to 1.1.1.1/8.8.8.8: %w", domain, err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build ios
|
||||
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// iOS version: uTLS is not supported on iOS due to cgo DNS resolver issues
|
||||
// Fall back to standard HTTP client
|
||||
|
||||
// GetCloudflareBypassClient returns the standard HTTP client on iOS
|
||||
// uTLS is not available on iOS due to cgo DNS resolver compatibility issues
|
||||
func GetCloudflareBypassClient() *http.Client {
|
||||
return sharedClient
|
||||
}
|
||||
|
||||
// DoRequestWithCloudflareBypass on iOS just uses the standard client
|
||||
// uTLS Chrome fingerprint bypass is not available on iOS
|
||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
resp, err := sharedClient.Do(req)
|
||||
if err != nil {
|
||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//go:build !ios
|
||||
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
utls "github.com/refraction-networking/utls"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
// uTLS transport that mimics Chrome's TLS fingerprint to bypass Cloudflare
|
||||
// Uses HTTP/2 for optimal performance as uTLS works best with HTTP/2
|
||||
type utlsTransport struct {
|
||||
dialer *net.Dialer
|
||||
mu sync.Mutex
|
||||
h2Transports map[string]*http2.Transport
|
||||
}
|
||||
|
||||
func newUTLSTransport() *utlsTransport {
|
||||
return &utlsTransport{
|
||||
dialer: &net.Dialer{
|
||||
Timeout: 30 * Second,
|
||||
KeepAlive: 30 * Second,
|
||||
},
|
||||
h2Transports: make(map[string]*http2.Transport),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Scheme != "https" {
|
||||
return sharedTransport.RoundTrip(req)
|
||||
}
|
||||
|
||||
host := req.URL.Hostname()
|
||||
port := t.getPort(req.URL)
|
||||
addr := net.JoinHostPort(host, port)
|
||||
|
||||
conn, err := t.dialer.DialContext(req.Context(), "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tlsConn := utls.UClient(conn, &utls.Config{
|
||||
ServerName: host,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
}, utls.HelloChrome_Auto)
|
||||
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
negotiatedProto := tlsConn.ConnectionState().NegotiatedProtocol
|
||||
|
||||
if negotiatedProto == "h2" {
|
||||
h2Transport := &http2.Transport{
|
||||
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
|
||||
return tlsConn, nil
|
||||
},
|
||||
AllowHTTP: false,
|
||||
DisableCompression: false,
|
||||
}
|
||||
return h2Transport.RoundTrip(req)
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return tlsConn, nil
|
||||
},
|
||||
DisableKeepAlives: true,
|
||||
}
|
||||
|
||||
return transport.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (t *utlsTransport) getPort(u *url.URL) string {
|
||||
if u.Port() != "" {
|
||||
return u.Port()
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
return "443"
|
||||
}
|
||||
return "80"
|
||||
}
|
||||
|
||||
// Cloudflare bypass client using uTLS Chrome fingerprint
|
||||
var cloudflareBypassTransport = newUTLSTransport()
|
||||
|
||||
var cloudflareBypassClient = &http.Client{
|
||||
Transport: cloudflareBypassTransport,
|
||||
Timeout: DefaultTimeout,
|
||||
}
|
||||
|
||||
// GetCloudflareBypassClient returns an HTTP client that mimics Chrome's TLS fingerprint
|
||||
// Use this when requests are blocked by Cloudflare (common when using VPN)
|
||||
func GetCloudflareBypassClient() *http.Client {
|
||||
return cloudflareBypassClient
|
||||
}
|
||||
|
||||
// DoRequestWithCloudflareBypass attempts request with standard client first,
|
||||
// then retries with uTLS Chrome fingerprint if Cloudflare blocks it.
|
||||
// This is useful when using VPN as Cloudflare detects Go's default TLS fingerprint.
|
||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
// Try with standard client first
|
||||
resp, err := sharedClient.Do(req)
|
||||
if err == nil {
|
||||
// Check for Cloudflare challenge page (403 with specific markers)
|
||||
if resp.StatusCode == 403 || resp.StatusCode == 503 {
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if readErr == nil {
|
||||
bodyStr := strings.ToLower(string(body))
|
||||
cloudflareMarkers := []string{
|
||||
"cloudflare", "cf-ray", "checking your browser",
|
||||
"please wait", "ddos protection", "ray id",
|
||||
"enable javascript", "challenge-platform",
|
||||
}
|
||||
|
||||
isCloudflare := false
|
||||
for _, marker := range cloudflareMarkers {
|
||||
if strings.Contains(bodyStr, marker) {
|
||||
isCloudflare = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isCloudflare {
|
||||
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
|
||||
|
||||
// Clone request for retry
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
// Retry with uTLS Chrome fingerprint
|
||||
return cloudflareBypassClient.Do(reqCopy)
|
||||
}
|
||||
}
|
||||
|
||||
// Not Cloudflare, return original response (recreate body)
|
||||
return &http.Response{
|
||||
Status: resp.Status,
|
||||
StatusCode: resp.StatusCode,
|
||||
Header: resp.Header,
|
||||
Body: io.NopCloser(strings.NewReader(string(body))),
|
||||
}, nil
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Check if error might be TLS-related (Cloudflare blocking)
|
||||
errStr := strings.ToLower(err.Error())
|
||||
tlsRelated := strings.Contains(errStr, "tls") ||
|
||||
strings.Contains(errStr, "handshake") ||
|
||||
strings.Contains(errStr, "certificate") ||
|
||||
strings.Contains(errStr, "connection reset")
|
||||
|
||||
if tlsRelated {
|
||||
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
||||
|
||||
// Clone request for retry
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
// Retry with uTLS Chrome fingerprint
|
||||
return cloudflareBypassClient.Do(reqCopy)
|
||||
}
|
||||
|
||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||
return nil, err
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IDHSClient is a client for I Don't Have Spotify API
|
||||
// Used as fallback when SongLink fails or is rate limited
|
||||
type IDHSClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
var (
|
||||
globalIDHSClient *IDHSClient
|
||||
idhsClientOnce sync.Once
|
||||
idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit)
|
||||
)
|
||||
|
||||
// IDHSSearchRequest represents the request body for IDHS API
|
||||
type IDHSSearchRequest struct {
|
||||
Link string `json:"link"`
|
||||
Adapters []string `json:"adapters,omitempty"`
|
||||
}
|
||||
|
||||
// IDHSSearchResponse represents the response from IDHS API
|
||||
type IDHSSearchResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // song, album, artist, podcast, show
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Audio string `json:"audio,omitempty"`
|
||||
Source string `json:"source"`
|
||||
UniversalLink string `json:"universalLink"`
|
||||
Links []IDHSLink `json:"links"`
|
||||
}
|
||||
|
||||
// IDHSLink represents a link to a streaming platform
|
||||
type IDHSLink struct {
|
||||
Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal
|
||||
URL string `json:"url"`
|
||||
IsVerified bool `json:"isVerified,omitempty"`
|
||||
NotAvailable bool `json:"notAvailable,omitempty"`
|
||||
}
|
||||
|
||||
// NewIDHSClient creates a new IDHS client
|
||||
func NewIDHSClient() *IDHSClient {
|
||||
idhsClientOnce.Do(func() {
|
||||
globalIDHSClient = &IDHSClient{
|
||||
client: NewHTTPClientWithTimeout(15 * time.Second),
|
||||
}
|
||||
})
|
||||
return globalIDHSClient
|
||||
}
|
||||
|
||||
// Search converts a music link to links on other platforms
|
||||
func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) {
|
||||
idhsRateLimiter.WaitForSlot()
|
||||
|
||||
reqBody := IDHSSearchRequest{
|
||||
Link: link,
|
||||
Adapters: adapters,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "https://idonthavespotify.sjdonado.com/api/search?v=1", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 400 {
|
||||
return nil, fmt.Errorf("invalid link or missing parameters")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("IDHS rate limit exceeded")
|
||||
}
|
||||
if resp.StatusCode == 500 {
|
||||
return nil, fmt.Errorf("IDHS processing failed")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("IDHS API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var result IDHSSearchResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetAvailabilityFromSpotify checks track availability using IDHS as fallback
|
||||
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||
|
||||
// Request only the platforms we need
|
||||
adapters := []string{"tidal", "deezer"}
|
||||
|
||||
result, err := c.Search(spotifyURL, adapters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{
|
||||
SpotifyID: spotifyTrackID,
|
||||
}
|
||||
|
||||
for _, link := range result.Links {
|
||||
if link.NotAvailable {
|
||||
continue
|
||||
}
|
||||
|
||||
switch strings.ToLower(link.Type) {
|
||||
case "tidal":
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = link.URL
|
||||
case "deezer":
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = link.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(link.URL)
|
||||
}
|
||||
}
|
||||
|
||||
LogDebug("IDHS", "Availability from Spotify %s: Tidal=%v, Deezer=%v",
|
||||
spotifyTrackID, availability.Tidal, availability.Deezer)
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// GetAvailabilityFromDeezer checks track availability using IDHS
|
||||
func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||
|
||||
// Request only the platforms we need
|
||||
adapters := []string{"spotify", "tidal"}
|
||||
|
||||
result, err := c.Search(deezerURL, adapters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{
|
||||
Deezer: true,
|
||||
DeezerID: deezerTrackID,
|
||||
}
|
||||
|
||||
for _, link := range result.Links {
|
||||
if link.NotAvailable {
|
||||
continue
|
||||
}
|
||||
|
||||
switch strings.ToLower(link.Type) {
|
||||
case "spotify":
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(link.URL)
|
||||
case "tidal":
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = link.URL
|
||||
}
|
||||
}
|
||||
|
||||
LogDebug("IDHS", "Availability from Deezer %s: Spotify=%s, Tidal=%v",
|
||||
deezerTrackID, availability.SpotifyID, availability.Tidal)
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LibraryScanResult represents metadata from a scanned audio file
|
||||
type LibraryScanResult struct {
|
||||
ID string `json:"id"`
|
||||
TrackName string `json:"trackName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
AlbumArtist string `json:"albumArtist,omitempty"`
|
||||
FilePath string `json:"filePath"`
|
||||
CoverPath string `json:"coverPath,omitempty"`
|
||||
ScannedAt string `json:"scannedAt"`
|
||||
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
TrackNumber int `json:"trackNumber,omitempty"`
|
||||
DiscNumber int `json:"discNumber,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||
BitDepth int `json:"bitDepth,omitempty"`
|
||||
SampleRate int `json:"sampleRate,omitempty"`
|
||||
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
}
|
||||
|
||||
type LibraryScanProgress struct {
|
||||
TotalFiles int `json:"total_files"`
|
||||
ScannedFiles int `json:"scanned_files"`
|
||||
CurrentFile string `json:"current_file"`
|
||||
ErrorCount int `json:"error_count"`
|
||||
ProgressPct float64 `json:"progress_pct"`
|
||||
IsComplete bool `json:"is_complete"`
|
||||
}
|
||||
|
||||
// IncrementalScanResult contains results of an incremental library scan
|
||||
type IncrementalScanResult struct {
|
||||
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
|
||||
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
|
||||
SkippedCount int `json:"skippedCount"` // Files that were unchanged
|
||||
TotalFiles int `json:"totalFiles"` // Total files in folder
|
||||
}
|
||||
|
||||
var (
|
||||
libraryScanProgress LibraryScanProgress
|
||||
libraryScanProgressMu sync.RWMutex
|
||||
libraryScanCancel chan struct{}
|
||||
libraryScanCancelMu sync.Mutex
|
||||
libraryCoverCacheDir string
|
||||
libraryCoverCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
var supportedAudioFormats = map[string]bool{
|
||||
".flac": true,
|
||||
".m4a": true,
|
||||
".mp3": true,
|
||||
".opus": true,
|
||||
".ogg": true,
|
||||
}
|
||||
|
||||
func SetLibraryCoverCacheDir(cacheDir string) {
|
||||
libraryCoverCacheMu.Lock()
|
||||
libraryCoverCacheDir = cacheDir
|
||||
libraryCoverCacheMu.Unlock()
|
||||
}
|
||||
|
||||
func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
if folderPath == "" {
|
||||
return "[]", fmt.Errorf("folder path is empty")
|
||||
}
|
||||
|
||||
info, err := os.Stat(folderPath)
|
||||
if err != nil {
|
||||
return "[]", fmt.Errorf("folder not found: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "[]", fmt.Errorf("path is not a folder: %s", folderPath)
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress = LibraryScanProgress{}
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
libraryScanCancelMu.Lock()
|
||||
if libraryScanCancel != nil {
|
||||
close(libraryScanCancel)
|
||||
}
|
||||
libraryScanCancel = make(chan struct{})
|
||||
cancelCh := libraryScanCancel
|
||||
libraryScanCancelMu.Unlock()
|
||||
|
||||
var audioFiles []string
|
||||
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return fmt.Errorf("scan cancelled")
|
||||
default:
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if supportedAudioFormats[ext] {
|
||||
audioFiles = append(audioFiles, path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
|
||||
totalFiles := len(audioFiles)
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.TotalFiles = totalFiles
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
if totalFiles == 0 {
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.IsComplete = true
|
||||
libraryScanProgressMu.Unlock()
|
||||
return "[]", nil
|
||||
}
|
||||
|
||||
GoLog("[LibraryScan] Found %d audio files to scan\n", totalFiles)
|
||||
|
||||
results := make([]LibraryScanResult, 0, totalFiles)
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
errorCount := 0
|
||||
|
||||
for i, filePath := range audioFiles {
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return "[]", fmt.Errorf("scan cancelled")
|
||||
default:
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ScannedFiles = i + 1
|
||||
libraryScanProgress.CurrentFile = filepath.Base(filePath)
|
||||
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
result, err := scanAudioFile(filePath, scanTime)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, *result)
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ErrorCount = errorCount
|
||||
libraryScanProgress.IsComplete = true
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
GoLog("[LibraryScan] Scan complete: %d tracks found, %d errors\n", len(results), errorCount)
|
||||
|
||||
jsonBytes, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "[]", fmt.Errorf("failed to marshal results: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
result := &LibraryScanResult{
|
||||
ID: generateLibraryID(filePath),
|
||||
FilePath: filePath,
|
||||
ScannedAt: scanTime,
|
||||
Format: strings.TrimPrefix(ext, "."),
|
||||
}
|
||||
|
||||
// Get file modification time
|
||||
if info, err := os.Stat(filePath); err == nil {
|
||||
result.FileModTime = info.ModTime().UnixMilli()
|
||||
}
|
||||
|
||||
libraryCoverCacheMu.RLock()
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
libraryCoverCacheMu.RUnlock()
|
||||
if coverCacheDir != "" && ext != ".m4a" {
|
||||
coverPath, err := SaveCoverToCache(filePath, coverCacheDir)
|
||||
if err == nil && coverPath != "" {
|
||||
result.CoverPath = coverPath
|
||||
}
|
||||
}
|
||||
|
||||
switch ext {
|
||||
case ".flac":
|
||||
return scanFLACFile(filePath, result)
|
||||
case ".m4a":
|
||||
return scanM4AFile(filePath, result)
|
||||
case ".mp3":
|
||||
return scanMP3File(filePath, result)
|
||||
case ".opus", ".ogg":
|
||||
return scanOggFile(filePath, result)
|
||||
default:
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
}
|
||||
|
||||
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.ReleaseDate = metadata.Date
|
||||
result.Genre = metadata.Genre
|
||||
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||
result.Duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||
}
|
||||
}
|
||||
|
||||
if result.TrackName == "" {
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
}
|
||||
if result.ArtistName == "" {
|
||||
result.ArtistName = "Unknown Artist"
|
||||
}
|
||||
if result.AlbumName == "" {
|
||||
result.AlbumName = "Unknown Album"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
quality, err := GetM4AQuality(filePath)
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadID3Tags(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.Genre = metadata.Genre
|
||||
if metadata.Date != "" {
|
||||
result.ReleaseDate = metadata.Date
|
||||
} else {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.ISRC = metadata.ISRC
|
||||
|
||||
quality, err := GetMP3Quality(filePath)
|
||||
if err == nil {
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.BitDepth = quality.BitDepth // 0 for lossy
|
||||
result.Duration = quality.Duration
|
||||
if quality.Bitrate > 0 {
|
||||
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
|
||||
}
|
||||
}
|
||||
|
||||
if result.TrackName == "" {
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
}
|
||||
if result.ArtistName == "" {
|
||||
result.ArtistName = "Unknown Artist"
|
||||
}
|
||||
if result.AlbumName == "" {
|
||||
result.AlbumName = "Unknown Album"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadOggVorbisComments(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] Ogg/Opus read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, result)
|
||||
}
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.Genre = metadata.Genre
|
||||
result.ReleaseDate = metadata.Date
|
||||
|
||||
quality, err := GetOggQuality(filePath)
|
||||
if err == nil {
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.BitDepth = quality.BitDepth // 0 for lossy
|
||||
result.Duration = quality.Duration
|
||||
if quality.Bitrate > 0 {
|
||||
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
|
||||
}
|
||||
}
|
||||
|
||||
if result.TrackName == "" {
|
||||
result.TrackName = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
}
|
||||
if result.ArtistName == "" {
|
||||
result.ArtistName = "Unknown Artist"
|
||||
}
|
||||
if result.AlbumName == "" {
|
||||
result.AlbumName = "Unknown Album"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanFromFilename(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
|
||||
filename := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
|
||||
|
||||
parts := strings.SplitN(filename, " - ", 2)
|
||||
if len(parts) == 2 {
|
||||
if len(parts[0]) <= 3 && isNumeric(parts[0]) {
|
||||
result.TrackName = parts[1]
|
||||
result.ArtistName = "Unknown Artist"
|
||||
} else {
|
||||
result.ArtistName = parts[0]
|
||||
result.TrackName = parts[1]
|
||||
}
|
||||
} else {
|
||||
if len(filename) > 3 && isNumeric(filename[:2]) {
|
||||
title := strings.TrimLeft(filename[2:], " .-")
|
||||
result.TrackName = title
|
||||
} else {
|
||||
result.TrackName = filename
|
||||
}
|
||||
result.ArtistName = "Unknown Artist"
|
||||
}
|
||||
|
||||
dir := filepath.Dir(filePath)
|
||||
result.AlbumName = filepath.Base(dir)
|
||||
if result.AlbumName == "." || result.AlbumName == "" {
|
||||
result.AlbumName = "Unknown Album"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func isNumeric(s string) bool {
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(s) > 0
|
||||
}
|
||||
|
||||
func generateLibraryID(filePath string) string {
|
||||
return fmt.Sprintf("lib_%x", hashString(filePath))
|
||||
}
|
||||
|
||||
func hashString(s string) uint32 {
|
||||
var hash uint32 = 5381
|
||||
for _, c := range s {
|
||||
hash = ((hash << 5) + hash) + uint32(c)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
func GetLibraryScanProgress() string {
|
||||
libraryScanProgressMu.RLock()
|
||||
defer libraryScanProgressMu.RUnlock()
|
||||
|
||||
jsonBytes, _ := json.Marshal(libraryScanProgress)
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func CancelLibraryScan() {
|
||||
libraryScanCancelMu.Lock()
|
||||
defer libraryScanCancelMu.Unlock()
|
||||
|
||||
if libraryScanCancel != nil {
|
||||
close(libraryScanCancel)
|
||||
libraryScanCancel = nil
|
||||
}
|
||||
}
|
||||
|
||||
func ReadAudioMetadata(filePath string) (string, error) {
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
result, err := scanAudioFile(filePath, scanTime)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
|
||||
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
|
||||
// Only files that are new or have changed modification time will be scanned
|
||||
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
|
||||
if folderPath == "" {
|
||||
return "{}", fmt.Errorf("folder path is empty")
|
||||
}
|
||||
|
||||
info, err := os.Stat(folderPath)
|
||||
if err != nil {
|
||||
return "{}", fmt.Errorf("folder not found: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
|
||||
}
|
||||
|
||||
// Parse existing files map
|
||||
existingFiles := make(map[string]int64)
|
||||
if existingFilesJSON != "" && existingFilesJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
|
||||
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
|
||||
|
||||
// Reset progress
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress = LibraryScanProgress{}
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
// Setup cancellation
|
||||
libraryScanCancelMu.Lock()
|
||||
if libraryScanCancel != nil {
|
||||
close(libraryScanCancel)
|
||||
}
|
||||
libraryScanCancel = make(chan struct{})
|
||||
cancelCh := libraryScanCancel
|
||||
libraryScanCancelMu.Unlock()
|
||||
|
||||
// Collect all audio files with their mod times
|
||||
type fileInfo struct {
|
||||
path string
|
||||
modTime int64
|
||||
}
|
||||
var currentFiles []fileInfo
|
||||
currentPathSet := make(map[string]bool)
|
||||
|
||||
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return fmt.Errorf("scan cancelled")
|
||||
default:
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if supportedAudioFormats[ext] {
|
||||
currentFiles = append(currentFiles, fileInfo{
|
||||
path: path,
|
||||
modTime: info.ModTime().UnixMilli(),
|
||||
})
|
||||
currentPathSet[path] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "{}", err
|
||||
}
|
||||
|
||||
totalFiles := len(currentFiles)
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.TotalFiles = totalFiles
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
// Find files to scan (new or modified)
|
||||
var filesToScan []fileInfo
|
||||
skippedCount := 0
|
||||
|
||||
for _, f := range currentFiles {
|
||||
existingModTime, exists := existingFiles[f.path]
|
||||
if !exists {
|
||||
// New file
|
||||
filesToScan = append(filesToScan, f)
|
||||
} else if f.modTime != existingModTime {
|
||||
// Modified file
|
||||
filesToScan = append(filesToScan, f)
|
||||
} else {
|
||||
// Unchanged file - skip
|
||||
skippedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Find deleted files
|
||||
var deletedPaths []string
|
||||
for existingPath := range existingFiles {
|
||||
if !currentPathSet[existingPath] {
|
||||
deletedPaths = append(deletedPaths, existingPath)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[LibraryScan] Incremental: %d to scan, %d skipped, %d deleted\n",
|
||||
len(filesToScan), skippedCount, len(deletedPaths))
|
||||
|
||||
if len(filesToScan) == 0 {
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ScannedFiles = totalFiles
|
||||
libraryScanProgress.IsComplete = true
|
||||
libraryScanProgress.ProgressPct = 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
result := IncrementalScanResult{
|
||||
Scanned: []LibraryScanResult{},
|
||||
DeletedPaths: deletedPaths,
|
||||
SkippedCount: skippedCount,
|
||||
TotalFiles: totalFiles,
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(result)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// Scan the files that need scanning
|
||||
results := make([]LibraryScanResult, 0, len(filesToScan))
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
errorCount := 0
|
||||
|
||||
for i, f := range filesToScan {
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return "{}", fmt.Errorf("scan cancelled")
|
||||
default:
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ScannedFiles = skippedCount + i + 1
|
||||
libraryScanProgress.CurrentFile = filepath.Base(f.path)
|
||||
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
result, err := scanAudioFile(f.path, scanTime)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, *result)
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ErrorCount = errorCount
|
||||
libraryScanProgress.IsComplete = true
|
||||
libraryScanProgress.ScannedFiles = totalFiles
|
||||
libraryScanProgress.ProgressPct = 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
GoLog("[LibraryScan] Incremental scan complete: %d scanned, %d skipped, %d deleted, %d errors\n",
|
||||
len(results), skippedCount, len(deletedPaths), errorCount)
|
||||
|
||||
scanResult := IncrementalScanResult{
|
||||
Scanned: results,
|
||||
DeletedPaths: deletedPaths,
|
||||
SkippedCount: skippedCount,
|
||||
TotalFiles: totalFiles,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(scanResult)
|
||||
if err != nil {
|
||||
return "{}", fmt.Errorf("failed to marshal results: %w", err)
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LogEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Tag string `json:"tag"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type LogBuffer struct {
|
||||
entries []LogEntry
|
||||
maxSize int
|
||||
mu sync.RWMutex
|
||||
loggingEnabled bool
|
||||
}
|
||||
|
||||
const (
|
||||
defaultLogBufferSize = 500
|
||||
maxLogMessageLength = 500
|
||||
)
|
||||
|
||||
var (
|
||||
globalLogBuffer *LogBuffer
|
||||
logBufferOnce sync.Once
|
||||
|
||||
authorizationBearerPattern = regexp.MustCompile(`(?i)\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*`)
|
||||
genericKeyValuePattern = regexp.MustCompile(`(?i)\b(access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key)\b(\s*[:=]\s*)([^\s,;]+)`)
|
||||
queryTokenPattern = regexp.MustCompile(`(?i)([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password)=)[^&\s]+`)
|
||||
bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/\-]+=*`)
|
||||
)
|
||||
|
||||
func sanitizeSensitiveLogText(message string) string {
|
||||
redacted := message
|
||||
redacted = authorizationBearerPattern.ReplaceAllString(redacted, "Authorization: Bearer [REDACTED]")
|
||||
redacted = genericKeyValuePattern.ReplaceAllString(redacted, `${1}${2}[REDACTED]`)
|
||||
redacted = queryTokenPattern.ReplaceAllString(redacted, `${1}[REDACTED]`)
|
||||
redacted = bearerTokenPattern.ReplaceAllString(redacted, "Bearer [REDACTED]")
|
||||
return redacted
|
||||
}
|
||||
|
||||
func GetLogBuffer() *LogBuffer {
|
||||
logBufferOnce.Do(func() {
|
||||
globalLogBuffer = &LogBuffer{
|
||||
entries: make([]LogEntry, 0, defaultLogBufferSize),
|
||||
maxSize: defaultLogBufferSize,
|
||||
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||
}
|
||||
})
|
||||
return globalLogBuffer
|
||||
}
|
||||
|
||||
func truncateLogMessage(message string) string {
|
||||
runes := []rune(message)
|
||||
if len(runes) <= maxLogMessageLength {
|
||||
return message
|
||||
}
|
||||
return string(runes[:maxLogMessageLength]) + "...[truncated]"
|
||||
}
|
||||
|
||||
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
lb.loggingEnabled = enabled
|
||||
}
|
||||
|
||||
func (lb *LogBuffer) IsLoggingEnabled() bool {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
return lb.loggingEnabled
|
||||
}
|
||||
|
||||
func (lb *LogBuffer) Add(level, tag, message string) {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
|
||||
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
|
||||
return
|
||||
}
|
||||
|
||||
message = sanitizeSensitiveLogText(message)
|
||||
message = truncateLogMessage(message)
|
||||
|
||||
entry := LogEntry{
|
||||
Timestamp: time.Now().Format("15:04:05.000"),
|
||||
Level: level,
|
||||
Tag: tag,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
if len(lb.entries) >= lb.maxSize {
|
||||
lb.entries = lb.entries[1:]
|
||||
}
|
||||
lb.entries = append(lb.entries, entry)
|
||||
|
||||
fmt.Printf("[%s] %s\n", tag, message)
|
||||
}
|
||||
|
||||
func (lb *LogBuffer) GetAll() string {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
|
||||
jsonBytes, _ := json.Marshal(lb.entries)
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
|
||||
if index < 0 {
|
||||
index = 0
|
||||
}
|
||||
if index >= len(lb.entries) {
|
||||
return []LogEntry{}, len(lb.entries)
|
||||
}
|
||||
|
||||
entries := lb.entries[index:]
|
||||
return entries, len(lb.entries)
|
||||
}
|
||||
|
||||
func (lb *LogBuffer) Clear() {
|
||||
lb.mu.Lock()
|
||||
defer lb.mu.Unlock()
|
||||
lb.entries = lb.entries[:0]
|
||||
}
|
||||
|
||||
func (lb *LogBuffer) Count() int {
|
||||
lb.mu.RLock()
|
||||
defer lb.mu.RUnlock()
|
||||
return len(lb.entries)
|
||||
}
|
||||
|
||||
func LogDebug(tag, format string, args ...interface{}) {
|
||||
GetLogBuffer().Add("DEBUG", tag, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func LogInfo(tag, format string, args ...interface{}) {
|
||||
GetLogBuffer().Add("INFO", tag, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func LogWarn(tag, format string, args ...interface{}) {
|
||||
GetLogBuffer().Add("WARN", tag, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func LogError(tag, format string, args ...interface{}) {
|
||||
GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// GoLog is a drop-in replacement for fmt.Printf that also logs to buffer
|
||||
// It parses the tag from the format string if it starts with [Tag]
|
||||
func GoLog(format string, args ...interface{}) {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
message = strings.TrimSuffix(message, "\n")
|
||||
|
||||
// Extract tag from message if present (e.g., "[Tidal] message")
|
||||
tag := "Go"
|
||||
level := "INFO"
|
||||
|
||||
if strings.HasPrefix(message, "[") {
|
||||
endBracket := strings.Index(message, "]")
|
||||
if endBracket > 1 {
|
||||
tag = message[1:endBracket]
|
||||
message = strings.TrimSpace(message[endBracket+1:])
|
||||
}
|
||||
}
|
||||
|
||||
// Determine level from message content
|
||||
msgLower := strings.ToLower(message)
|
||||
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") {
|
||||
level = "ERROR"
|
||||
} else if strings.Contains(msgLower, "warning") || strings.Contains(msgLower, "warn") {
|
||||
level = "WARN"
|
||||
} else if strings.Contains(msgLower, "success") || strings.Contains(msgLower, "match found") {
|
||||
level = "INFO"
|
||||
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
|
||||
level = "DEBUG"
|
||||
}
|
||||
|
||||
GetLogBuffer().Add(level, tag, message)
|
||||
}
|
||||
|
||||
func GetLogs() string {
|
||||
return GetLogBuffer().GetAll()
|
||||
}
|
||||
|
||||
func GetLogsSince(index int) string {
|
||||
entries, nextIndex := GetLogBuffer().getSince(index)
|
||||
logsJson, _ := json.Marshal(entries)
|
||||
result := fmt.Sprintf(`{"logs":%s,"next_index":%d}`, string(logsJson), nextIndex)
|
||||
return result
|
||||
}
|
||||
|
||||
func ClearLogs() {
|
||||
GetLogBuffer().Clear()
|
||||
}
|
||||
|
||||
func GetLogCount() int {
|
||||
return GetLogBuffer().Count()
|
||||
}
|
||||
|
||||
func SetLoggingEnabled(enabled bool) {
|
||||
GetLogBuffer().SetLoggingEnabled(enabled)
|
||||
}
|
||||
@@ -3,14 +3,93 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
lyricsCacheTTL = 24 * time.Hour
|
||||
durationToleranceSec = 10.0
|
||||
)
|
||||
|
||||
type lyricsCacheEntry struct {
|
||||
response *LyricsResponse
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type lyricsCache struct {
|
||||
mu sync.RWMutex
|
||||
cache map[string]*lyricsCacheEntry
|
||||
}
|
||||
|
||||
var globalLyricsCache = &lyricsCache{
|
||||
cache: make(map[string]*lyricsCacheEntry),
|
||||
}
|
||||
|
||||
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
|
||||
normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
|
||||
normalizedTrack := strings.ToLower(strings.TrimSpace(track))
|
||||
roundedDuration := math.Round(durationSec/10) * 10
|
||||
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
|
||||
}
|
||||
|
||||
func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsResponse, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
key := c.generateKey(artist, track, durationSec)
|
||||
entry, exists := c.cache[key]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return entry.response, true
|
||||
}
|
||||
|
||||
func (c *lyricsCache) Set(artist, track string, durationSec float64, response *LyricsResponse) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
key := c.generateKey(artist, track, durationSec)
|
||||
c.cache[key] = &lyricsCacheEntry{
|
||||
response: response,
|
||||
expiresAt: time.Now().Add(lyricsCacheTTL),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *lyricsCache) CleanExpired() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cleaned := 0
|
||||
for key, entry := range c.cache {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(c.cache, key)
|
||||
cleaned++
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func (c *lyricsCache) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.cache)
|
||||
}
|
||||
|
||||
type LRCLibResponse struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -44,9 +123,7 @@ type LyricsClient struct {
|
||||
|
||||
func NewLyricsClient() *LyricsClient {
|
||||
return &LyricsClient{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +163,7 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
|
||||
return c.parseLRCLibResponse(&lrcResp), nil
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsResponse, error) {
|
||||
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
|
||||
baseURL := "https://lrclib.net/api/search"
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
@@ -118,6 +195,11 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
|
||||
return nil, fmt.Errorf("no lyrics found")
|
||||
}
|
||||
|
||||
bestMatch := c.findBestMatch(results, durationSec)
|
||||
if bestMatch != nil {
|
||||
return c.parseLRCLibResponse(bestMatch), nil
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
if result.SyncedLyrics != "" {
|
||||
return c.parseLRCLibResponse(&result), nil
|
||||
@@ -127,38 +209,92 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
|
||||
return c.parseLRCLibResponse(&results[0]), nil
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, error) {
|
||||
// Strategy 1: Direct match with artist and track name
|
||||
lyrics, err := c.FetchLyricsWithMetadata(artistName, trackName)
|
||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||
var bestSynced *LRCLibResponse
|
||||
var bestPlain *LRCLibResponse
|
||||
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
|
||||
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
|
||||
|
||||
if durationMatches {
|
||||
if result.SyncedLyrics != "" && bestSynced == nil {
|
||||
bestSynced = result
|
||||
} else if result.PlainLyrics != "" && bestPlain == nil {
|
||||
bestPlain = result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestSynced != nil {
|
||||
return bestSynced
|
||||
}
|
||||
return bestPlain
|
||||
}
|
||||
|
||||
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
|
||||
diff := math.Abs(lrcDuration - targetDuration)
|
||||
return diff <= durationToleranceSec
|
||||
}
|
||||
|
||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
primaryArtist := normalizeArtistName(artistName)
|
||||
|
||||
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
||||
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
||||
cachedCopy := *cached
|
||||
cachedCopy.Source = cached.Source + " (cached)"
|
||||
return &cachedCopy, nil
|
||||
}
|
||||
|
||||
var lyrics *LyricsResponse
|
||||
var err error
|
||||
|
||||
isValidResult := func(l *LyricsResponse) bool {
|
||||
return l != nil && (len(l.Lines) > 0 || l.Instrumental)
|
||||
}
|
||||
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// Strategy 2: Try with simplified track name
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
if simplifiedTrack != trackName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
|
||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
lyrics.Source = "LRCLIB (simplified)"
|
||||
if primaryArtist != artistName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Search with full query
|
||||
query := artistName + " " + trackName
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
|
||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
simplifiedTrack := simplifyTrackName(trackName)
|
||||
if simplifiedTrack != trackName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB (simplified)"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
|
||||
query := primaryArtist + " " + trackName
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB Search"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// Strategy 4: Search with simplified query
|
||||
if simplifiedTrack != trackName {
|
||||
query = artistName + " " + simplifiedTrack
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
|
||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
query = primaryArtist + " " + simplifiedTrack
|
||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
lyrics.Source = "LRCLIB Search (simplified)"
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
}
|
||||
@@ -248,15 +384,23 @@ func msToLRCTimestamp(ms int64) string {
|
||||
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
|
||||
}
|
||||
|
||||
func convertToLRC(lyrics *LyricsResponse) string {
|
||||
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
|
||||
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
|
||||
builder.WriteString("\n")
|
||||
|
||||
if lyrics.SyncType == "LINE_SYNCED" {
|
||||
for _, line := range lyrics.Lines {
|
||||
if line.Words == "" {
|
||||
continue
|
||||
}
|
||||
timestamp := msToLRCTimestamp(line.StartTimeMs)
|
||||
builder.WriteString(timestamp)
|
||||
builder.WriteString(line.Words)
|
||||
@@ -264,6 +408,9 @@ func convertToLRC(lyrics *LyricsResponse) string {
|
||||
}
|
||||
} else {
|
||||
for _, line := range lyrics.Lines {
|
||||
if line.Words == "" {
|
||||
continue
|
||||
}
|
||||
builder.WriteString(line.Words)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
@@ -297,3 +444,36 @@ func simplifyTrackName(name string) string {
|
||||
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
func normalizeArtistName(name string) string {
|
||||
separators := []string{", ", "; ", " & ", " feat. ", " ft. ", " featuring ", " with "}
|
||||
|
||||
result := name
|
||||
for _, sep := range separators {
|
||||
if idx := strings.Index(strings.ToLower(result), strings.ToLower(sep)); idx > 0 {
|
||||
result = result[:idx]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
|
||||
if lrcContent == "" {
|
||||
return "", fmt.Errorf("empty LRC content")
|
||||
}
|
||||
|
||||
dir := filepath.Dir(audioFilePath)
|
||||
ext := filepath.Ext(audioFilePath)
|
||||
baseName := strings.TrimSuffix(filepath.Base(audioFilePath), ext)
|
||||
|
||||
lrcFilePath := filepath.Join(dir, baseName+".lrc")
|
||||
|
||||
if err := os.WriteFile(lrcFilePath, []byte(lrcContent), 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write LRC file: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[Lyrics] Saved LRC file: %s\n", lrcFilePath)
|
||||
return lrcFilePath, nil
|
||||
}
|
||||
|
||||
@@ -1,16 +1,100 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
stdimage "image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-flac/flacpicture"
|
||||
"github.com/go-flac/flacvorbis"
|
||||
"github.com/go-flac/go-flac"
|
||||
"github.com/go-flac/flacpicture/v2"
|
||||
"github.com/go-flac/flacvorbis/v2"
|
||||
"github.com/go-flac/go-flac/v2"
|
||||
)
|
||||
|
||||
// Metadata represents track metadata for embedding
|
||||
func detectCoverMIME(coverPath string, coverData []byte) string {
|
||||
// Prefer magic-byte detection over file extension.
|
||||
// Some providers return non-JPEG data behind .jpg URLs.
|
||||
if len(coverData) >= 8 &&
|
||||
coverData[0] == 0x89 &&
|
||||
coverData[1] == 0x50 &&
|
||||
coverData[2] == 0x4E &&
|
||||
coverData[3] == 0x47 &&
|
||||
coverData[4] == 0x0D &&
|
||||
coverData[5] == 0x0A &&
|
||||
coverData[6] == 0x1A &&
|
||||
coverData[7] == 0x0A {
|
||||
return "image/png"
|
||||
}
|
||||
if len(coverData) >= 3 &&
|
||||
coverData[0] == 0xFF &&
|
||||
coverData[1] == 0xD8 &&
|
||||
coverData[2] == 0xFF {
|
||||
return "image/jpeg"
|
||||
}
|
||||
if len(coverData) >= 6 {
|
||||
header := string(coverData[:6])
|
||||
if header == "GIF87a" || header == "GIF89a" {
|
||||
return "image/gif"
|
||||
}
|
||||
}
|
||||
if len(coverData) >= 12 &&
|
||||
string(coverData[:4]) == "RIFF" &&
|
||||
string(coverData[8:12]) == "WEBP" {
|
||||
return "image/webp"
|
||||
}
|
||||
|
||||
switch strings.ToLower(filepath.Ext(strings.TrimSpace(coverPath))) {
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
}
|
||||
|
||||
return "image/jpeg"
|
||||
}
|
||||
|
||||
func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock, error) {
|
||||
if len(coverData) == 0 {
|
||||
return flac.MetaDataBlock{}, fmt.Errorf("empty cover data")
|
||||
}
|
||||
|
||||
mime := detectCoverMIME(coverPath, coverData)
|
||||
picture := &flacpicture.MetadataBlockPicture{
|
||||
PictureType: flacpicture.PictureTypeFrontCover,
|
||||
MIME: mime,
|
||||
Description: "Front Cover",
|
||||
ImageData: coverData,
|
||||
}
|
||||
|
||||
// Width/height/depth are optional in practice; keep zero when decode fails.
|
||||
if cfg, format, err := stdimage.DecodeConfig(bytes.NewReader(coverData)); err == nil {
|
||||
picture.Width = uint32(cfg.Width)
|
||||
picture.Height = uint32(cfg.Height)
|
||||
switch format {
|
||||
case "png":
|
||||
picture.ColorDepth = 32
|
||||
case "jpeg":
|
||||
picture.ColorDepth = 24
|
||||
default:
|
||||
picture.ColorDepth = 0
|
||||
}
|
||||
}
|
||||
|
||||
return picture.Marshal(), nil
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
Title string
|
||||
Artist string
|
||||
@@ -23,16 +107,19 @@ type Metadata struct {
|
||||
ISRC string
|
||||
Description string
|
||||
Lyrics string
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
Composer string
|
||||
Comment string
|
||||
}
|
||||
|
||||
// EmbedMetadata embeds metadata into a FLAC file
|
||||
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
// Find or create vorbis comment block
|
||||
var cmtIdx int = -1
|
||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||
|
||||
@@ -51,13 +138,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
cmt = flacvorbis.New()
|
||||
}
|
||||
|
||||
// Set metadata fields
|
||||
setComment(cmt, "TITLE", metadata.Title)
|
||||
setComment(cmt, "ARTIST", metadata.Artist)
|
||||
setComment(cmt, "ALBUM", metadata.Album)
|
||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
||||
setComment(cmt, "DATE", metadata.Date)
|
||||
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
if metadata.TotalTracks > 0 {
|
||||
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
||||
@@ -65,15 +151,15 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if metadata.DiscNumber > 0 {
|
||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||
}
|
||||
|
||||
|
||||
if metadata.ISRC != "" {
|
||||
setComment(cmt, "ISRC", metadata.ISRC)
|
||||
}
|
||||
|
||||
|
||||
if metadata.Description != "" {
|
||||
setComment(cmt, "DESCRIPTION", metadata.Description)
|
||||
}
|
||||
@@ -83,7 +169,26 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
||||
}
|
||||
|
||||
// Update or add vorbis comment block
|
||||
if metadata.Genre != "" {
|
||||
setComment(cmt, "GENRE", metadata.Genre)
|
||||
}
|
||||
|
||||
if metadata.Label != "" {
|
||||
setComment(cmt, "ORGANIZATION", metadata.Label)
|
||||
}
|
||||
|
||||
if metadata.Copyright != "" {
|
||||
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||
}
|
||||
|
||||
if metadata.Composer != "" {
|
||||
setComment(cmt, "COMPOSER", metadata.Composer)
|
||||
}
|
||||
|
||||
if metadata.Comment != "" {
|
||||
setComment(cmt, "COMMENT", metadata.Comment)
|
||||
}
|
||||
|
||||
cmtBlock := cmt.Marshal()
|
||||
if cmtIdx >= 0 {
|
||||
f.Meta[cmtIdx] = &cmtBlock
|
||||
@@ -91,52 +196,39 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
f.Meta = append(f.Meta, &cmtBlock)
|
||||
}
|
||||
|
||||
// Add cover art if provided
|
||||
if coverPath != "" {
|
||||
if fileExists(coverPath) {
|
||||
coverData, err := os.ReadFile(coverPath)
|
||||
if err != nil {
|
||||
fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err)
|
||||
} else {
|
||||
// Remove existing picture blocks first (like PC version)
|
||||
for i := len(f.Meta) - 1; i >= 0; i-- {
|
||||
if f.Meta[i].Type == flac.Picture {
|
||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
picture, err := flacpicture.NewFromImageData(
|
||||
flacpicture.PictureTypeFrontCover,
|
||||
"Front Cover",
|
||||
coverData,
|
||||
"image/jpeg",
|
||||
)
|
||||
|
||||
picBlock, err := buildPictureBlock(coverPath, coverData)
|
||||
if err != nil {
|
||||
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
|
||||
} else {
|
||||
picBlock := picture.Marshal()
|
||||
f.Meta = append(f.Meta, &picBlock)
|
||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||
return fmt.Errorf("failed to create picture block: %w", err)
|
||||
}
|
||||
f.Meta = append(f.Meta, &picBlock)
|
||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Save file
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
// EmbedMetadataWithCoverData embeds metadata into a FLAC file with cover data as bytes
|
||||
// This avoids file permission issues on Android by not requiring a temp file
|
||||
func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []byte) error {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
// Find or create vorbis comment block
|
||||
var cmtIdx int = -1
|
||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||
|
||||
@@ -155,13 +247,12 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
cmt = flacvorbis.New()
|
||||
}
|
||||
|
||||
// Set metadata fields
|
||||
setComment(cmt, "TITLE", metadata.Title)
|
||||
setComment(cmt, "ARTIST", metadata.Artist)
|
||||
setComment(cmt, "ALBUM", metadata.Album)
|
||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
||||
setComment(cmt, "DATE", metadata.Date)
|
||||
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
if metadata.TotalTracks > 0 {
|
||||
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
||||
@@ -169,15 +260,15 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if metadata.DiscNumber > 0 {
|
||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||
}
|
||||
|
||||
|
||||
if metadata.ISRC != "" {
|
||||
setComment(cmt, "ISRC", metadata.ISRC)
|
||||
}
|
||||
|
||||
|
||||
if metadata.Description != "" {
|
||||
setComment(cmt, "DESCRIPTION", metadata.Description)
|
||||
}
|
||||
@@ -187,7 +278,26 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
||||
}
|
||||
|
||||
// Update or add vorbis comment block
|
||||
if metadata.Genre != "" {
|
||||
setComment(cmt, "GENRE", metadata.Genre)
|
||||
}
|
||||
|
||||
if metadata.Label != "" {
|
||||
setComment(cmt, "ORGANIZATION", metadata.Label)
|
||||
}
|
||||
|
||||
if metadata.Copyright != "" {
|
||||
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||
}
|
||||
|
||||
if metadata.Composer != "" {
|
||||
setComment(cmt, "COMPOSER", metadata.Composer)
|
||||
}
|
||||
|
||||
if metadata.Comment != "" {
|
||||
setComment(cmt, "COMMENT", metadata.Comment)
|
||||
}
|
||||
|
||||
cmtBlock := cmt.Marshal()
|
||||
if cmtIdx >= 0 {
|
||||
f.Meta[cmtIdx] = &cmtBlock
|
||||
@@ -195,35 +305,24 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
||||
f.Meta = append(f.Meta, &cmtBlock)
|
||||
}
|
||||
|
||||
// Add cover art if provided
|
||||
if len(coverData) > 0 {
|
||||
// Remove existing picture blocks first
|
||||
for i := len(f.Meta) - 1; i >= 0; i-- {
|
||||
if f.Meta[i].Type == flac.Picture {
|
||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
picture, err := flacpicture.NewFromImageData(
|
||||
flacpicture.PictureTypeFrontCover,
|
||||
"Front Cover",
|
||||
coverData,
|
||||
"image/jpeg",
|
||||
)
|
||||
|
||||
picBlock, err := buildPictureBlock("", coverData)
|
||||
if err != nil {
|
||||
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
|
||||
} else {
|
||||
picBlock := picture.Marshal()
|
||||
f.Meta = append(f.Meta, &picBlock)
|
||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||
return fmt.Errorf("failed to create picture block: %w", err)
|
||||
}
|
||||
f.Meta = append(f.Meta, &picBlock)
|
||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||
}
|
||||
|
||||
// Save file
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
// ReadMetadata reads metadata from a FLAC file
|
||||
func ReadMetadata(filePath string) (*Metadata, error) {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
@@ -256,11 +355,33 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
||||
if trackNum != "" {
|
||||
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||
}
|
||||
if metadata.TrackNumber == 0 {
|
||||
trackNum = getComment(cmt, "TRACK")
|
||||
if trackNum != "" {
|
||||
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||
}
|
||||
}
|
||||
|
||||
discNum := getComment(cmt, "DISCNUMBER")
|
||||
if discNum != "" {
|
||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||
}
|
||||
if metadata.DiscNumber == 0 {
|
||||
discNum = getComment(cmt, "DISC")
|
||||
if discNum != "" {
|
||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||
}
|
||||
}
|
||||
|
||||
if metadata.Date == "" {
|
||||
metadata.Date = getComment(cmt, "YEAR")
|
||||
}
|
||||
|
||||
metadata.Genre = getComment(cmt, "GENRE")
|
||||
metadata.Label = getComment(cmt, "ORGANIZATION")
|
||||
metadata.Copyright = getComment(cmt, "COPYRIGHT")
|
||||
metadata.Composer = getComment(cmt, "COMPOSER")
|
||||
metadata.Comment = getComment(cmt, "COMMENT")
|
||||
|
||||
break
|
||||
}
|
||||
@@ -273,32 +394,71 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
// Remove existing
|
||||
keyUpper := strings.ToUpper(key)
|
||||
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
||||
if len(cmt.Comments[i]) > len(key)+1 && cmt.Comments[i][:len(key)+1] == key+"=" {
|
||||
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
|
||||
comment := cmt.Comments[i]
|
||||
eqIdx := strings.Index(comment, "=")
|
||||
if eqIdx > 0 {
|
||||
existingKey := strings.ToUpper(comment[:eqIdx])
|
||||
if existingKey == keyUpper {
|
||||
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add new
|
||||
cmt.Comments = append(cmt.Comments, key+"="+value)
|
||||
}
|
||||
|
||||
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
||||
keyUpper := strings.ToUpper(key) + "="
|
||||
for _, comment := range cmt.Comments {
|
||||
if len(comment) > len(key)+1 && comment[:len(key)+1] == key+"=" {
|
||||
return comment[len(key)+1:]
|
||||
if len(comment) > len(key) {
|
||||
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
||||
if commentUpper == keyUpper {
|
||||
return comment[len(key)+1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fileExists checks if a file exists
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// EmbedLyrics embeds lyrics into a FLAC file as a separate operation
|
||||
func ExtractCoverArt(filePath string) ([]byte, error) {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
for _, meta := range f.Meta {
|
||||
if meta.Type == flac.Picture {
|
||||
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if pic.PictureType == flacpicture.PictureTypeFrontCover && len(pic.ImageData) > 0 {
|
||||
return pic.ImageData, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, meta := range f.Meta {
|
||||
if meta.Type == flac.Picture {
|
||||
pic, err := flacpicture.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(pic.ImageData) > 0 {
|
||||
return pic.ImageData, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no cover art found in file")
|
||||
}
|
||||
|
||||
func EmbedLyrics(filePath string, lyrics string) error {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
@@ -335,3 +495,377 @@ func EmbedLyrics(filePath string, lyrics string) error {
|
||||
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
func EmbedGenreLabel(filePath string, genre, label string) error {
|
||||
if genre == "" && label == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
var cmtIdx int = -1
|
||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||
|
||||
for idx, meta := range f.Meta {
|
||||
if meta.Type == flac.VorbisComment {
|
||||
cmtIdx = idx
|
||||
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse vorbis comment: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cmt == nil {
|
||||
cmt = flacvorbis.New()
|
||||
}
|
||||
|
||||
if genre != "" {
|
||||
setComment(cmt, "GENRE", genre)
|
||||
}
|
||||
if label != "" {
|
||||
setComment(cmt, "ORGANIZATION", label)
|
||||
}
|
||||
|
||||
cmtBlock := cmt.Marshal()
|
||||
if cmtIdx >= 0 {
|
||||
f.Meta[cmtIdx] = &cmtBlock
|
||||
} else {
|
||||
f.Meta = append(f.Meta, &cmtBlock)
|
||||
}
|
||||
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
func ExtractLyrics(filePath string) (string, error) {
|
||||
lower := strings.ToLower(filePath)
|
||||
|
||||
if strings.HasSuffix(lower, ".flac") {
|
||||
return extractLyricsFromFlac(filePath)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, ".mp3") {
|
||||
meta, err := ReadID3Tags(filePath)
|
||||
if err != nil || meta == nil {
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||
return meta.Lyrics, nil
|
||||
}
|
||||
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||
return meta.Comment, nil
|
||||
}
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
||||
meta, err := ReadOggVorbisComments(filePath)
|
||||
if err != nil || meta == nil {
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||
return meta.Lyrics, nil
|
||||
}
|
||||
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||
return meta.Comment, nil
|
||||
}
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unsupported file format for lyrics extraction")
|
||||
}
|
||||
|
||||
func extractLyricsFromFlac(filePath string) (string, error) {
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
for _, meta := range f.Meta {
|
||||
if meta.Type != flac.VorbisComment {
|
||||
continue
|
||||
}
|
||||
|
||||
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
lyrics, err := cmt.Get("LYRICS")
|
||||
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
|
||||
return lyrics[0], nil
|
||||
}
|
||||
|
||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
|
||||
return lyrics[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no lyrics found in file")
|
||||
}
|
||||
|
||||
func looksLikeEmbeddedLyrics(value string) bool {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
lower := strings.ToLower(trimmed)
|
||||
if strings.Contains(lower, "[ar:") || strings.Contains(lower, "[ti:") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.Contains(trimmed, "\n") && strings.Contains(trimmed, "[") && strings.Contains(trimmed, "]") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type AudioQuality struct {
|
||||
BitDepth int `json:"bit_depth"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
TotalSamples int64 `json:"total_samples"`
|
||||
}
|
||||
|
||||
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
marker := make([]byte, 4)
|
||||
if _, err := file.Read(marker); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||
}
|
||||
|
||||
if string(marker) == "fLaC" {
|
||||
header := make([]byte, 4)
|
||||
if _, err := file.Read(header); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||
}
|
||||
|
||||
blockType := header[0] & 0x7F
|
||||
if blockType != 0 {
|
||||
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
||||
}
|
||||
|
||||
streamInfo := make([]byte, 34)
|
||||
if _, err := file.Read(streamInfo); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
||||
}
|
||||
|
||||
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
||||
|
||||
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
||||
|
||||
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
|
||||
int64(streamInfo[14])<<24 |
|
||||
int64(streamInfo[15])<<16 |
|
||||
int64(streamInfo[16])<<8 |
|
||||
int64(streamInfo[17])
|
||||
|
||||
return AudioQuality{
|
||||
BitDepth: bitsPerSample,
|
||||
SampleRate: sampleRate,
|
||||
TotalSamples: totalSamples,
|
||||
}, nil
|
||||
}
|
||||
|
||||
file.Seek(0, 0)
|
||||
header8 := make([]byte, 8)
|
||||
if _, err := file.Read(header8); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||
}
|
||||
|
||||
if string(header8[4:8]) == "ftyp" {
|
||||
file.Close()
|
||||
return GetM4AQuality(filePath)
|
||||
}
|
||||
|
||||
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
||||
}
|
||||
|
||||
func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to open M4A file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to stat M4A file: %w", err)
|
||||
}
|
||||
fileSize := info.Size()
|
||||
|
||||
moovHeader, moovFound, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
|
||||
if err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to find moov atom: %w", err)
|
||||
}
|
||||
if !moovFound {
|
||||
return AudioQuality{}, fmt.Errorf("moov atom not found")
|
||||
}
|
||||
|
||||
moovStart := moovHeader.offset
|
||||
moovEnd := moovHeader.offset + moovHeader.size
|
||||
|
||||
sampleOffset, atomType, err := findAudioSampleEntry(f, moovStart, moovEnd, fileSize)
|
||||
if err != nil {
|
||||
return AudioQuality{}, err
|
||||
}
|
||||
|
||||
buf := make([]byte, 24)
|
||||
if _, err := f.ReadAt(buf, sampleOffset); err != nil {
|
||||
return AudioQuality{}, fmt.Errorf("failed to read audio sample entry: %w", err)
|
||||
}
|
||||
|
||||
sampleRate := int(buf[22])<<8 | int(buf[23])
|
||||
bitDepth := 16
|
||||
if atomType == "alac" {
|
||||
bitDepth = 24
|
||||
}
|
||||
|
||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||
}
|
||||
|
||||
type atomHeader struct {
|
||||
offset int64
|
||||
size int64
|
||||
headerSize int64
|
||||
typ string
|
||||
}
|
||||
|
||||
func readAtomHeaderAt(f *os.File, offset, fileSize int64) (atomHeader, error) {
|
||||
if offset+8 > fileSize {
|
||||
return atomHeader{}, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
headerBuf := make([]byte, 8)
|
||||
if _, err := f.ReadAt(headerBuf, offset); err != nil {
|
||||
return atomHeader{}, err
|
||||
}
|
||||
|
||||
size32 := binary.BigEndian.Uint32(headerBuf[0:4])
|
||||
typ := string(headerBuf[4:8])
|
||||
|
||||
if size32 == 1 {
|
||||
if offset+16 > fileSize {
|
||||
return atomHeader{}, io.ErrUnexpectedEOF
|
||||
}
|
||||
extBuf := make([]byte, 8)
|
||||
if _, err := f.ReadAt(extBuf, offset+8); err != nil {
|
||||
return atomHeader{}, err
|
||||
}
|
||||
size64 := binary.BigEndian.Uint64(extBuf)
|
||||
return atomHeader{offset: offset, size: int64(size64), headerSize: 16, typ: typ}, nil
|
||||
}
|
||||
|
||||
return atomHeader{offset: offset, size: int64(size32), headerSize: 8, typ: typ}, nil
|
||||
}
|
||||
|
||||
func findAtomInRange(f *os.File, start, size int64, target string, fileSize int64) (atomHeader, bool, error) {
|
||||
if size <= 0 {
|
||||
return atomHeader{}, false, nil
|
||||
}
|
||||
|
||||
end := start + size
|
||||
pos := start
|
||||
|
||||
for pos+8 <= end {
|
||||
header, err := readAtomHeaderAt(f, pos, fileSize)
|
||||
if err != nil {
|
||||
return atomHeader{}, false, err
|
||||
}
|
||||
|
||||
atomSize := header.size
|
||||
if atomSize == 0 {
|
||||
atomSize = end - pos
|
||||
}
|
||||
|
||||
if atomSize < header.headerSize {
|
||||
return atomHeader{}, false, fmt.Errorf("invalid atom size for %s", header.typ)
|
||||
}
|
||||
|
||||
header.size = atomSize
|
||||
if header.typ == target {
|
||||
return header, true, nil
|
||||
}
|
||||
|
||||
pos += atomSize
|
||||
}
|
||||
|
||||
return atomHeader{}, false, nil
|
||||
}
|
||||
|
||||
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
|
||||
const chunkSize = 64 * 1024
|
||||
patternMP4A := []byte("mp4a")
|
||||
patternALAC := []byte("alac")
|
||||
|
||||
var tail []byte
|
||||
readPos := start
|
||||
|
||||
for readPos < end {
|
||||
toRead := end - readPos
|
||||
if toRead > chunkSize {
|
||||
toRead = chunkSize
|
||||
}
|
||||
|
||||
buf := make([]byte, toRead)
|
||||
n, err := f.ReadAt(buf, readPos)
|
||||
if err != nil && err != io.EOF {
|
||||
return 0, "", fmt.Errorf("failed to read M4A atom data: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
data := append(tail, buf[:n]...)
|
||||
mp4aIdx := bytes.Index(data, patternMP4A)
|
||||
alacIdx := bytes.Index(data, patternALAC)
|
||||
|
||||
bestIdx := -1
|
||||
bestType := ""
|
||||
switch {
|
||||
case mp4aIdx >= 0 && alacIdx >= 0:
|
||||
if mp4aIdx <= alacIdx {
|
||||
bestIdx = mp4aIdx
|
||||
bestType = "mp4a"
|
||||
} else {
|
||||
bestIdx = alacIdx
|
||||
bestType = "alac"
|
||||
}
|
||||
case mp4aIdx >= 0:
|
||||
bestIdx = mp4aIdx
|
||||
bestType = "mp4a"
|
||||
case alacIdx >= 0:
|
||||
bestIdx = alacIdx
|
||||
bestType = "alac"
|
||||
}
|
||||
|
||||
if bestIdx >= 0 {
|
||||
absolute := readPos - int64(len(tail)) + int64(bestIdx)
|
||||
if absolute+24 > fileSize {
|
||||
return 0, "", fmt.Errorf("audio info not found in M4A file")
|
||||
}
|
||||
return absolute, bestType, nil
|
||||
}
|
||||
|
||||
if len(data) >= 3 {
|
||||
tail = append([]byte{}, data[len(data)-3:]...)
|
||||
} else {
|
||||
tail = append([]byte{}, data...)
|
||||
}
|
||||
|
||||
readPos += int64(n)
|
||||
}
|
||||
|
||||
return 0, "", fmt.Errorf("audio info not found in M4A file")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// mobile_deps.go
|
||||
// This file ensures gomobile dependencies are not removed by go mod tidy.
|
||||
// These packages are required by gomobile bind but not directly imported in code.
|
||||
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
// Required for gomobile bind to work
|
||||
_ "golang.org/x/mobile/bind"
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func isFDOutput(outputFD int) bool {
|
||||
return outputFD > 0
|
||||
}
|
||||
|
||||
func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
|
||||
if isFDOutput(outputFD) {
|
||||
return os.NewFile(uintptr(outputFD), fmt.Sprintf("saf_fd_%d", outputFD)), nil
|
||||
}
|
||||
return os.Create(outputPath)
|
||||
}
|
||||
|
||||
func cleanupOutputOnError(outputPath string, outputFD int) {
|
||||
if isFDOutput(outputFD) {
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(outputPath)
|
||||
if path == "" || strings.HasPrefix(path, "/proc/self/fd/") {
|
||||
return
|
||||
}
|
||||
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TrackIDCacheEntry struct {
|
||||
TidalTrackID int64
|
||||
QobuzTrackID int64
|
||||
AmazonURL string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type TrackIDCache struct {
|
||||
cache map[string]*TrackIDCacheEntry
|
||||
mu sync.RWMutex
|
||||
ttl time.Duration
|
||||
lastCleanup time.Time
|
||||
cleanupInterval time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
globalTrackIDCache *TrackIDCache
|
||||
trackIDCacheOnce sync.Once
|
||||
)
|
||||
|
||||
func GetTrackIDCache() *TrackIDCache {
|
||||
trackIDCacheOnce.Do(func() {
|
||||
globalTrackIDCache = &TrackIDCache{
|
||||
cache: make(map[string]*TrackIDCacheEntry),
|
||||
ttl: 30 * time.Minute,
|
||||
cleanupInterval: 5 * time.Minute,
|
||||
}
|
||||
})
|
||||
return globalTrackIDCache
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
|
||||
c.mu.RLock()
|
||||
entry, exists := c.cache[isrc]
|
||||
if !exists {
|
||||
c.mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
expired := time.Now().After(entry.ExpiresAt)
|
||||
c.mu.RUnlock()
|
||||
|
||||
if !expired {
|
||||
return entry
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
entry, exists = c.cache[isrc]
|
||||
if exists && time.Now().After(entry.ExpiresAt) {
|
||||
delete(c.cache, isrc)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) pruneExpiredLocked(now time.Time) {
|
||||
for key, entry := range c.cache {
|
||||
if now.After(entry.ExpiresAt) {
|
||||
delete(c.cache, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
entry, exists := c.cache[isrc]
|
||||
if !exists {
|
||||
entry = &TrackIDCacheEntry{}
|
||||
c.cache[isrc] = entry
|
||||
}
|
||||
entry.TidalTrackID = trackID
|
||||
now := time.Now()
|
||||
entry.ExpiresAt = now.Add(c.ttl)
|
||||
|
||||
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
||||
c.pruneExpiredLocked(now)
|
||||
c.lastCleanup = now
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
entry, exists := c.cache[isrc]
|
||||
if !exists {
|
||||
entry = &TrackIDCacheEntry{}
|
||||
c.cache[isrc] = entry
|
||||
}
|
||||
entry.QobuzTrackID = trackID
|
||||
now := time.Now()
|
||||
entry.ExpiresAt = now.Add(c.ttl)
|
||||
|
||||
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
||||
c.pruneExpiredLocked(now)
|
||||
c.lastCleanup = now
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) SetAmazonURL(isrc string, amazonURL string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
entry, exists := c.cache[isrc]
|
||||
if !exists {
|
||||
entry = &TrackIDCacheEntry{}
|
||||
c.cache[isrc] = entry
|
||||
}
|
||||
entry.AmazonURL = amazonURL
|
||||
now := time.Now()
|
||||
entry.ExpiresAt = now.Add(c.ttl)
|
||||
|
||||
if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) {
|
||||
c.pruneExpiredLocked(now)
|
||||
c.lastCleanup = now
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.cache = make(map[string]*TrackIDCacheEntry)
|
||||
}
|
||||
|
||||
func (c *TrackIDCache) Size() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.cache)
|
||||
}
|
||||
|
||||
type ParallelDownloadResult struct {
|
||||
CoverData []byte
|
||||
LyricsData *LyricsResponse
|
||||
LyricsLRC string
|
||||
CoverErr error
|
||||
LyricsErr error
|
||||
}
|
||||
|
||||
func FetchCoverAndLyricsParallel(
|
||||
coverURL string,
|
||||
maxQualityCover bool,
|
||||
spotifyID string,
|
||||
trackName string,
|
||||
artistName string,
|
||||
embedLyrics bool,
|
||||
durationMs int64,
|
||||
) *ParallelDownloadResult {
|
||||
result := &ParallelDownloadResult{}
|
||||
var wg sync.WaitGroup
|
||||
var resultMu sync.Mutex
|
||||
|
||||
if coverURL != "" {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
|
||||
resultMu.Lock()
|
||||
if err != nil {
|
||||
result.CoverErr = err
|
||||
} else {
|
||||
result.CoverData = data
|
||||
}
|
||||
resultMu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
if embedLyrics {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
resultMu.Lock()
|
||||
if err != nil {
|
||||
result.LyricsErr = err
|
||||
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||
result.LyricsData = lyrics
|
||||
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
||||
} else {
|
||||
result.LyricsErr = fmt.Errorf("no lyrics found")
|
||||
}
|
||||
resultMu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return result
|
||||
}
|
||||
|
||||
type PreWarmCacheRequest struct {
|
||||
ISRC string
|
||||
TrackName string
|
||||
ArtistName string
|
||||
SpotifyID string
|
||||
Service string
|
||||
}
|
||||
|
||||
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
||||
if len(requests) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cache := GetTrackIDCache()
|
||||
|
||||
semaphore := make(chan struct{}, 3)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, req := range requests {
|
||||
if req.ISRC == "" {
|
||||
continue
|
||||
}
|
||||
if cached := cache.Get(req.ISRC); cached != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(r PreWarmCacheRequest) {
|
||||
defer wg.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
switch r.Service {
|
||||
case "tidal":
|
||||
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
|
||||
case "qobuz":
|
||||
preWarmQobuzCache(r.ISRC, r.SpotifyID)
|
||||
case "amazon":
|
||||
preWarmAmazonCache(r.ISRC, r.SpotifyID)
|
||||
}
|
||||
}(req)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func preWarmTidalCache(isrc, _, _ string) {
|
||||
downloader := NewTidalDownloader()
|
||||
track, err := downloader.SearchTrackByISRC(isrc)
|
||||
if err == nil && track != nil {
|
||||
GetTrackIDCache().SetTidal(isrc, track.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// preWarmQobuzCache tries to get Qobuz Track ID in the following order:
|
||||
// 1. From SongLink (fast, no Qobuz API call needed)
|
||||
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
|
||||
func preWarmQobuzCache(isrc, spotifyID string) {
|
||||
// First, try to get QobuzID from SongLink - this is faster and more reliable
|
||||
if spotifyID != "" {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||
if err == nil && availability != nil && availability.QobuzID != "" {
|
||||
// Parse QobuzID to int64
|
||||
var trackID int64
|
||||
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc)
|
||||
GetTrackIDCache().SetQobuz(isrc, trackID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Direct ISRC search on Qobuz API
|
||||
downloader := NewQobuzDownloader()
|
||||
track, err := downloader.SearchTrackByISRC(isrc)
|
||||
if err == nil && track != nil {
|
||||
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from direct ISRC search for %s\n", track.ID, isrc)
|
||||
GetTrackIDCache().SetQobuz(isrc, track.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func preWarmAmazonCache(isrc, spotifyID string) {
|
||||
client := NewSongLinkClient()
|
||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||
if err == nil && availability != nil && availability.AmazonURL != "" {
|
||||
GetTrackIDCache().SetAmazonURL(isrc, availability.AmazonURL)
|
||||
}
|
||||
}
|
||||
|
||||
func PreWarmCache(tracksJSON string) error {
|
||||
var tracks []struct {
|
||||
ISRC string `json:"isrc"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Service string `json:"service"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
|
||||
return fmt.Errorf("failed to parse tracks JSON: %w", err)
|
||||
}
|
||||
|
||||
requests := make([]PreWarmCacheRequest, len(tracks))
|
||||
for i, t := range tracks {
|
||||
requests[i] = PreWarmCacheRequest{
|
||||
ISRC: t.ISRC,
|
||||
TrackName: t.TrackName,
|
||||
ArtistName: t.ArtistName,
|
||||
SpotifyID: t.SpotifyID,
|
||||
Service: t.Service,
|
||||
}
|
||||
}
|
||||
|
||||
go PreWarmTrackCache(requests)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearTrackCache() {
|
||||
GetTrackIDCache().Clear()
|
||||
}
|
||||
|
||||
func GetCacheSize() int {
|
||||
return GetTrackIDCache().Size()
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DownloadProgress represents current download progress
|
||||
type DownloadProgress struct {
|
||||
CurrentFile string `json:"current_file"`
|
||||
Progress float64 `json:"progress"`
|
||||
@@ -12,57 +13,169 @@ type DownloadProgress struct {
|
||||
BytesTotal int64 `json:"bytes_total"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type ItemProgress struct {
|
||||
ItemID string `json:"item_id"`
|
||||
BytesTotal int64 `json:"bytes_total"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
Progress float64 `json:"progress"`
|
||||
SpeedMBps float64 `json:"speed_mbps"`
|
||||
IsDownloading bool `json:"is_downloading"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type MultiProgress struct {
|
||||
Items map[string]*ItemProgress `json:"items"`
|
||||
}
|
||||
|
||||
var (
|
||||
currentProgress DownloadProgress
|
||||
progressMu sync.RWMutex
|
||||
downloadDir string
|
||||
downloadDirMu sync.RWMutex
|
||||
downloadDir string
|
||||
downloadDirMu sync.RWMutex
|
||||
|
||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||
multiMu sync.RWMutex
|
||||
)
|
||||
|
||||
// getProgress returns current download progress
|
||||
func getProgress() DownloadProgress {
|
||||
progressMu.RLock()
|
||||
defer progressMu.RUnlock()
|
||||
return currentProgress
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
|
||||
for _, item := range multiProgress.Items {
|
||||
return DownloadProgress{
|
||||
CurrentFile: item.ItemID,
|
||||
Progress: item.Progress * 100,
|
||||
BytesTotal: item.BytesTotal,
|
||||
BytesReceived: item.BytesReceived,
|
||||
IsDownloading: item.IsDownloading,
|
||||
Status: item.Status,
|
||||
}
|
||||
}
|
||||
|
||||
return DownloadProgress{}
|
||||
}
|
||||
|
||||
// SetDownloadProgress sets the current download progress (MB downloaded)
|
||||
func SetDownloadProgress(mbDownloaded float64) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
currentProgress.Progress = mbDownloaded
|
||||
currentProgress.IsDownloading = true
|
||||
func GetMultiProgress() string {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
|
||||
jsonBytes, err := json.Marshal(multiProgress)
|
||||
if err != nil {
|
||||
return "{\"items\":{}}"
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
// SetDownloadSpeed sets the current download speed
|
||||
func SetDownloadSpeed(speedMBps float64) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
currentProgress.Speed = speedMBps
|
||||
func GetItemProgress(itemID string) string {
|
||||
multiMu.RLock()
|
||||
defer multiMu.RUnlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
jsonBytes, _ := json.Marshal(item)
|
||||
return string(jsonBytes)
|
||||
}
|
||||
return "{}"
|
||||
}
|
||||
|
||||
// SetCurrentFile sets the current file being downloaded and resets progress
|
||||
func SetCurrentFile(filename string) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
// Reset progress for new file
|
||||
currentProgress.BytesReceived = 0
|
||||
currentProgress.BytesTotal = 0
|
||||
currentProgress.Progress = 0
|
||||
currentProgress.CurrentFile = filename
|
||||
currentProgress.IsDownloading = true
|
||||
func StartItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
multiProgress.Items[itemID] = &ItemProgress{
|
||||
ItemID: itemID,
|
||||
BytesTotal: 0,
|
||||
BytesReceived: 0,
|
||||
Progress: 0,
|
||||
IsDownloading: true,
|
||||
Status: "downloading",
|
||||
}
|
||||
}
|
||||
|
||||
// ResetProgress resets the download progress
|
||||
func ResetProgress() {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
currentProgress = DownloadProgress{}
|
||||
func SetItemBytesTotal(itemID string, total int64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.BytesTotal = total
|
||||
}
|
||||
}
|
||||
|
||||
func SetItemBytesReceived(itemID string, received int64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.BytesReceived = received
|
||||
if item.BytesTotal > 0 {
|
||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps float64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.BytesReceived = received
|
||||
item.SpeedMBps = speedMBps
|
||||
if item.BytesTotal > 0 {
|
||||
item.Progress = float64(received) / float64(item.BytesTotal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func CompleteItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.Progress = 1.0
|
||||
item.IsDownloading = false
|
||||
item.Status = "completed"
|
||||
}
|
||||
}
|
||||
|
||||
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.Progress = progress
|
||||
if bytesReceived > 0 {
|
||||
item.BytesReceived = bytesReceived
|
||||
}
|
||||
if bytesTotal > 0 {
|
||||
item.BytesTotal = bytesTotal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func SetItemFinalizing(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
if item, ok := multiProgress.Items[itemID]; ok {
|
||||
item.Progress = 1.0
|
||||
item.Status = "finalizing"
|
||||
}
|
||||
}
|
||||
|
||||
func RemoveItemProgress(itemID string) {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
delete(multiProgress.Items, itemID)
|
||||
}
|
||||
|
||||
func ClearAllItemProgress() {
|
||||
multiMu.Lock()
|
||||
defer multiMu.Unlock()
|
||||
|
||||
multiProgress.Items = make(map[string]*ItemProgress)
|
||||
}
|
||||
|
||||
// setDownloadDir sets the default download directory
|
||||
func setDownloadDir(path string) error {
|
||||
downloadDirMu.Lock()
|
||||
defer downloadDirMu.Unlock()
|
||||
@@ -70,68 +183,54 @@ func setDownloadDir(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDownloadDir returns the default download directory
|
||||
func getDownloadDir() string {
|
||||
downloadDirMu.RLock()
|
||||
defer downloadDirMu.RUnlock()
|
||||
return downloadDir
|
||||
type ItemProgressWriter struct {
|
||||
writer interface{ Write([]byte) (int, error) }
|
||||
itemID string
|
||||
current int64
|
||||
lastReported int64
|
||||
startTime time.Time
|
||||
lastTime time.Time
|
||||
lastBytes int64
|
||||
}
|
||||
|
||||
// SetDownloading sets the download status
|
||||
func SetDownloading(status bool) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
currentProgress.IsDownloading = status
|
||||
}
|
||||
const progressUpdateThreshold = 64 * 1024
|
||||
|
||||
// SetBytesTotal sets total bytes to download
|
||||
func SetBytesTotal(total int64) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
currentProgress.BytesTotal = total
|
||||
}
|
||||
|
||||
// SetBytesReceived sets bytes received so far
|
||||
func SetBytesReceived(received int64) {
|
||||
progressMu.Lock()
|
||||
defer progressMu.Unlock()
|
||||
currentProgress.BytesReceived = received
|
||||
if currentProgress.BytesTotal > 0 {
|
||||
currentProgress.Progress = float64(received) / float64(currentProgress.BytesTotal) * 100
|
||||
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
|
||||
now := time.Now()
|
||||
return &ItemProgressWriter{
|
||||
writer: w,
|
||||
itemID: itemID,
|
||||
current: 0,
|
||||
lastReported: 0,
|
||||
startTime: now,
|
||||
lastTime: now,
|
||||
lastBytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ProgressWriter wraps io.Writer to track download progress
|
||||
type ProgressWriter struct {
|
||||
writer interface{ Write([]byte) (int, error) }
|
||||
total int64
|
||||
current int64
|
||||
}
|
||||
|
||||
// NewProgressWriter creates a new progress writer wrapping an io.Writer
|
||||
func NewProgressWriter(w interface{ Write([]byte) (int, error) }) *ProgressWriter {
|
||||
// Reset bytes received when starting new download
|
||||
SetBytesReceived(0)
|
||||
return &ProgressWriter{
|
||||
writer: w,
|
||||
current: 0,
|
||||
total: 0,
|
||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
|
||||
return 0, ErrDownloadCancelled
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements io.Writer
|
||||
func (pw *ProgressWriter) Write(p []byte) (int, error) {
|
||||
n, err := pw.writer.Write(p)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
pw.current += int64(n)
|
||||
pw.total += int64(n)
|
||||
SetBytesReceived(pw.current)
|
||||
|
||||
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
||||
now := time.Now()
|
||||
elapsed := now.Sub(pw.lastTime).Seconds()
|
||||
var speedMBps float64
|
||||
if elapsed > 0 {
|
||||
bytesInInterval := pw.current - pw.lastBytes
|
||||
speedMBps = float64(bytesInInterval) / (1024 * 1024) / elapsed
|
||||
}
|
||||
|
||||
SetItemBytesReceivedWithSpeed(pw.itemID, pw.current, speedMBps)
|
||||
pw.lastReported = pw.current
|
||||
pw.lastTime = now
|
||||
pw.lastBytes = pw.current
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// GetTotal returns total bytes written
|
||||
func (pw *ProgressWriter) GetTotal() int64 {
|
||||
return pw.total
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
|
||||
t.Run("reads nested data.url", func(t *testing.T) {
|
||||
body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`)
|
||||
|
||||
got, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if got != "https://example.test/audio.flac" {
|
||||
t.Fatalf("unexpected URL: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reads top-level url", func(t *testing.T) {
|
||||
body := []byte(`{"url":"https://example.test/top.flac"}`)
|
||||
|
||||
got, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if got != "https://example.test/top.flac" {
|
||||
t.Fatalf("unexpected URL: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns API error", func(t *testing.T) {
|
||||
body := []byte(`{"error":"track not found"}`)
|
||||
|
||||
_, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err == nil || err.Error() != "track not found" {
|
||||
t.Fatalf("expected track-not-found error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns message when success false", func(t *testing.T) {
|
||||
body := []byte(`{"success":false,"message":"blocked"}`)
|
||||
|
||||
_, err := extractQobuzDownloadURLFromBody(body)
|
||||
if err == nil || err.Error() != "blocked" {
|
||||
t.Fatalf("expected blocked error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// RateLimiter implements a sliding window rate limiter
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
maxRequests int
|
||||
@@ -13,7 +12,6 @@ type RateLimiter struct {
|
||||
timestamps []time.Time
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a new rate limiter with specified max requests per window
|
||||
func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
maxRequests: maxRequests,
|
||||
@@ -22,39 +20,31 @@ func NewRateLimiter(maxRequests int, window time.Duration) *RateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForSlot blocks until a request is allowed under the rate limit
|
||||
// Returns immediately if under the limit, otherwise waits until a slot is available
|
||||
func (r *RateLimiter) WaitForSlot() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Remove timestamps outside the window
|
||||
r.cleanOldTimestamps(now)
|
||||
|
||||
// If under limit, record and return immediately
|
||||
if len(r.timestamps) < r.maxRequests {
|
||||
r.timestamps = append(r.timestamps, now)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate wait time until oldest timestamp expires
|
||||
oldestTimestamp := r.timestamps[0]
|
||||
waitUntil := oldestTimestamp.Add(r.window)
|
||||
waitDuration := waitUntil.Sub(now)
|
||||
|
||||
if waitDuration > 0 {
|
||||
// Release lock while waiting
|
||||
r.mu.Unlock()
|
||||
time.Sleep(waitDuration)
|
||||
r.mu.Lock()
|
||||
|
||||
// Clean again after waiting
|
||||
r.cleanOldTimestamps(time.Now())
|
||||
}
|
||||
|
||||
// Record this request
|
||||
r.timestamps = append(r.timestamps, time.Now())
|
||||
}
|
||||
|
||||
@@ -76,8 +66,6 @@ func (r *RateLimiter) cleanOldTimestamps(now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// TryAcquire attempts to acquire a slot without blocking
|
||||
// Returns true if successful, false if rate limit would be exceeded
|
||||
func (r *RateLimiter) TryAcquire() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -93,7 +81,6 @@ func (r *RateLimiter) TryAcquire() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Available returns the number of requests available in the current window
|
||||
func (r *RateLimiter) Available() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -105,7 +92,6 @@ func (r *RateLimiter) Available() int {
|
||||
// Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10)
|
||||
var songLinkRateLimiter = NewRateLimiter(9, time.Minute)
|
||||
|
||||
// GetSongLinkRateLimiter returns the global SongLink rate limiter
|
||||
func GetSongLinkRateLimiter() *RateLimiter {
|
||||
return songLinkRateLimiter
|
||||
}
|
||||
|
||||
@@ -5,109 +5,58 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Japanese character ranges
|
||||
const (
|
||||
hiraganaStart = 0x3040
|
||||
hiraganaEnd = 0x309F
|
||||
katakanaStart = 0x30A0
|
||||
katakanaEnd = 0x30FF
|
||||
kanjiStart = 0x4E00
|
||||
kanjiEnd = 0x9FFF
|
||||
)
|
||||
|
||||
// hiraganaToRomaji maps hiragana characters to romaji
|
||||
var hiraganaToRomaji = map[rune]string{
|
||||
// Basic vowels
|
||||
'あ': "a", 'い': "i", 'う': "u", 'え': "e", 'お': "o",
|
||||
// K-row
|
||||
'か': "ka", 'き': "ki", 'く': "ku", 'け': "ke", 'こ': "ko",
|
||||
// S-row
|
||||
'さ': "sa", 'し': "shi", 'す': "su", 'せ': "se", 'そ': "so",
|
||||
// T-row
|
||||
'た': "ta", 'ち': "chi", 'つ': "tsu", 'て': "te", 'と': "to",
|
||||
// N-row
|
||||
'な': "na", 'に': "ni", 'ぬ': "nu", 'ね': "ne", 'の': "no",
|
||||
// H-row
|
||||
'は': "ha", 'ひ': "hi", 'ふ': "fu", 'へ': "he", 'ほ': "ho",
|
||||
// M-row
|
||||
'ま': "ma", 'み': "mi", 'む': "mu", 'め': "me", 'も': "mo",
|
||||
// Y-row
|
||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||
// R-row
|
||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||
// W-row
|
||||
'わ': "wa", 'を': "wo",
|
||||
// N
|
||||
'ん': "n",
|
||||
// Voiced (dakuten) - G-row
|
||||
'わ': "wa", 'を': "wo", 'ん': "n",
|
||||
// Dakuten (voiced)
|
||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||
// Z-row
|
||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||
// D-row
|
||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||
// B-row
|
||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||
// P-row (handakuten)
|
||||
// Handakuten (semi-voiced)
|
||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||
// Small characters
|
||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||
'っ': "", // Double consonant marker
|
||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||
'っ': "", // Small tsu - handled specially
|
||||
// Long vowel mark
|
||||
'ー': "",
|
||||
}
|
||||
|
||||
// katakanaToRomaji maps katakana characters to romaji
|
||||
var katakanaToRomaji = map[rune]string{
|
||||
// Basic vowels
|
||||
'ア': "a", 'イ': "i", 'ウ': "u", 'エ': "e", 'オ': "o",
|
||||
// K-row
|
||||
'カ': "ka", 'キ': "ki", 'ク': "ku", 'ケ': "ke", 'コ': "ko",
|
||||
// S-row
|
||||
'サ': "sa", 'シ': "shi", 'ス': "su", 'セ': "se", 'ソ': "so",
|
||||
// T-row
|
||||
'タ': "ta", 'チ': "chi", 'ツ': "tsu", 'テ': "te", 'ト': "to",
|
||||
// N-row
|
||||
'ナ': "na", 'ニ': "ni", 'ヌ': "nu", 'ネ': "ne", 'ノ': "no",
|
||||
// H-row
|
||||
'ハ': "ha", 'ヒ': "hi", 'フ': "fu", 'ヘ': "he", 'ホ': "ho",
|
||||
// M-row
|
||||
'マ': "ma", 'ミ': "mi", 'ム': "mu", 'メ': "me", 'モ': "mo",
|
||||
// Y-row
|
||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||
// R-row
|
||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||
// W-row
|
||||
'ワ': "wa", 'ヲ': "wo",
|
||||
// N
|
||||
'ン': "n",
|
||||
// Voiced (dakuten) - G-row
|
||||
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
||||
// Dakuten (voiced)
|
||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||
// Z-row
|
||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||
// D-row
|
||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||
// B-row
|
||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||
// P-row (handakuten)
|
||||
// Handakuten (semi-voiced)
|
||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||
// Small characters
|
||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||
'ッ': "", // Double consonant marker
|
||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||
'ッ': "", // Small tsu - handled specially
|
||||
// Extended katakana
|
||||
'ー': "", // Long vowel mark
|
||||
'ヴ': "vu",
|
||||
// Long vowel mark
|
||||
'ー': "",
|
||||
}
|
||||
|
||||
// Extended katakana combinations (multi-character)
|
||||
var katakanaExtended = map[string]string{
|
||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||
}
|
||||
|
||||
// Combination mappings for small ya/yu/yo
|
||||
var hiraganaCombo = map[string]string{
|
||||
var combinationHiragana = map[string]string{
|
||||
"きゃ": "kya", "きゅ": "kyu", "きょ": "kyo",
|
||||
"しゃ": "sha", "しゅ": "shu", "しょ": "sho",
|
||||
"ちゃ": "cha", "ちゅ": "chu", "ちょ": "cho",
|
||||
@@ -121,7 +70,7 @@ var hiraganaCombo = map[string]string{
|
||||
"ぴゃ": "pya", "ぴゅ": "pyu", "ぴょ": "pyo",
|
||||
}
|
||||
|
||||
var katakanaCombo = map[string]string{
|
||||
var combinationKatakana = map[string]string{
|
||||
"キャ": "kya", "キュ": "kyu", "キョ": "kyo",
|
||||
"シャ": "sha", "シュ": "shu", "ショ": "sho",
|
||||
"チャ": "cha", "チュ": "chu", "チョ": "cho",
|
||||
@@ -133,15 +82,12 @@ var katakanaCombo = map[string]string{
|
||||
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||
// Extended katakana combinations
|
||||
"ティ": "ti", "ディ": "di",
|
||||
"トゥ": "tu", "ドゥ": "du",
|
||||
// Extended combinations
|
||||
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||
"ヴァ": "va", "ヴィ": "vi", "ヴェ": "ve", "ヴォ": "vo",
|
||||
}
|
||||
|
||||
// ContainsJapanese checks if a string contains Japanese characters (Hiragana, Katakana, or Kanji)
|
||||
func ContainsJapanese(s string) bool {
|
||||
for _, r := range s {
|
||||
if isHiragana(r) || isKatakana(r) || isKanji(r) {
|
||||
@@ -151,107 +97,70 @@ func ContainsJapanese(s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ContainsKana checks if a string contains Hiragana or Katakana (convertible to romaji)
|
||||
func ContainsKana(s string) bool {
|
||||
for _, r := range s {
|
||||
if isHiragana(r) || isKatakana(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isHiragana(r rune) bool {
|
||||
return r >= hiraganaStart && r <= hiraganaEnd
|
||||
return r >= 0x3040 && r <= 0x309F
|
||||
}
|
||||
|
||||
func isKatakana(r rune) bool {
|
||||
return r >= katakanaStart && r <= katakanaEnd
|
||||
return r >= 0x30A0 && r <= 0x30FF
|
||||
}
|
||||
|
||||
func isKanji(r rune) bool {
|
||||
return r >= kanjiStart && r <= kanjiEnd
|
||||
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK Unified Ideographs
|
||||
(r >= 0x3400 && r <= 0x4DBF) // CJK Unified Ideographs Extension A
|
||||
}
|
||||
|
||||
// ToRomaji converts Japanese kana (Hiragana/Katakana) to romaji
|
||||
// Kanji characters are preserved as-is since they require dictionary lookup
|
||||
func ToRomaji(s string) string {
|
||||
if !ContainsKana(s) {
|
||||
return s
|
||||
func JapaneseToRomaji(text string) string {
|
||||
if !ContainsJapanese(text) {
|
||||
return text
|
||||
}
|
||||
|
||||
runes := []rune(s)
|
||||
var result strings.Builder
|
||||
result.Grow(len(s) * 2) // Romaji is typically longer
|
||||
|
||||
runes := []rune(text)
|
||||
i := 0
|
||||
|
||||
for i < len(runes) {
|
||||
r := runes[i]
|
||||
// Check for っ/ッ (double consonant)
|
||||
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
||||
nextRomaji := ""
|
||||
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
||||
nextRomaji = romaji
|
||||
} else if romaji, ok := katakanaToRomaji[runes[i+1]]; ok {
|
||||
nextRomaji = romaji
|
||||
}
|
||||
if len(nextRomaji) > 0 {
|
||||
result.WriteByte(nextRomaji[0]) // Double the first consonant
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for two-character combinations first
|
||||
if i+1 < len(runes) {
|
||||
// Check for two-character combinations
|
||||
if i < len(runes)-1 {
|
||||
combo := string(runes[i : i+2])
|
||||
if romaji, ok := hiraganaCombo[combo]; ok {
|
||||
if romaji, ok := combinationHiragana[combo]; ok {
|
||||
result.WriteString(romaji)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if romaji, ok := katakanaCombo[combo]; ok {
|
||||
if romaji, ok := combinationKatakana[combo]; ok {
|
||||
result.WriteString(romaji)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Handle small tsu (っ/ッ) - doubles the next consonant
|
||||
if r == 'っ' || r == 'ッ' {
|
||||
if i+1 < len(runes) {
|
||||
nextRune := runes[i+1]
|
||||
var nextRomaji string
|
||||
if romaji, ok := hiraganaToRomaji[nextRune]; ok {
|
||||
nextRomaji = romaji
|
||||
} else if romaji, ok := katakanaToRomaji[nextRune]; ok {
|
||||
nextRomaji = romaji
|
||||
}
|
||||
if len(nextRomaji) > 0 {
|
||||
result.WriteByte(nextRomaji[0]) // Double the consonant
|
||||
}
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle long vowel mark (ー)
|
||||
if r == 'ー' {
|
||||
// Extend the previous vowel
|
||||
resultStr := result.String()
|
||||
if len(resultStr) > 0 {
|
||||
lastChar := resultStr[len(resultStr)-1]
|
||||
if lastChar == 'a' || lastChar == 'i' || lastChar == 'u' || lastChar == 'e' || lastChar == 'o' {
|
||||
result.WriteByte(lastChar)
|
||||
}
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Single character conversion
|
||||
r := runes[i]
|
||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||
result.WriteString(romaji)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if romaji, ok := katakanaToRomaji[r]; ok {
|
||||
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
||||
result.WriteString(romaji)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Keep non-Japanese characters as-is
|
||||
if unicode.IsSpace(r) {
|
||||
result.WriteRune(' ')
|
||||
} else if isKanji(r) {
|
||||
// Keep kanji as-is (would need dictionary for proper conversion)
|
||||
result.WriteRune(r)
|
||||
} else {
|
||||
// Keep other characters (punctuation, spaces, etc.)
|
||||
result.WriteRune(r)
|
||||
}
|
||||
i++
|
||||
@@ -260,17 +169,43 @@ func ToRomaji(s string) string {
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// GetRomajiVariants returns search variants for Japanese text
|
||||
// Returns the original string plus romaji version if applicable
|
||||
func GetRomajiVariants(s string) []string {
|
||||
variants := []string{s}
|
||||
func BuildSearchQuery(trackName, artistName string) string {
|
||||
// Convert Japanese to romaji
|
||||
trackRomaji := JapaneseToRomaji(trackName)
|
||||
artistRomaji := JapaneseToRomaji(artistName)
|
||||
|
||||
if ContainsKana(s) {
|
||||
romaji := ToRomaji(s)
|
||||
if romaji != s && strings.TrimSpace(romaji) != "" {
|
||||
variants = append(variants, romaji)
|
||||
// Clean up the query - remove special characters that might interfere with search
|
||||
trackClean := cleanSearchQuery(trackRomaji)
|
||||
artistClean := cleanSearchQuery(artistRomaji)
|
||||
|
||||
return strings.TrimSpace(artistClean + " " + trackClean)
|
||||
}
|
||||
|
||||
func cleanSearchQuery(s string) string {
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsSpace(r) {
|
||||
result.WriteRune(r)
|
||||
} else if r == '-' || r == '\'' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return variants
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
func CleanToASCII(s string) string {
|
||||
var result strings.Builder
|
||||
for _, r := range s {
|
||||
// Keep only ASCII letters, numbers, spaces, and basic punctuation
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' {
|
||||
result.WriteRune(r)
|
||||
} else if r == ',' || r == '.' {
|
||||
// Convert punctuation to space
|
||||
result.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
// Clean up multiple spaces
|
||||
cleaned := strings.Join(strings.Fields(result.String()), " ")
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeSensitiveLogText(t *testing.T) {
|
||||
input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz"
|
||||
redacted := sanitizeSensitiveLogText(input)
|
||||
|
||||
if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") {
|
||||
t.Fatalf("expected sensitive values to be redacted, got: %s", redacted)
|
||||
}
|
||||
if !strings.Contains(redacted, "[REDACTED]") {
|
||||
t.Fatalf("expected redaction marker in output, got: %s", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExtensionAuthURL(t *testing.T) {
|
||||
if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil {
|
||||
t.Fatalf("expected valid auth URL, got error: %v", err)
|
||||
}
|
||||
|
||||
blocked := []string{
|
||||
"http://accounts.example.com/oauth/authorize",
|
||||
"https://user:pass@accounts.example.com/oauth/authorize",
|
||||
"https://localhost/oauth/authorize",
|
||||
}
|
||||
|
||||
for _, rawURL := range blocked {
|
||||
if err := validateExtensionAuthURL(rawURL); err == nil {
|
||||
t.Fatalf("expected URL to be blocked: %s", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"api.example.com"},
|
||||
},
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil {
|
||||
t.Fatal("expected embedded URL credentials to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStoreExtensionDestPath(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
|
||||
destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name")
|
||||
if err != nil {
|
||||
t.Fatalf("expected sanitized path to be generated, got error: %v", err)
|
||||
}
|
||||
|
||||
if !isPathWithinBase(baseDir, destPath) {
|
||||
t.Fatalf("expected destination path to remain under base dir: %s", destPath)
|
||||
}
|
||||
|
||||
baseName := filepath.Base(destPath)
|
||||
if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) {
|
||||
t.Fatalf("expected filename to be sanitized, got: %s", baseName)
|
||||
}
|
||||
if !strings.HasSuffix(baseName, ".spotiflac-ext") {
|
||||
t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName)
|
||||
}
|
||||
|
||||
if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil {
|
||||
t.Fatal("expected empty extension id to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -6,38 +6,49 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SongLinkClient handles song.link API interactions
|
||||
type SongLinkClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// TrackAvailability represents track availability on different platforms
|
||||
type TrackAvailability struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Tidal bool `json:"tidal"`
|
||||
Amazon bool `json:"amazon"`
|
||||
Qobuz bool `json:"qobuz"`
|
||||
TidalURL string `json:"tidal_url,omitempty"`
|
||||
AmazonURL string `json:"amazon_url,omitempty"`
|
||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Tidal bool `json:"tidal"`
|
||||
Amazon bool `json:"amazon"`
|
||||
Qobuz bool `json:"qobuz"`
|
||||
Deezer bool `json:"deezer"`
|
||||
YouTube bool `json:"youtube"`
|
||||
TidalURL string `json:"tidal_url,omitempty"`
|
||||
AmazonURL string `json:"amazon_url,omitempty"`
|
||||
QobuzURL string `json:"qobuz_url,omitempty"`
|
||||
DeezerURL string `json:"deezer_url,omitempty"`
|
||||
YouTubeURL string `json:"youtube_url,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
YouTubeID string `json:"youtube_id,omitempty"`
|
||||
}
|
||||
|
||||
// NewSongLinkClient creates a new SongLink client
|
||||
var (
|
||||
globalSongLinkClient *SongLinkClient
|
||||
songLinkClientOnce sync.Once
|
||||
)
|
||||
|
||||
func NewSongLinkClient() *SongLinkClient {
|
||||
return &SongLinkClient{
|
||||
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
|
||||
}
|
||||
songLinkClientOnce.Do(func() {
|
||||
globalSongLinkClient = &SongLinkClient{
|
||||
client: NewMetadataHTTPClient(SongLinkTimeout),
|
||||
}
|
||||
})
|
||||
return globalSongLinkClient
|
||||
}
|
||||
|
||||
// CheckTrackAvailability checks track availability on streaming platforms
|
||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||
// Use global rate limiter - blocks until request is allowed
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
// Build API URL
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||
|
||||
@@ -49,7 +60,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Use retry logic with User-Agent
|
||||
retryConfig := DefaultRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
@@ -57,6 +67,308 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 400 {
|
||||
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
||||
}
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{
|
||||
SpotifyID: spotifyTrackID,
|
||||
}
|
||||
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
|
||||
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
|
||||
// Fallback to regular youtube if youtubeMusic not available
|
||||
if !availability.YouTube {
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
|
||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
urls := make(map[string]string)
|
||||
if availability.TidalURL != "" {
|
||||
urls["tidal"] = availability.TidalURL
|
||||
}
|
||||
if availability.AmazonURL != "" {
|
||||
urls["amazon"] = availability.AmazonURL
|
||||
}
|
||||
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
||||
func extractDeezerIDFromURL(deezerURL string) string {
|
||||
parts := strings.Split(deezerURL, "/")
|
||||
if len(parts) > 0 {
|
||||
lastPart := parts[len(parts)-1]
|
||||
if idx := strings.Index(lastPart, "?"); idx > 0 {
|
||||
lastPart = lastPart[:idx]
|
||||
}
|
||||
return lastPart
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractQobuzIDFromURL extracts Qobuz track ID from URL
|
||||
// URL formats:
|
||||
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
|
||||
// - https://open.qobuz.com/track/12345678
|
||||
// - https://www.qobuz.com/track/12345678
|
||||
// - https://play.qobuz.com/track/12345678
|
||||
func extractQobuzIDFromURL(qobuzURL string) string {
|
||||
if qobuzURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Try to find /track/ID pattern first
|
||||
if strings.Contains(qobuzURL, "/track/") {
|
||||
parts := strings.Split(qobuzURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
idPart := parts[1]
|
||||
// Remove query parameters
|
||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
// Remove trailing slash or path
|
||||
if idx := strings.Index(idPart, "/"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
// Validate it's a number
|
||||
if idPart != "" && isNumeric(idPart) {
|
||||
return idPart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract from album URL with track highlight
|
||||
// Format: /album/albumname/trackid or ?trackId=12345678
|
||||
if strings.Contains(qobuzURL, "trackId=") {
|
||||
parts := strings.Split(qobuzURL, "trackId=")
|
||||
if len(parts) > 1 {
|
||||
idPart := parts[1]
|
||||
if idx := strings.Index(idPart, "&"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
if idPart != "" && isNumeric(idPart) {
|
||||
return idPart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: get last numeric segment from URL
|
||||
parts := strings.Split(qobuzURL, "/")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
part := parts[i]
|
||||
// Remove query parameters
|
||||
if idx := strings.Index(part, "?"); idx > 0 {
|
||||
part = part[:idx]
|
||||
}
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" && isNumeric(part) {
|
||||
return part
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractTidalIDFromURL extracts Tidal track ID from URL
|
||||
// URL formats:
|
||||
// - https://tidal.com/browse/track/12345678
|
||||
// - https://listen.tidal.com/track/12345678
|
||||
func extractTidalIDFromURL(tidalURL string) string {
|
||||
if tidalURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.Contains(tidalURL, "/track/") {
|
||||
parts := strings.Split(tidalURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
idPart := parts[1]
|
||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
if idx := strings.Index(idPart, "/"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
if idPart != "" && isNumeric(idPart) {
|
||||
return idPart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractYouTubeIDFromURL extracts YouTube video ID from URL
|
||||
// URL formats:
|
||||
// - https://www.youtube.com/watch?v=VIDEO_ID
|
||||
// - https://youtu.be/VIDEO_ID
|
||||
// - https://music.youtube.com/watch?v=VIDEO_ID
|
||||
func extractYouTubeIDFromURL(youtubeURL string) string {
|
||||
if youtubeURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Handle youtu.be short URLs
|
||||
if strings.Contains(youtubeURL, "youtu.be/") {
|
||||
parts := strings.Split(youtubeURL, "youtu.be/")
|
||||
if len(parts) >= 2 {
|
||||
idPart := parts[1]
|
||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
if idx := strings.Index(idPart, "&"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
return strings.TrimSpace(idPart)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle youtube.com URLs with ?v= parameter
|
||||
parsed, err := url.Parse(youtubeURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if v := parsed.Query().Get("v"); v != "" {
|
||||
return v
|
||||
}
|
||||
|
||||
// Handle /embed/ format
|
||||
if strings.Contains(parsed.Path, "/embed/") {
|
||||
parts := strings.Split(parsed.Path, "/embed/")
|
||||
if len(parts) >= 2 {
|
||||
return strings.Split(parts[1], "/")[0]
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isNumeric is defined in library_scan.go
|
||||
|
||||
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
|
||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !availability.Deezer || availability.DeezerID == "" {
|
||||
return "", fmt.Errorf("track not found on Deezer")
|
||||
}
|
||||
|
||||
return availability.DeezerID, nil
|
||||
}
|
||||
|
||||
// GetYouTubeURLFromSpotify converts a Spotify track ID to YouTube URL using SongLink
|
||||
func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string, error) {
|
||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !availability.YouTube || availability.YouTubeURL == "" {
|
||||
return "", fmt.Errorf("track not found on YouTube")
|
||||
}
|
||||
|
||||
return availability.YouTubeURL, nil
|
||||
}
|
||||
|
||||
// AlbumAvailability represents album availability on different platforms
|
||||
type AlbumAvailability struct {
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
Deezer bool `json:"deezer"`
|
||||
DeezerURL string `json:"deezer_url,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL2FsYnVtLw==")
|
||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyAlbumID)
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check album availability: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
@@ -76,78 +388,401 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{
|
||||
SpotifyID: spotifyTrackID,
|
||||
availability := &AlbumAvailability{
|
||||
SpotifyID: spotifyAlbumID,
|
||||
}
|
||||
|
||||
// Check Tidal
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
}
|
||||
|
||||
// Check Amazon
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
|
||||
// Check Qobuz using ISRC
|
||||
if isrc != "" {
|
||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// GetStreamingURLs gets streaming URLs for a Spotify track
|
||||
func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]string, error) {
|
||||
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
|
||||
// GetDeezerAlbumIDFromSpotify converts a Spotify album ID to Deezer album ID using SongLink
|
||||
func (s *SongLinkClient) GetDeezerAlbumIDFromSpotify(spotifyAlbumID string) (string, error) {
|
||||
availability, err := s.CheckAlbumAvailability(spotifyAlbumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
urls := make(map[string]string)
|
||||
if availability.TidalURL != "" {
|
||||
urls["tidal"] = availability.TidalURL
|
||||
}
|
||||
if availability.AmazonURL != "" {
|
||||
urls["amazon"] = availability.AmazonURL
|
||||
if !availability.Deezer || availability.DeezerID == "" {
|
||||
return "", fmt.Errorf("album not found on Deezer")
|
||||
}
|
||||
|
||||
return urls, nil
|
||||
return availability.DeezerID, nil
|
||||
}
|
||||
|
||||
func checkQobuzAvailability(isrc string) bool {
|
||||
client := NewHTTPClientWithTimeout(10 * time.Second)
|
||||
appID := "798273057"
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
// This is useful when we have Deezer metadata and want to find the track on other platforms
|
||||
func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) {
|
||||
if deezerTrackID == "" {
|
||||
return nil, fmt.Errorf("deezer track ID is empty")
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(client, req)
|
||||
availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID)
|
||||
if err != nil {
|
||||
return false
|
||||
LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err)
|
||||
idhsClient := NewIDHSClient()
|
||||
availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err)
|
||||
}
|
||||
LogInfo("SongLink", "IDHS fallback successful for Deezer %s", deezerTrackID)
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer
|
||||
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 400 {
|
||||
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
||||
}
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return false
|
||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp struct {
|
||||
Tracks struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return false
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
return searchResp.Tracks.Total > 0
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
EntitiesByUniqueId map[string]struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
ArtistName string `json:"artistName"`
|
||||
} `json:"entitiesByUniqueId"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{
|
||||
Deezer: true,
|
||||
DeezerID: deezerTrackID,
|
||||
}
|
||||
|
||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||
}
|
||||
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
}
|
||||
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// platform: "spotify", "deezer", "tidal", "amazonMusic", "appleMusic", "youtube", etc.
|
||||
// entityType: "song" or "album"
|
||||
// entityID: the ID on that platform
|
||||
func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entityID string) (*TrackAvailability, error) {
|
||||
if entityID == "" {
|
||||
return nil, fmt.Errorf("%s ID is empty", platform)
|
||||
}
|
||||
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.song.link/v1-alpha.1/links?platform=%s&type=%s&id=%s&userCountry=US",
|
||||
url.QueryEscape(platform),
|
||||
url.QueryEscape(entityType),
|
||||
url.QueryEscape(entityID))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 400 {
|
||||
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
||||
}
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{}
|
||||
|
||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||
}
|
||||
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
||||
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||
parts := strings.Split(spotifyURL, "/track/")
|
||||
if len(parts) > 1 {
|
||||
idPart := parts[1]
|
||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||
idPart = idPart[:idx]
|
||||
}
|
||||
return idPart
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) GetSpotifyIDFromDeezer(deezerTrackID string) (string, error) {
|
||||
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if availability.SpotifyID == "" {
|
||||
return "", fmt.Errorf("track not found on Spotify")
|
||||
}
|
||||
|
||||
return availability.SpotifyID, nil
|
||||
}
|
||||
|
||||
// GetTidalURLFromDeezer converts a Deezer track ID to Tidal URL using SongLink
|
||||
func (s *SongLinkClient) GetTidalURLFromDeezer(deezerTrackID string) (string, error) {
|
||||
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !availability.Tidal || availability.TidalURL == "" {
|
||||
return "", fmt.Errorf("track not found on Tidal")
|
||||
}
|
||||
|
||||
return availability.TidalURL, nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, error) {
|
||||
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !availability.Amazon || availability.AmazonURL == "" {
|
||||
return "", fmt.Errorf("track not found on Amazon Music")
|
||||
}
|
||||
|
||||
return availability.AmazonURL, nil
|
||||
}
|
||||
|
||||
// GetYouTubeURLFromDeezer converts a Deezer track ID to YouTube URL using SongLink
|
||||
func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, error) {
|
||||
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !availability.YouTube || availability.YouTubeURL == "" {
|
||||
return "", fmt.Errorf("track not found on YouTube")
|
||||
}
|
||||
|
||||
return availability.YouTubeURL, nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(inputURL))
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := DefaultRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 400 || resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on SongLink")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
EntityID string `json:"entityUniqueId"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{}
|
||||
|
||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||
}
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
|
||||
|
||||
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
|
||||
// This is used as a fallback when direct Spotify API access is blocked/limited.
|
||||
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL, apiBaseURL string) (interface{}, error) {
|
||||
parsed, err := parseSpotifyURI(spotifyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid Spotify URL: %w", err)
|
||||
}
|
||||
|
||||
base := strings.TrimSpace(apiBaseURL)
|
||||
if base == "" {
|
||||
base = DefaultSpotFetchAPIBaseURL
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(base, "/"), parsed.Type, parsed.ID)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SpotFetch API request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read SpotFetch API response: %w", err)
|
||||
}
|
||||
|
||||
switch parsed.Type {
|
||||
case "track":
|
||||
var trackResp TrackResponse
|
||||
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode track response: %w", err)
|
||||
}
|
||||
return trackResp, nil
|
||||
case "album":
|
||||
var albumResp AlbumResponsePayload
|
||||
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode album response: %w", err)
|
||||
}
|
||||
return &albumResp, nil
|
||||
case "playlist":
|
||||
var playlistResp PlaylistResponsePayload
|
||||
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
|
||||
}
|
||||
return playlistResp, nil
|
||||
case "artist":
|
||||
var artistResp ArtistResponsePayload
|
||||
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode artist response: %w", err)
|
||||
}
|
||||
return &artistResp, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -10,59 +9,123 @@ import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||
spotifyTokenURL = "https://accounts.spotify.com/api/token"
|
||||
playlistBaseURL = "https://api.spotify.com/v1/playlists/%s"
|
||||
albumBaseURL = "https://api.spotify.com/v1/albums/%s"
|
||||
trackBaseURL = "https://api.spotify.com/v1/tracks/%s"
|
||||
artistBaseURL = "https://api.spotify.com/v1/artists/%s"
|
||||
artistAlbumsURL = "https://api.spotify.com/v1/artists/%s/albums"
|
||||
searchBaseURL = "https://api.spotify.com/v1/search"
|
||||
|
||||
artistCacheTTL = 10 * time.Minute
|
||||
searchCacheTTL = 5 * time.Minute
|
||||
albumCacheTTL = 10 * time.Minute
|
||||
)
|
||||
|
||||
var errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL")
|
||||
|
||||
// SpotifyMetadataClient handles Spotify API interactions
|
||||
type cacheEntry struct {
|
||||
data interface{}
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func (e *cacheEntry) isExpired() bool {
|
||||
return time.Now().After(e.expiresAt)
|
||||
}
|
||||
|
||||
type SpotifyMetadataClient struct {
|
||||
httpClient *http.Client
|
||||
clientID string
|
||||
clientSecret string
|
||||
cachedToken string
|
||||
tokenExpiresAt time.Time
|
||||
tokenMu sync.Mutex
|
||||
rng *rand.Rand
|
||||
rngMu sync.Mutex
|
||||
userAgent string
|
||||
|
||||
artistCache map[string]*cacheEntry
|
||||
searchCache map[string]*cacheEntry
|
||||
albumCache map[string]*cacheEntry
|
||||
cacheMu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewSpotifyMetadataClient creates a new Spotify client
|
||||
func NewSpotifyMetadataClient() *SpotifyMetadataClient {
|
||||
var (
|
||||
customClientID string
|
||||
customClientSecret string
|
||||
credentialsMu sync.RWMutex
|
||||
)
|
||||
|
||||
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
|
||||
|
||||
func SetSpotifyCredentials(clientID, clientSecret string) {
|
||||
credentialsMu.Lock()
|
||||
defer credentialsMu.Unlock()
|
||||
customClientID = clientID
|
||||
customClientSecret = clientSecret
|
||||
}
|
||||
|
||||
func HasSpotifyCredentials() bool {
|
||||
credentialsMu.RLock()
|
||||
defer credentialsMu.RUnlock()
|
||||
|
||||
if customClientID != "" && customClientSecret != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func getCredentials() (string, string, error) {
|
||||
credentialsMu.RLock()
|
||||
defer credentialsMu.RUnlock()
|
||||
|
||||
if customClientID != "" && customClientSecret != "" {
|
||||
return customClientID, customClientSecret, nil
|
||||
}
|
||||
|
||||
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||
|
||||
if clientID != "" && clientSecret != "" {
|
||||
return clientID, clientSecret, nil
|
||||
}
|
||||
|
||||
return "", "", ErrNoSpotifyCredentials
|
||||
}
|
||||
|
||||
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
||||
clientID, clientSecret, err := getCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
src := rand.NewSource(time.Now().UnixNano())
|
||||
|
||||
// Decode credentials from base64
|
||||
clientID := ""
|
||||
if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil {
|
||||
clientID = string(decoded)
|
||||
}
|
||||
|
||||
clientSecret := ""
|
||||
if decoded, err := base64.StdEncoding.DecodeString("MjEyNDc2ZDliMGYzNDcyZWFhNzYyZDkwYjE5YjBiYTg="); err == nil {
|
||||
clientSecret = string(decoded)
|
||||
}
|
||||
|
||||
c := &SpotifyMetadataClient{
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
rng: rand.New(src),
|
||||
artistCache: make(map[string]*cacheEntry),
|
||||
searchCache: make(map[string]*cacheEntry),
|
||||
albumCache: make(map[string]*cacheEntry),
|
||||
}
|
||||
c.userAgent = c.randomUserAgent()
|
||||
return c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// TrackMetadata represents track information
|
||||
type TrackMetadata struct {
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
Artists string `json:"artists"`
|
||||
@@ -77,9 +140,9 @@ type TrackMetadata struct {
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
}
|
||||
|
||||
// AlbumTrackMetadata holds per-track info for album/playlist
|
||||
type AlbumTrackMetadata struct {
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
Artists string `json:"artists"`
|
||||
@@ -96,24 +159,26 @@ type AlbumTrackMetadata struct {
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
AlbumURL string `json:"album_url,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
}
|
||||
|
||||
// AlbumInfoMetadata holds album information
|
||||
type AlbumInfoMetadata struct {
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
Name string `json:"name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Artists string `json:"artists"`
|
||||
ArtistId string `json:"artist_id,omitempty"`
|
||||
Images string `json:"images"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
}
|
||||
|
||||
// AlbumResponsePayload is the response for album requests
|
||||
type AlbumResponsePayload struct {
|
||||
AlbumInfo AlbumInfoMetadata `json:"album_info"`
|
||||
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||
}
|
||||
|
||||
// PlaylistInfoMetadata holds playlist information
|
||||
type PlaylistInfoMetadata struct {
|
||||
Tracks struct {
|
||||
Total int `json:"total"`
|
||||
@@ -125,23 +190,76 @@ type PlaylistInfoMetadata struct {
|
||||
} `json:"owner"`
|
||||
}
|
||||
|
||||
// PlaylistResponsePayload is the response for playlist requests
|
||||
type PlaylistResponsePayload struct {
|
||||
PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"`
|
||||
TrackList []AlbumTrackMetadata `json:"track_list"`
|
||||
}
|
||||
|
||||
// TrackResponse is the response for single track requests
|
||||
type ArtistInfoMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images string `json:"images"`
|
||||
Followers int `json:"followers"`
|
||||
Popularity int `json:"popularity"`
|
||||
}
|
||||
|
||||
type ArtistAlbumMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
Images string `json:"images"`
|
||||
AlbumType string `json:"album_type"`
|
||||
Artists string `json:"artists"`
|
||||
}
|
||||
|
||||
type ArtistResponsePayload struct {
|
||||
ArtistInfo ArtistInfoMetadata `json:"artist_info"`
|
||||
Albums []ArtistAlbumMetadata `json:"albums"`
|
||||
}
|
||||
|
||||
type TrackResponse struct {
|
||||
Track TrackMetadata `json:"track"`
|
||||
}
|
||||
|
||||
// SearchResult represents search results
|
||||
type SearchResult struct {
|
||||
Tracks []TrackMetadata `json:"tracks"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type SearchArtistResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images string `json:"images"`
|
||||
Followers int `json:"followers"`
|
||||
Popularity int `json:"popularity"`
|
||||
}
|
||||
|
||||
type SearchAlbumResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
Images string `json:"images"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
AlbumType string `json:"album_type"`
|
||||
}
|
||||
|
||||
type SearchPlaylistResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
Images string `json:"images"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
}
|
||||
|
||||
type SearchAllResult struct {
|
||||
Tracks []TrackMetadata `json:"tracks"`
|
||||
Artists []SearchArtistResult `json:"artists"`
|
||||
Albums []SearchAlbumResult `json:"albums"`
|
||||
Playlists []SearchPlaylistResult `json:"playlists"`
|
||||
}
|
||||
|
||||
type spotifyURI struct {
|
||||
Type string
|
||||
ID string
|
||||
@@ -153,7 +271,6 @@ type accessTokenResponse struct {
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// Internal API response types
|
||||
type image struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
@@ -179,6 +296,7 @@ type albumSimplified struct {
|
||||
Images []image `json:"images"`
|
||||
ExternalURL externalURL `json:"external_urls"`
|
||||
Artists []artist `json:"artists"`
|
||||
AlbumType string `json:"album_type"`
|
||||
}
|
||||
|
||||
type trackFull struct {
|
||||
@@ -193,7 +311,6 @@ type trackFull struct {
|
||||
Artists []artist `json:"artists"`
|
||||
}
|
||||
|
||||
// GetFilteredData fetches and formats Spotify data
|
||||
func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) {
|
||||
parsed, err := parseSpotifyURI(spotifyURL)
|
||||
if err != nil {
|
||||
@@ -212,12 +329,13 @@ func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL
|
||||
return c.fetchAlbum(ctx, parsed.ID, token)
|
||||
case "playlist":
|
||||
return c.fetchPlaylist(ctx, parsed.ID, token)
|
||||
case "artist":
|
||||
return c.fetchArtist(ctx, parsed.ID, token)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// SearchTracks searches for tracks on Spotify
|
||||
func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string, limit int) (*SearchResult, error) {
|
||||
token, err := c.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
@@ -225,14 +343,14 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
}
|
||||
|
||||
searchURL := fmt.Sprintf("%s?q=%s&type=track&limit=%d", searchBaseURL, url.QueryEscape(query), limit)
|
||||
|
||||
|
||||
var response struct {
|
||||
Tracks struct {
|
||||
Items []trackFull `json:"items"`
|
||||
Total int `json:"total"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
|
||||
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -257,12 +375,101 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
||||
DiscNumber: track.DiscNumber,
|
||||
ExternalURL: track.ExternalURL.Spotify,
|
||||
ISRC: track.ExternalID.ISRC,
|
||||
AlbumType: track.Album.AlbumType,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||
c.cacheMu.RUnlock()
|
||||
return entry.data.(*SearchAllResult), nil
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
token, err := c.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
searchURL := fmt.Sprintf("%s?q=%s&type=track,artist&limit=%d", searchBaseURL, url.QueryEscape(query), trackLimit)
|
||||
|
||||
var response struct {
|
||||
Tracks struct {
|
||||
Items []trackFull `json:"items"`
|
||||
} `json:"tracks"`
|
||||
Artists struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
Followers struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"followers"`
|
||||
Popularity int `json:"popularity"`
|
||||
} `json:"items"`
|
||||
} `json:"artists"`
|
||||
}
|
||||
|
||||
if err := c.getJSON(ctx, searchURL, token, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &SearchAllResult{
|
||||
Tracks: make([]TrackMetadata, 0, len(response.Tracks.Items)),
|
||||
Artists: make([]SearchArtistResult, 0, len(response.Artists.Items)),
|
||||
}
|
||||
|
||||
for _, track := range response.Tracks.Items {
|
||||
result.Tracks = append(result.Tracks, TrackMetadata{
|
||||
SpotifyID: track.ID,
|
||||
Artists: joinArtists(track.Artists),
|
||||
Name: track.Name,
|
||||
AlbumName: track.Album.Name,
|
||||
AlbumArtist: joinArtists(track.Album.Artists),
|
||||
DurationMS: track.DurationMS,
|
||||
Images: firstImageURL(track.Album.Images),
|
||||
ReleaseDate: track.Album.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
TotalTracks: track.Album.TotalTracks,
|
||||
DiscNumber: track.DiscNumber,
|
||||
ExternalURL: track.ExternalURL.Spotify,
|
||||
ISRC: track.ExternalID.ISRC,
|
||||
AlbumType: track.Album.AlbumType,
|
||||
})
|
||||
}
|
||||
|
||||
artistCount := len(response.Artists.Items)
|
||||
if artistCount > artistLimit {
|
||||
artistCount = artistLimit
|
||||
}
|
||||
|
||||
for i := 0; i < artistCount; i++ {
|
||||
artist := response.Artists.Items[i]
|
||||
result.Artists = append(result.Artists, SearchArtistResult{
|
||||
ID: artist.ID,
|
||||
Name: artist.Name,
|
||||
Images: firstImageURL(artist.Images),
|
||||
Followers: artist.Followers.Total,
|
||||
Popularity: artist.Popularity,
|
||||
})
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
c.searchCache[cacheKey] = &cacheEntry{
|
||||
data: result,
|
||||
expiresAt: time.Now().Add(searchCacheTTL),
|
||||
}
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*TrackResponse, error) {
|
||||
var data trackFull
|
||||
if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil {
|
||||
@@ -289,6 +496,23 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token s
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||
c.cacheMu.RUnlock()
|
||||
return entry.data.(*AlbumResponsePayload), nil
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
type trackItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
ExternalURL externalURL `json:"external_urls"`
|
||||
Artists []artist `json:"artists"`
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Name string `json:"name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
@@ -296,15 +520,8 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
Images []image `json:"images"`
|
||||
Artists []artist `json:"artists"`
|
||||
Tracks struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
ExternalURL externalURL `json:"external_urls"`
|
||||
Artists []artist `json:"artists"`
|
||||
} `json:"items"`
|
||||
Items []trackItem `json:"items"`
|
||||
Next string `json:"next"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
@@ -313,19 +530,50 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
}
|
||||
|
||||
albumImage := firstImageURL(data.Images)
|
||||
|
||||
var firstArtistId string
|
||||
if len(data.Artists) > 0 {
|
||||
firstArtistId = data.Artists[0].ID
|
||||
}
|
||||
|
||||
info := AlbumInfoMetadata{
|
||||
TotalTracks: data.TotalTracks,
|
||||
Name: data.Name,
|
||||
ReleaseDate: data.ReleaseDate,
|
||||
Artists: joinArtists(data.Artists),
|
||||
ArtistId: firstArtistId,
|
||||
Images: albumImage,
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items))
|
||||
for _, item := range data.Tracks.Items {
|
||||
// Fetch ISRC for each track
|
||||
isrc := c.fetchTrackISRC(ctx, item.ID, token)
|
||||
|
||||
allTrackItems := data.Tracks.Items
|
||||
nextURL := data.Tracks.Next
|
||||
|
||||
for nextURL != "" {
|
||||
var pageData struct {
|
||||
Items []trackItem `json:"items"`
|
||||
Next string `json:"next"`
|
||||
}
|
||||
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
||||
fmt.Printf("[Spotify] Warning: failed to fetch album tracks page: %v\n", err)
|
||||
break
|
||||
}
|
||||
allTrackItems = append(allTrackItems, pageData.Items...)
|
||||
nextURL = pageData.Next
|
||||
}
|
||||
|
||||
fmt.Printf("[Spotify] Album has %d tracks (total: %d)\n", len(allTrackItems), data.TotalTracks)
|
||||
|
||||
trackIDs := make([]string, len(allTrackItems))
|
||||
for i, item := range allTrackItems {
|
||||
trackIDs[i] = item.ID
|
||||
}
|
||||
|
||||
isrcMap := c.fetchISRCsParallel(ctx, trackIDs, token)
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(allTrackItems))
|
||||
for _, item := range allTrackItems {
|
||||
isrc := isrcMap[item.ID]
|
||||
|
||||
tracks = append(tracks, AlbumTrackMetadata{
|
||||
SpotifyID: item.ID,
|
||||
Artists: joinArtists(item.Artists),
|
||||
@@ -344,10 +592,56 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
||||
})
|
||||
}
|
||||
|
||||
return &AlbumResponsePayload{
|
||||
result := &AlbumResponsePayload{
|
||||
AlbumInfo: info,
|
||||
TrackList: tracks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
c.albumCache[albumID] = &cacheEntry{
|
||||
data: result,
|
||||
expiresAt: time.Now().Add(albumCacheTTL),
|
||||
}
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchISRCsParallel(ctx context.Context, trackIDs []string, token string) map[string]string {
|
||||
const maxParallelISRC = 10
|
||||
|
||||
result := make(map[string]string)
|
||||
var resultMu sync.Mutex
|
||||
|
||||
if len(trackIDs) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, maxParallelISRC)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, trackID := range trackIDs {
|
||||
wg.Add(1)
|
||||
go func(id string) {
|
||||
defer wg.Done()
|
||||
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
isrc := c.fetchTrackISRC(ctx, id, token)
|
||||
|
||||
resultMu.Lock()
|
||||
result[id] = isrc
|
||||
resultMu.Unlock()
|
||||
}(trackID)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
||||
@@ -361,7 +655,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
Items []struct {
|
||||
Track *trackFull `json:"track"`
|
||||
} `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Total int `json:"total"`
|
||||
Next string `json:"next"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
@@ -375,7 +670,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
info.Owner.Name = data.Name
|
||||
info.Owner.Images = firstImageURL(data.Images)
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items))
|
||||
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
|
||||
|
||||
for _, item := range data.Tracks.Items {
|
||||
if item.Track == nil {
|
||||
continue
|
||||
@@ -399,12 +695,149 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
||||
})
|
||||
}
|
||||
|
||||
nextURL := data.Tracks.Next
|
||||
|
||||
for nextURL != "" {
|
||||
var pageData struct {
|
||||
Items []struct {
|
||||
Track *trackFull `json:"track"`
|
||||
} `json:"items"`
|
||||
Next string `json:"next"`
|
||||
}
|
||||
|
||||
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
||||
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
|
||||
break
|
||||
}
|
||||
|
||||
for _, item := range pageData.Items {
|
||||
if item.Track == nil {
|
||||
continue
|
||||
}
|
||||
tracks = append(tracks, AlbumTrackMetadata{
|
||||
SpotifyID: item.Track.ID,
|
||||
Artists: joinArtists(item.Track.Artists),
|
||||
Name: item.Track.Name,
|
||||
AlbumName: item.Track.Album.Name,
|
||||
AlbumArtist: joinArtists(item.Track.Album.Artists),
|
||||
DurationMS: item.Track.DurationMS,
|
||||
Images: firstImageURL(item.Track.Album.Images),
|
||||
ReleaseDate: item.Track.Album.ReleaseDate,
|
||||
TrackNumber: item.Track.TrackNumber,
|
||||
TotalTracks: item.Track.Album.TotalTracks,
|
||||
DiscNumber: item.Track.DiscNumber,
|
||||
ExternalURL: item.Track.ExternalURL.Spotify,
|
||||
ISRC: item.Track.ExternalID.ISRC,
|
||||
AlbumID: item.Track.Album.ID,
|
||||
AlbumURL: item.Track.Album.ExternalURL.Spotify,
|
||||
})
|
||||
}
|
||||
|
||||
nextURL = pageData.Next
|
||||
}
|
||||
|
||||
fmt.Printf("[Spotify] Fetched %d tracks from playlist (total: %d)\n", len(tracks), data.Tracks.Total)
|
||||
|
||||
return &PlaylistResponsePayload{
|
||||
PlaylistInfo: info,
|
||||
TrackList: tracks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
||||
c.cacheMu.RLock()
|
||||
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||
c.cacheMu.RUnlock()
|
||||
return entry.data.(*ArtistResponsePayload), nil
|
||||
}
|
||||
c.cacheMu.RUnlock()
|
||||
|
||||
var artistData struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Images []image `json:"images"`
|
||||
Followers struct {
|
||||
Total int `json:"total"`
|
||||
} `json:"followers"`
|
||||
Popularity int `json:"popularity"`
|
||||
}
|
||||
|
||||
if err := c.getJSON(ctx, fmt.Sprintf(artistBaseURL, artistID), token, &artistData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
artistInfo := ArtistInfoMetadata{
|
||||
ID: artistData.ID,
|
||||
Name: artistData.Name,
|
||||
Images: firstImageURL(artistData.Images),
|
||||
Followers: artistData.Followers.Total,
|
||||
Popularity: artistData.Popularity,
|
||||
}
|
||||
|
||||
albums := make([]ArtistAlbumMetadata, 0)
|
||||
offset := 0
|
||||
limit := 50
|
||||
|
||||
for {
|
||||
albumsURL := fmt.Sprintf("%s?include_groups=album,single,compilation&limit=%d&offset=%d",
|
||||
fmt.Sprintf(artistAlbumsURL, artistID), limit, offset)
|
||||
|
||||
var albumsData struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
Images []image `json:"images"`
|
||||
AlbumType string `json:"album_type"`
|
||||
Artists []artist `json:"artists"`
|
||||
ExternalURL externalURL `json:"external_urls"`
|
||||
} `json:"items"`
|
||||
Next string `json:"next"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
if err := c.getJSON(ctx, albumsURL, token, &albumsData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, album := range albumsData.Items {
|
||||
albums = append(albums, ArtistAlbumMetadata{
|
||||
ID: album.ID,
|
||||
Name: album.Name,
|
||||
ReleaseDate: album.ReleaseDate,
|
||||
TotalTracks: album.TotalTracks,
|
||||
Images: firstImageURL(album.Images),
|
||||
AlbumType: album.AlbumType,
|
||||
Artists: joinArtists(album.Artists),
|
||||
})
|
||||
}
|
||||
|
||||
if albumsData.Next == "" || len(albumsData.Items) < limit {
|
||||
break
|
||||
}
|
||||
offset += limit
|
||||
|
||||
if offset > 500 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
result := &ArtistResponsePayload{
|
||||
ArtistInfo: artistInfo,
|
||||
Albums: albums,
|
||||
}
|
||||
|
||||
c.cacheMu.Lock()
|
||||
c.artistCache[artistID] = &cacheEntry{
|
||||
data: result,
|
||||
expiresAt: time.Now().Add(artistCacheTTL),
|
||||
}
|
||||
c.cacheMu.Unlock()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string) string {
|
||||
var data struct {
|
||||
ExternalID externalID `json:"external_ids"`
|
||||
@@ -467,6 +900,13 @@ func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token str
|
||||
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||||
req.Header.Set("sec-ch-ua-platform", "\"Windows\"")
|
||||
req.Header.Set("sec-fetch-dest", "empty")
|
||||
req.Header.Set("sec-fetch-mode", "cors")
|
||||
req.Header.Set("sec-fetch-site", "same-origin")
|
||||
req.Header.Set("Referer", "https://open.spotify.com/")
|
||||
req.Header.Set("Origin", "https://open.spotify.com")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
@@ -493,13 +933,22 @@ func (c *SpotifyMetadataClient) randomUserAgent() string {
|
||||
c.rngMu.Lock()
|
||||
defer c.rngMu.Unlock()
|
||||
|
||||
chromeMajor := 80 + c.rng.Intn(25)
|
||||
chromeBuild := 3000 + c.rng.Intn(1500)
|
||||
chromePatch := 60 + c.rng.Intn(65)
|
||||
macMajor := c.rng.Intn(4) + 11
|
||||
macMinor := c.rng.Intn(5) + 4
|
||||
webkitMajor := c.rng.Intn(7) + 530
|
||||
webkitMinor := c.rng.Intn(7) + 30
|
||||
chromeMajor := c.rng.Intn(25) + 80
|
||||
chromeBuild := c.rng.Intn(1500) + 3000
|
||||
chromePatch := c.rng.Intn(65) + 60
|
||||
safariMajor := c.rng.Intn(7) + 530
|
||||
safariMinor := c.rng.Intn(6) + 30
|
||||
|
||||
return fmt.Sprintf(
|
||||
"Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d",
|
||||
macMajor, macMinor,
|
||||
webkitMajor, webkitMinor,
|
||||
chromeMajor, chromeBuild, chromePatch,
|
||||
safariMajor, safariMinor,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -509,7 +958,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
return spotifyURI{}, errInvalidSpotifyURL
|
||||
}
|
||||
|
||||
// Handle spotify: URI format
|
||||
if strings.HasPrefix(trimmed, "spotify:") {
|
||||
parts := strings.Split(trimmed, ":")
|
||||
if len(parts) == 3 {
|
||||
@@ -520,13 +968,11 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle URL format
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return spotifyURI{}, err
|
||||
}
|
||||
|
||||
// Handle embed.spotify.com URLs
|
||||
if parsed.Host == "embed.spotify.com" {
|
||||
if parsed.RawQuery == "" {
|
||||
return spotifyURI{}, errInvalidSpotifyURL
|
||||
@@ -539,7 +985,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
return parseSpotifyURI(embedded)
|
||||
}
|
||||
|
||||
// Handle plain ID (no scheme/host) - defaults to playlist
|
||||
if parsed.Scheme == "" && parsed.Host == "" {
|
||||
id := strings.Trim(strings.TrimSpace(parsed.Path), "/")
|
||||
if id == "" {
|
||||
@@ -565,7 +1010,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
return spotifyURI{}, errInvalidSpotifyURL
|
||||
}
|
||||
|
||||
// Skip intl- prefix if present
|
||||
if strings.HasPrefix(parts[0], "intl-") {
|
||||
parts = parts[1:]
|
||||
}
|
||||
@@ -573,7 +1017,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
return spotifyURI{}, errInvalidSpotifyURL
|
||||
}
|
||||
|
||||
// Handle standard URLs: /album/{id}, /track/{id}, /playlist/{id}, /artist/{id}
|
||||
if len(parts) == 2 {
|
||||
switch parts[0] {
|
||||
case "album", "track", "playlist", "artist":
|
||||
@@ -581,7 +1024,6 @@ func parseSpotifyURI(input string) (spotifyURI, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle nested playlist URLs: /user/{user}/playlist/{id}
|
||||
if len(parts) == 4 && parts[2] == "playlist" {
|
||||
return spotifyURI{Type: "playlist", ID: parts[3]}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
// Package gobackend - YouTube download via Cobalt API (lossy-only provider)
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type YouTubeDownloader struct {
|
||||
client *http.Client
|
||||
apiURL string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var (
|
||||
globalYouTubeDownloader *YouTubeDownloader
|
||||
youtubeDownloaderOnce sync.Once
|
||||
)
|
||||
|
||||
type YouTubeQuality string
|
||||
|
||||
const (
|
||||
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
||||
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
|
||||
)
|
||||
|
||||
type CobaltRequest struct {
|
||||
URL string `json:"url"`
|
||||
AudioBitrate string `json:"audioBitrate,omitempty"`
|
||||
AudioFormat string `json:"audioFormat,omitempty"`
|
||||
DownloadMode string `json:"downloadMode,omitempty"`
|
||||
FilenameStyle string `json:"filenameStyle,omitempty"`
|
||||
DisableMetadata bool `json:"disableMetadata,omitempty"`
|
||||
}
|
||||
|
||||
type CobaltResponse struct {
|
||||
Status string `json:"status"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Error *struct {
|
||||
Code string `json:"code"`
|
||||
Context *struct {
|
||||
Service string `json:"service,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
} `json:"context,omitempty"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type YouTubeDownloadResult struct {
|
||||
FilePath string
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
Format string // "opus" or "mp3"
|
||||
Bitrate int
|
||||
LyricsLRC string
|
||||
CoverData []byte
|
||||
}
|
||||
|
||||
func NewYouTubeDownloader() *YouTubeDownloader {
|
||||
youtubeDownloaderOnce.Do(func() {
|
||||
globalYouTubeDownloader = &YouTubeDownloader{
|
||||
client: NewHTTPClientWithTimeout(120 * time.Second),
|
||||
apiURL: "https://api.qwkuns.me",
|
||||
}
|
||||
})
|
||||
return globalYouTubeDownloader
|
||||
}
|
||||
|
||||
// SearchYouTube returns a YouTube Music search URL for the given track
|
||||
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||
searchQuery := url.QueryEscape(query)
|
||||
|
||||
GoLog("[YouTube] Search query: %s\n", query)
|
||||
|
||||
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
|
||||
|
||||
return youtubeMusicURL, nil
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
|
||||
var audioFormat string
|
||||
var audioBitrate string
|
||||
|
||||
switch quality {
|
||||
case YouTubeQualityOpus256:
|
||||
audioFormat = "opus"
|
||||
audioBitrate = "256"
|
||||
case YouTubeQualityMP3320:
|
||||
audioFormat = "mp3"
|
||||
audioBitrate = "320"
|
||||
default:
|
||||
audioFormat = "mp3"
|
||||
audioBitrate = "320"
|
||||
}
|
||||
|
||||
// Try SpotubeDL first (primary)
|
||||
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
|
||||
if extractErr == nil {
|
||||
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
|
||||
videoID, audioFormat, audioBitrate)
|
||||
|
||||
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
|
||||
} else {
|
||||
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
|
||||
}
|
||||
|
||||
// Fallback: direct Cobalt API (api.qwkuns.me)
|
||||
cobaltURL := toYouTubeMusicURL(youtubeURL)
|
||||
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
|
||||
cobaltURL, audioFormat, audioBitrate)
|
||||
|
||||
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// requestCobaltDirect sends a download request to the primary Cobalt API.
|
||||
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||
reqBody := CobaltRequest{
|
||||
URL: videoURL,
|
||||
AudioFormat: audioFormat,
|
||||
AudioBitrate: audioBitrate,
|
||||
DownloadMode: "audio",
|
||||
FilenameStyle: "basic",
|
||||
DisableMetadata: true,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cobalt API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var cobaltResp CobaltResponse
|
||||
if err := json.Unmarshal(body, &cobaltResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
|
||||
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
|
||||
}
|
||||
|
||||
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
|
||||
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
|
||||
}
|
||||
|
||||
if cobaltResp.URL == "" {
|
||||
return nil, fmt.Errorf("no download URL in response")
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
|
||||
return &cobaltResp, nil
|
||||
}
|
||||
|
||||
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
|
||||
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||
apiURL := fmt.Sprintf("https://spotubedl.com/api/download/%s?engine=v1&format=%s&quality=%s",
|
||||
videoID, audioFormat, audioBitrate)
|
||||
|
||||
GoLog("[YouTube] Requesting from SpotubeDL: %s\n", apiURL)
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("spotubedl request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] SpotubeDL response status: %d\n", resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("spotubedl returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
|
||||
}
|
||||
|
||||
if result.URL == "" {
|
||||
return nil, fmt.Errorf("no download URL from spotubedl")
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Got download URL from SpotubeDL\n")
|
||||
return &CobaltResponse{
|
||||
Status: "tunnel",
|
||||
URL: result.URL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(progressWriter, resp.Body)
|
||||
} else {
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Download completed: %d bytes written\n", written)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func BuildYouTubeSearchURL(trackName, artistName string) string {
|
||||
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
|
||||
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
|
||||
}
|
||||
|
||||
func BuildYouTubeWatchURL(videoID string) string {
|
||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||
}
|
||||
|
||||
// isYouTubeVideoID checks if s is an 11-char YouTube video ID
|
||||
func isYouTubeVideoID(s string) bool {
|
||||
if len(s) != 11 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func IsYouTubeURL(urlStr string) bool {
|
||||
lower := strings.ToLower(urlStr)
|
||||
return strings.Contains(lower, "youtube.com") ||
|
||||
strings.Contains(lower, "youtu.be") ||
|
||||
strings.Contains(lower, "music.youtube.com")
|
||||
}
|
||||
|
||||
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
|
||||
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
|
||||
func toYouTubeMusicURL(rawURL string) string {
|
||||
videoID, err := ExtractYouTubeVideoID(rawURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||
}
|
||||
|
||||
func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
||||
if strings.Contains(urlStr, "youtu.be/") {
|
||||
parts := strings.Split(urlStr, "youtu.be/")
|
||||
if len(parts) >= 2 {
|
||||
videoID := strings.Split(parts[1], "?")[0]
|
||||
videoID = strings.Split(videoID, "&")[0]
|
||||
return strings.TrimSpace(videoID), nil
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
// /watch?v=
|
||||
if v := parsed.Query().Get("v"); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// /embed/
|
||||
if strings.Contains(parsed.Path, "/embed/") {
|
||||
parts := strings.Split(parsed.Path, "/embed/")
|
||||
if len(parts) >= 2 {
|
||||
return strings.Split(parts[1], "/")[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
// /v/
|
||||
if strings.Contains(parsed.Path, "/v/") {
|
||||
parts := strings.Split(parsed.Path, "/v/")
|
||||
if len(parts) >= 2 {
|
||||
return strings.Split(parts[1], "/")[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not extract video ID from URL")
|
||||
}
|
||||
|
||||
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
||||
downloader := NewYouTubeDownloader()
|
||||
|
||||
var quality YouTubeQuality
|
||||
switch strings.ToLower(req.Quality) {
|
||||
case "opus_256", "opus256", "opus":
|
||||
quality = YouTubeQualityOpus256
|
||||
case "mp3_320", "mp3320", "mp3":
|
||||
quality = YouTubeQualityMP3320
|
||||
default:
|
||||
quality = YouTubeQualityMP3320 // Default to MP3 320kbps
|
||||
}
|
||||
|
||||
// URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC
|
||||
var youtubeURL string
|
||||
var lookupErr error
|
||||
|
||||
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
|
||||
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
|
||||
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
|
||||
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
|
||||
}
|
||||
|
||||
// Try Spotify ID via SongLink
|
||||
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
|
||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
|
||||
songlink := NewSongLinkClient()
|
||||
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
|
||||
if lookupErr != nil {
|
||||
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
|
||||
} else {
|
||||
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Try Deezer ID via SongLink
|
||||
if youtubeURL == "" && req.DeezerID != "" {
|
||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
|
||||
songlink := NewSongLinkClient()
|
||||
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
|
||||
if lookupErr != nil {
|
||||
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
|
||||
} else {
|
||||
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Try ISRC via SongLink
|
||||
if youtubeURL == "" && req.ISRC != "" {
|
||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
|
||||
songlink := NewSongLinkClient()
|
||||
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
|
||||
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
|
||||
youtubeURL = availability.YouTubeURL
|
||||
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
|
||||
} else if isrcErr != nil {
|
||||
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Cobalt requires direct video URLs, not search URLs
|
||||
if youtubeURL == "" {
|
||||
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
|
||||
|
||||
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
|
||||
if err != nil {
|
||||
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
var ext string
|
||||
var format string
|
||||
var bitrate int
|
||||
switch quality {
|
||||
case YouTubeQualityOpus256:
|
||||
ext = ".opus"
|
||||
format = "opus"
|
||||
bitrate = 256
|
||||
case YouTubeQualityMP3320:
|
||||
ext = ".mp3"
|
||||
format = "mp3"
|
||||
bitrate = 320
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
filename = sanitizeFilename(filename) + ext
|
||||
|
||||
var outputPath string
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
outputPath = req.OutputDir + "/" + filename
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Downloading to: %s\n", outputPath)
|
||||
|
||||
// Parallel fetch cover art + lyrics
|
||||
var parallelResult *ParallelDownloadResult
|
||||
if req.EmbedLyrics || req.CoverURL != "" {
|
||||
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
|
||||
parallelResult = FetchCoverAndLyricsParallel(
|
||||
req.CoverURL,
|
||||
req.EmbedMaxQualityCover,
|
||||
req.SpotifyID,
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
req.EmbedLyrics,
|
||||
int64(req.DurationMS),
|
||||
)
|
||||
}
|
||||
|
||||
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
var coverData []byte
|
||||
if parallelResult != nil {
|
||||
if parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
|
||||
}
|
||||
if parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
|
||||
}
|
||||
}
|
||||
|
||||
return YouTubeDownloadResult{
|
||||
FilePath: outputPath,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
Format: format,
|
||||
Bitrate: bitrate,
|
||||
LyricsLRC: lyricsLRC,
|
||||
CoverData: coverData,
|
||||
}, nil
|
||||
}
|
||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 70 KiB |
@@ -1 +1,5 @@
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
// Go backend framework (Gobackend.xcframework)
|
||||
FRAMEWORK_SEARCH_PATHS=$(inherited) $(PROJECT_DIR)/Frameworks
|
||||
OTHER_LDFLAGS=$(inherited) -framework Gobackend
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
// Go backend framework (Gobackend.xcframework)
|
||||
FRAMEWORK_SEARCH_PATHS=$(inherited) $(PROJECT_DIR)/Frameworks
|
||||
OTHER_LDFLAGS=$(inherited) -framework Gobackend
|
||||
|
||||
@@ -46,6 +46,11 @@ post_install do |installer|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0'
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)']
|
||||
definitions = config.build_settings['GCC_PREPROCESSOR_DEFINITIONS']
|
||||
unless definitions.include?('PERMISSION_NOTIFICATIONS=1')
|
||||
definitions << 'PERMISSION_NOTIFICATIONS=1'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||